added backup and email client

This commit is contained in:
2026-02-01 00:02:35 +01:00
parent ff857be01a
commit e4fdfbc95f
210 changed files with 24211 additions and 742 deletions
File diff suppressed because it is too large Load Diff
+957
View File
@@ -0,0 +1,957 @@
// ==================== CACHED EMAIL SERVICE ====================
// Service für E-Mail-Caching und Vertragszuordnung
import { PrismaClient, CachedEmail, Prisma, EmailFolder } from '@prisma/client';
import { decrypt } from '../utils/encryption.js';
import { fetchEmails, ImapCredentials, FetchedEmail, moveToTrash, restoreFromTrash, permanentDelete } from './imapService.js';
import { getImapSmtpSettings } from './emailProvider/emailProviderService.js';
const prisma = new PrismaClient();
// ==================== TYPES ====================
export interface CachedEmailWithRelations extends CachedEmail {
stressfreiEmail?: {
id: number;
email: string;
customerId: number;
};
contract?: {
id: number;
contractNumber: string;
} | null;
}
// Parameter für gesendete E-Mail
export interface SentEmailParams {
to: string[];
cc?: string[];
subject: string;
text?: string;
html?: string;
messageId: string;
contractId?: number; // Optional: Vertrag dem die E-Mail zugeordnet wird
attachmentNames?: string[]; // Namen der Anhänge
uid?: number; // UID im IMAP Sent-Ordner (für Attachment-Download)
}
export interface SyncResult {
success: boolean;
newEmails: number;
totalEmails: number;
error?: string;
}
export interface EmailListOptions {
stressfreiEmailId?: number;
customerId?: number;
contractId?: number;
folder?: string;
limit?: number;
offset?: number;
includeBody?: boolean;
}
// ==================== SYNC FUNCTIONS ====================
// E-Mails für eine StressfreiEmail synchronisieren
export async function syncEmailsForAccount(
stressfreiEmailId: number,
options?: { folder?: string; fullSync?: boolean }
): Promise<SyncResult> {
const folder = options?.folder || 'INBOX';
const fullSync = options?.fullSync || false;
try {
// StressfreiEmail mit Mailbox-Daten laden
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
where: { id: stressfreiEmailId },
});
if (!stressfreiEmail) {
return { success: false, newEmails: 0, totalEmails: 0, error: 'StressfreiEmail nicht gefunden' };
}
if (!stressfreiEmail.hasMailbox || !stressfreiEmail.emailPasswordEncrypted) {
return { success: false, newEmails: 0, totalEmails: 0, error: 'Keine Mailbox für diese E-Mail-Adresse' };
}
// IMAP/SMTP-Einstellungen vom Provider holen
const settings = await getImapSmtpSettings();
if (!settings) {
return { success: false, newEmails: 0, totalEmails: 0, error: 'Keine E-Mail-Provider-Einstellungen gefunden' };
}
// Passwort entschlüsseln
let password: string;
try {
password = decrypt(stressfreiEmail.emailPasswordEncrypted);
} catch {
return { success: false, newEmails: 0, totalEmails: 0, error: 'Passwort konnte nicht entschlüsselt werden' };
}
// IMAP-Credentials zusammenstellen
const credentials: ImapCredentials = {
host: settings.imapServer,
port: settings.imapPort,
user: stressfreiEmail.email,
password,
encryption: settings.imapEncryption,
allowSelfSignedCerts: settings.allowSelfSignedCerts,
};
// Folder-Mapping: IMAP-Ordner zu DB-Ordner
const dbFolder = folder.toUpperCase() === 'SENT' || folder.toLowerCase() === 'sent'
? EmailFolder.SENT
: EmailFolder.INBOX;
// Für inkrementellen Sync: Höchste UID des Ordners ermitteln
let sinceUid: number | undefined;
if (!fullSync) {
const lastEmail = await prisma.cachedEmail.findFirst({
where: { stressfreiEmailId, folder: dbFolder },
orderBy: { uid: 'desc' },
select: { uid: true },
});
if (lastEmail) {
sinceUid = lastEmail.uid;
}
}
// E-Mails vom IMAP-Server abrufen
const fetchedEmails = await fetchEmails(credentials, {
folder,
sinceUid,
limit: fullSync ? 500 : 100, // Mehr bei Full-Sync
});
// Neue E-Mails in DB speichern
let newCount = 0;
for (const email of fetchedEmails) {
const created = await upsertCachedEmail(stressfreiEmailId, email, dbFolder);
if (created) newCount++;
}
// Gesamtzahl ermitteln
const totalEmails = await prisma.cachedEmail.count({
where: { stressfreiEmailId },
});
return {
success: true,
newEmails: newCount,
totalEmails,
};
} catch (error) {
console.error('syncEmailsForAccount error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
return {
success: false,
newEmails: 0,
totalEmails: 0,
error: errorMessage,
};
}
}
// Alle Ordner synchronisieren (INBOX + SENT)
export async function syncAllFoldersForAccount(
stressfreiEmailId: number,
options?: { fullSync?: boolean }
): Promise<SyncResult> {
const fullSync = options?.fullSync || false;
// INBOX synchronisieren
const inboxResult = await syncEmailsForAccount(stressfreiEmailId, { folder: 'INBOX', fullSync });
// SENT synchronisieren (Plesk verwendet "Sent" als Ordnername)
const sentResult = await syncEmailsForAccount(stressfreiEmailId, { folder: 'Sent', fullSync });
// Ergebnisse kombinieren
if (!inboxResult.success && !sentResult.success) {
return {
success: false,
newEmails: 0,
totalEmails: 0,
error: inboxResult.error || sentResult.error || 'Synchronisation fehlgeschlagen',
};
}
return {
success: true,
newEmails: inboxResult.newEmails + sentResult.newEmails,
totalEmails: inboxResult.totalEmails,
};
}
// Einzelne E-Mail in DB speichern/aktualisieren
async function upsertCachedEmail(
stressfreiEmailId: number,
email: FetchedEmail,
folder: EmailFolder = EmailFolder.INBOX
): Promise<boolean> {
try {
// Prüfen ob bereits vorhanden (via messageId + folder)
// WICHTIG: Gleiche messageId kann in INBOX und SENT existieren (z.B. E-Mail an sich selbst)
const existing = await prisma.cachedEmail.findUnique({
where: {
stressfreiEmailId_messageId_folder: {
stressfreiEmailId,
messageId: email.messageId,
folder,
},
},
});
if (existing) {
// Bereits vorhanden
return false;
}
// Neue E-Mail anlegen
await prisma.cachedEmail.create({
data: {
stressfreiEmailId,
folder,
messageId: email.messageId,
uid: email.uid,
subject: email.subject,
fromAddress: email.fromAddress,
fromName: email.fromName,
toAddresses: JSON.stringify(email.toAddresses),
ccAddresses: email.ccAddresses.length > 0 ? JSON.stringify(email.ccAddresses) : null,
receivedAt: email.date,
textBody: email.textBody,
htmlBody: email.htmlBody,
hasAttachments: email.hasAttachments,
attachmentNames: email.attachmentNames.length > 0
? JSON.stringify(email.attachmentNames)
: null,
isRead: folder === EmailFolder.SENT, // Gesendete E-Mails sind bereits gelesen
isStarred: false,
},
});
return true;
} catch (error) {
// Duplikat-Fehler ignorieren (Race Condition)
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
return false;
}
throw error;
}
}
// ==================== CRUD FUNCTIONS ====================
// E-Mails abrufen mit Optionen
export async function getCachedEmails(
options: EmailListOptions
): Promise<CachedEmailWithRelations[]> {
const where: Prisma.CachedEmailWhereInput = {
isDeleted: false, // Gelöschte E-Mails ausschließen
};
if (options.stressfreiEmailId) {
where.stressfreiEmailId = options.stressfreiEmailId;
}
if (options.customerId) {
where.stressfreiEmail = {
customerId: options.customerId,
};
}
if (options.contractId) {
where.contractId = options.contractId;
}
// Folder-Filter (INBOX oder SENT)
if (options.folder === 'SENT') {
where.folder = EmailFolder.SENT;
} else if (options.folder === 'INBOX' || !options.folder) {
// Standard: Nur Posteingang
where.folder = EmailFolder.INBOX;
}
// Body-Felder nur wenn explizit angefordert (spart Bandbreite)
const select: Prisma.CachedEmailSelect = {
id: true,
stressfreiEmailId: true,
folder: true,
messageId: true,
uid: true,
subject: true,
fromAddress: true,
fromName: true,
toAddresses: true,
ccAddresses: true,
receivedAt: true,
hasAttachments: true,
attachmentNames: true,
contractId: true,
assignedAt: true,
assignedBy: true,
isAutoAssigned: true,
isRead: true,
isStarred: true,
createdAt: true,
updatedAt: true,
stressfreiEmail: {
select: {
id: true,
email: true,
customerId: true,
},
},
contract: {
select: {
id: true,
contractNumber: true,
},
},
};
if (options.includeBody) {
select.textBody = true;
select.htmlBody = true;
}
const emails = await prisma.cachedEmail.findMany({
where,
select,
orderBy: { receivedAt: 'desc' },
take: options.limit || 50,
skip: options.offset || 0,
});
return emails as CachedEmailWithRelations[];
}
// Einzelne E-Mail abrufen (mit Body)
export async function getCachedEmailById(
id: number
): Promise<CachedEmailWithRelations | null> {
const email = await prisma.cachedEmail.findUnique({
where: { id },
include: {
stressfreiEmail: {
select: {
id: true,
email: true,
customerId: true,
},
},
contract: {
select: {
id: true,
contractNumber: true,
},
},
},
});
return email as CachedEmailWithRelations | null;
}
// E-Mail als gelesen markieren
export async function markEmailAsRead(id: number): Promise<void> {
await prisma.cachedEmail.update({
where: { id },
data: { isRead: true },
});
}
// E-Mail als ungelesen markieren
export async function markEmailAsUnread(id: number): Promise<void> {
await prisma.cachedEmail.update({
where: { id },
data: { isRead: false },
});
}
// E-Mail Stern setzen/entfernen
export async function toggleEmailStar(id: number): Promise<boolean> {
const email = await prisma.cachedEmail.findUnique({
where: { id },
select: { isStarred: true },
});
if (!email) {
throw new Error('E-Mail nicht gefunden');
}
const newValue = !email.isStarred;
await prisma.cachedEmail.update({
where: { id },
data: { isStarred: newValue },
});
return newValue;
}
// ==================== CONTRACT ASSIGNMENT ====================
// E-Mail einem Vertrag zuordnen
export async function assignEmailToContract(
emailId: number,
contractId: number,
userId?: number
): Promise<CachedEmailWithRelations> {
// Prüfen ob E-Mail existiert
const email = await prisma.cachedEmail.findUnique({
where: { id: emailId },
});
if (!email) {
throw new Error('E-Mail nicht gefunden');
}
// Prüfen ob Vertrag existiert
const contract = await prisma.contract.findUnique({
where: { id: contractId },
});
if (!contract) {
throw new Error('Vertrag nicht gefunden');
}
// Zuordnung setzen (manuell)
const updated = await prisma.cachedEmail.update({
where: { id: emailId },
data: {
contractId,
assignedAt: new Date(),
assignedBy: userId || null,
isAutoAssigned: false, // Manuell zugeordnet
},
include: {
stressfreiEmail: {
select: {
id: true,
email: true,
customerId: true,
},
},
contract: {
select: {
id: true,
contractNumber: true,
},
},
},
});
return updated as CachedEmailWithRelations;
}
// Vertragszuordnung aufheben
export async function unassignEmailFromContract(
emailId: number
): Promise<CachedEmailWithRelations> {
const updated = await prisma.cachedEmail.update({
where: { id: emailId },
data: {
contractId: null,
assignedAt: null,
assignedBy: null,
},
include: {
stressfreiEmail: {
select: {
id: true,
email: true,
customerId: true,
},
},
contract: {
select: {
id: true,
contractNumber: true,
},
},
},
});
return updated as CachedEmailWithRelations;
}
// ==================== HELPER FUNCTIONS ====================
// Anzahl ungelesener E-Mails für Kunde
export async function getUnreadCountForCustomer(customerId: number): Promise<number> {
return prisma.cachedEmail.count({
where: {
stressfreiEmail: {
customerId,
},
isRead: false,
},
});
}
// Anzahl ungelesener E-Mails für Vertrag
export async function getUnreadCountForContract(contractId: number): Promise<number> {
return prisma.cachedEmail.count({
where: {
contractId,
isRead: false,
},
});
}
// E-Mail-Anzahl pro Ordner für ein Konto (total und ungelesen)
export async function getFolderCountsForAccount(stressfreiEmailId: number): Promise<{
inbox: number;
inboxUnread: number;
sent: number;
sentUnread: number;
trash: number;
trashUnread: number;
}> {
const [inbox, inboxUnread, sent, sentUnread, trash, trashUnread] = await Promise.all([
// INBOX total
prisma.cachedEmail.count({
where: { stressfreiEmailId, folder: EmailFolder.INBOX, isDeleted: false },
}),
// INBOX unread
prisma.cachedEmail.count({
where: { stressfreiEmailId, folder: EmailFolder.INBOX, isDeleted: false, isRead: false },
}),
// SENT total
prisma.cachedEmail.count({
where: { stressfreiEmailId, folder: EmailFolder.SENT, isDeleted: false },
}),
// SENT unread
prisma.cachedEmail.count({
where: { stressfreiEmailId, folder: EmailFolder.SENT, isDeleted: false, isRead: false },
}),
// TRASH total (isDeleted = true)
prisma.cachedEmail.count({
where: { stressfreiEmailId, isDeleted: true },
}),
// TRASH unread
prisma.cachedEmail.count({
where: { stressfreiEmailId, isDeleted: true, isRead: false },
}),
]);
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
}
// E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails)
export async function getFolderCountsForContract(contractId: number): Promise<{
inbox: number;
inboxUnread: number;
sent: number;
sentUnread: number;
}> {
const [inbox, inboxUnread, sent, sentUnread] = await Promise.all([
// INBOX total
prisma.cachedEmail.count({
where: { contractId, folder: EmailFolder.INBOX, isDeleted: false },
}),
// INBOX unread
prisma.cachedEmail.count({
where: { contractId, folder: EmailFolder.INBOX, isDeleted: false, isRead: false },
}),
// SENT total
prisma.cachedEmail.count({
where: { contractId, folder: EmailFolder.SENT, isDeleted: false },
}),
// SENT unread
prisma.cachedEmail.count({
where: { contractId, folder: EmailFolder.SENT, isDeleted: false, isRead: false },
}),
]);
return { inbox, inboxUnread, sent, sentUnread };
}
// Alle StressfreiEmails eines Kunden mit Mailbox
export async function getMailboxAccountsForCustomer(customerId: number) {
return prisma.stressfreiEmail.findMany({
where: {
customerId,
hasMailbox: true,
},
select: {
id: true,
email: true,
notes: true,
_count: {
select: {
cachedEmails: true,
},
},
},
orderBy: { email: 'asc' },
});
}
// E-Mail-Thread finden (basierend auf References/In-Reply-To)
export async function getEmailThread(emailId: number): Promise<CachedEmailWithRelations[]> {
const email = await prisma.cachedEmail.findUnique({
where: { id: emailId },
});
if (!email) {
return [];
}
// Suche nach E-Mails mit dem gleichen Betreff (vereinfachte Thread-Logik)
// In einer vollständigen Implementierung würde man References/In-Reply-To parsen
const baseSubject = email.subject?.replace(/^(Re|Fwd|Aw|Wg):\s*/gi, '') || '';
if (!baseSubject) {
return [email as CachedEmailWithRelations];
}
const thread = await prisma.cachedEmail.findMany({
where: {
stressfreiEmailId: email.stressfreiEmailId,
OR: [
{ subject: baseSubject },
{ subject: { startsWith: `Re: ${baseSubject}` } },
{ subject: { startsWith: `Aw: ${baseSubject}` } },
{ subject: { startsWith: `Fwd: ${baseSubject}` } },
{ subject: { startsWith: `Wg: ${baseSubject}` } },
],
},
include: {
stressfreiEmail: {
select: {
id: true,
email: true,
customerId: true,
},
},
contract: {
select: {
id: true,
contractNumber: true,
},
},
},
orderBy: { receivedAt: 'asc' },
});
return thread as CachedEmailWithRelations[];
}
// ==================== TRASH OPERATIONS ====================
export interface TrashOperationResult {
success: boolean;
error?: string;
}
// E-Mail in Papierkorb verschieben (markiert als gelöscht + IMAP-Move)
export async function moveEmailToTrash(id: number): Promise<TrashOperationResult> {
// E-Mail mit StressfreiEmail laden
const email = await prisma.cachedEmail.findUnique({
where: { id },
include: {
stressfreiEmail: true,
},
});
if (!email) {
return { success: false, error: 'E-Mail nicht gefunden' };
}
if (email.isDeleted) {
return { success: false, error: 'E-Mail ist bereits im Papierkorb' };
}
// IMAP-Einstellungen und Credentials laden
const settings = await getImapSmtpSettings();
if (!settings) {
return { success: false, error: 'Keine E-Mail-Provider-Einstellungen' };
}
if (!email.stressfreiEmail.emailPasswordEncrypted) {
return { success: false, error: 'Keine Mailbox-Zugangsdaten' };
}
let password: string;
try {
password = decrypt(email.stressfreiEmail.emailPasswordEncrypted);
} catch {
return { success: false, error: 'Passwort konnte nicht entschlüsselt werden' };
}
const credentials: ImapCredentials = {
host: settings.imapServer,
port: settings.imapPort,
user: email.stressfreiEmail.email,
password,
encryption: settings.imapEncryption,
allowSelfSignedCerts: settings.allowSelfSignedCerts,
};
// Ordner bestimmen (INBOX oder Sent)
const sourceFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
// Auf IMAP-Server in Trash verschieben
const imapResult = await moveToTrash(credentials, email.uid, sourceFolder);
if (!imapResult.success) {
return { success: false, error: imapResult.error || 'IMAP-Fehler beim Verschieben' };
}
// In DB als gelöscht markieren (mit neuer UID im Trash)
await prisma.cachedEmail.update({
where: { id },
data: {
isDeleted: true,
deletedAt: new Date(),
uid: imapResult.newUid || email.uid, // Neue UID im Trash speichern
},
});
return { success: true };
}
// E-Mail aus Papierkorb wiederherstellen
export async function restoreEmailFromTrash(id: number): Promise<TrashOperationResult> {
// E-Mail laden
const email = await prisma.cachedEmail.findUnique({
where: { id },
include: {
stressfreiEmail: true,
},
});
if (!email) {
return { success: false, error: 'E-Mail nicht gefunden' };
}
if (!email.isDeleted) {
return { success: false, error: 'E-Mail ist nicht im Papierkorb' };
}
// IMAP-Einstellungen und Credentials
const settings = await getImapSmtpSettings();
if (!settings) {
return { success: false, error: 'Keine E-Mail-Provider-Einstellungen' };
}
if (!email.stressfreiEmail.emailPasswordEncrypted) {
return { success: false, error: 'Keine Mailbox-Zugangsdaten' };
}
let password: string;
try {
password = decrypt(email.stressfreiEmail.emailPasswordEncrypted);
} catch {
return { success: false, error: 'Passwort konnte nicht entschlüsselt werden' };
}
const credentials: ImapCredentials = {
host: settings.imapServer,
port: settings.imapPort,
user: email.stressfreiEmail.email,
password,
encryption: settings.imapEncryption,
allowSelfSignedCerts: settings.allowSelfSignedCerts,
};
// Ziel-Ordner bestimmen (basierend auf originalem Ordner)
const targetFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
// Auf IMAP-Server aus Trash wiederherstellen
const imapResult = await restoreFromTrash(credentials, email.uid, targetFolder);
if (!imapResult.success) {
return { success: false, error: imapResult.error || 'IMAP-Fehler beim Wiederherstellen' };
}
// In DB als wiederhergestellt markieren
await prisma.cachedEmail.update({
where: { id },
data: {
isDeleted: false,
deletedAt: null,
uid: imapResult.newUid || email.uid, // Neue UID im wiederhergestellten Ordner
},
});
return { success: true };
}
// E-Mail endgültig löschen (aus Papierkorb)
export async function permanentDeleteEmail(id: number): Promise<TrashOperationResult> {
// E-Mail laden
const email = await prisma.cachedEmail.findUnique({
where: { id },
include: {
stressfreiEmail: true,
},
});
if (!email) {
return { success: false, error: 'E-Mail nicht gefunden' };
}
if (!email.isDeleted) {
return { success: false, error: 'E-Mail muss erst in den Papierkorb verschoben werden' };
}
// IMAP-Einstellungen und Credentials
const settings = await getImapSmtpSettings();
if (!settings) {
return { success: false, error: 'Keine E-Mail-Provider-Einstellungen' };
}
if (!email.stressfreiEmail.emailPasswordEncrypted) {
return { success: false, error: 'Keine Mailbox-Zugangsdaten' };
}
let password: string;
try {
password = decrypt(email.stressfreiEmail.emailPasswordEncrypted);
} catch {
return { success: false, error: 'Passwort konnte nicht entschlüsselt werden' };
}
const credentials: ImapCredentials = {
host: settings.imapServer,
port: settings.imapPort,
user: email.stressfreiEmail.email,
password,
encryption: settings.imapEncryption,
allowSelfSignedCerts: settings.allowSelfSignedCerts,
};
// Auf IMAP-Server endgültig löschen (aus Trash)
const imapResult = await permanentDelete(credentials, email.uid);
if (!imapResult.success) {
return { success: false, error: imapResult.error || 'IMAP-Fehler beim endgültigen Löschen' };
}
// Aus DB löschen
await prisma.cachedEmail.delete({
where: { id },
});
return { success: true };
}
// Papierkorb-E-Mails für einen Kunden abrufen
export async function getTrashEmails(customerId: number): Promise<CachedEmailWithRelations[]> {
return prisma.cachedEmail.findMany({
where: {
isDeleted: true,
stressfreiEmail: {
customerId,
},
},
include: {
stressfreiEmail: {
select: {
id: true,
email: true,
customerId: true,
},
},
contract: {
select: {
id: true,
contractNumber: true,
},
},
},
orderBy: { deletedAt: 'desc' },
}) as Promise<CachedEmailWithRelations[]>;
}
// Papierkorb-E-Mails zählen
export async function getTrashCount(customerId: number): Promise<number> {
return prisma.cachedEmail.count({
where: {
isDeleted: true,
stressfreiEmail: {
customerId,
},
},
});
}
// Legacy: E-Mail löschen (jetzt deprecated, nutze moveEmailToTrash)
export async function deleteCachedEmail(id: number): Promise<void> {
// Zur Abwärtskompatibilität: In Papierkorb verschieben statt löschen
const result = await moveEmailToTrash(id);
if (!result.success) {
throw new Error(result.error || 'Fehler beim Löschen');
}
}
// Alle gecachten E-Mails für eine StressfreiEmail löschen (nur Cache, kein IMAP)
export async function clearCacheForAccount(stressfreiEmailId: number): Promise<number> {
const result = await prisma.cachedEmail.deleteMany({
where: { stressfreiEmailId },
});
return result.count;
}
// ==================== SENT EMAILS ====================
// Gesendete E-Mail speichern
export async function createSentEmail(
stressfreiEmailId: number,
params: SentEmailParams
): Promise<CachedEmail> {
// StressfreiEmail laden um Absender-Info zu bekommen
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
where: { id: stressfreiEmailId },
select: { email: true },
});
if (!stressfreiEmail) {
throw new Error('StressfreiEmail nicht gefunden');
}
const hasAttachments = params.attachmentNames && params.attachmentNames.length > 0;
const email = await prisma.cachedEmail.create({
data: {
stressfreiEmailId,
folder: EmailFolder.SENT,
messageId: params.messageId,
uid: params.uid || 0, // UID vom IMAP Sent-Ordner (für Attachment-Download)
subject: params.subject || null,
fromAddress: stressfreiEmail.email,
fromName: null,
toAddresses: JSON.stringify(params.to),
ccAddresses: params.cc && params.cc.length > 0 ? JSON.stringify(params.cc) : null,
receivedAt: new Date(), // Sendezeitpunkt
textBody: params.text || null,
htmlBody: params.html || null,
hasAttachments: hasAttachments || false,
attachmentNames: hasAttachments ? JSON.stringify(params.attachmentNames) : null,
isRead: true, // Gesendete sind immer "gelesen"
isStarred: false,
// Vertragszuordnung falls angegeben (automatisch = aus Vertrag gesendet)
contractId: params.contractId || null,
assignedAt: params.contractId ? new Date() : null,
isAutoAssigned: params.contractId ? true : false, // Automatisch wenn aus Vertrag gesendet
},
});
return email;
}
// Anzahl E-Mails für Konto nach Ordner
export async function getEmailCountByFolder(
stressfreiEmailId: number,
folder: 'INBOX' | 'SENT'
): Promise<number> {
return prisma.cachedEmail.count({
where: {
stressfreiEmailId,
folder: folder === 'SENT' ? EmailFolder.SENT : EmailFolder.INBOX,
},
});
}
@@ -8,6 +8,7 @@ import {
EmailExistsResult,
EmailOperationResult,
CreateEmailParams,
MailEncryption,
} from './types.js';
import { PleskEmailProvider } from './pleskProvider.js';
@@ -68,6 +69,10 @@ export interface CreateProviderConfigData {
password?: string;
domain: string;
defaultForwardEmail?: string;
// Verschlüsselungs-Einstellungen
imapEncryption?: MailEncryption;
smtpEncryption?: MailEncryption;
allowSelfSignedCerts?: boolean;
isActive?: boolean;
isDefault?: boolean;
}
@@ -95,6 +100,9 @@ export async function createProviderConfig(data: CreateProviderConfigData) {
passwordEncrypted,
domain: data.domain,
defaultForwardEmail: data.defaultForwardEmail || null,
imapEncryption: data.imapEncryption ?? 'SSL',
smtpEncryption: data.smtpEncryption ?? 'SSL',
allowSelfSignedCerts: data.allowSelfSignedCerts ?? false,
isActive: data.isActive ?? true,
isDefault: data.isDefault ?? false,
},
@@ -123,6 +131,9 @@ export async function updateProviderConfig(
if (data.domain !== undefined) updateData.domain = data.domain;
if (data.defaultForwardEmail !== undefined)
updateData.defaultForwardEmail = data.defaultForwardEmail || null;
if (data.imapEncryption !== undefined) updateData.imapEncryption = data.imapEncryption;
if (data.smtpEncryption !== undefined) updateData.smtpEncryption = data.smtpEncryption;
if (data.allowSelfSignedCerts !== undefined) updateData.allowSelfSignedCerts = data.allowSelfSignedCerts;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
@@ -179,6 +190,13 @@ async function getProviderInstance(): Promise<IEmailProvider> {
password,
domain: dbConfig.domain,
defaultForwardEmail: dbConfig.defaultForwardEmail || undefined,
imapServer: dbConfig.imapServer || undefined,
imapPort: dbConfig.imapPort || undefined,
smtpServer: dbConfig.smtpServer || undefined,
smtpPort: dbConfig.smtpPort || undefined,
imapEncryption: dbConfig.imapEncryption as MailEncryption,
smtpEncryption: dbConfig.smtpEncryption as MailEncryption,
allowSelfSignedCerts: dbConfig.allowSelfSignedCerts,
isActive: dbConfig.isActive,
isDefault: dbConfig.isDefault,
};
@@ -239,6 +257,169 @@ export async function provisionEmail(
}
}
// E-Mail mit echter Mailbox erstellen (IMAP/SMTP-Zugang)
export async function provisionEmailWithMailbox(
localPart: string,
customerEmail: string,
password: string
): Promise<EmailOperationResult & { email?: string }> {
try {
const provider = await getProviderInstance();
const config = await getActiveProviderConfig();
// Weiterleitungsziele zusammenstellen
const forwardTargets: string[] = [customerEmail];
// Unsere eigene Weiterleitungsadresse hinzufügen falls konfiguriert
if (config?.defaultForwardEmail) {
forwardTargets.push(config.defaultForwardEmail);
}
// Prüfen ob existiert
const exists = await provider.emailExists(localPart);
if (exists.exists) {
return {
success: true,
message: `E-Mail ${exists.email} existiert bereits`,
email: exists.email,
};
}
// Mit Mailbox erstellen
const result = await provider.createEmailWithMailbox({
localPart,
forwardTargets,
password,
});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
return {
success: false,
error: errorMessage,
};
}
}
// Mailbox für existierende E-Mail-Weiterleitung aktivieren
export async function enableMailboxForExistingEmail(
localPart: string,
password: string
): Promise<EmailOperationResult> {
try {
const provider = await getProviderInstance();
const result = await provider.enableMailboxForExisting({
localPart,
password,
});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
return {
success: false,
error: errorMessage,
};
}
}
// Mailbox-Passwort beim Provider aktualisieren
export async function updateMailboxPassword(
localPart: string,
password: string
): Promise<EmailOperationResult> {
try {
const provider = await getProviderInstance();
const result = await provider.updateMailboxPassword({
localPart,
password,
});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
return {
success: false,
error: errorMessage,
};
}
}
// IMAP/SMTP-Einstellungen vom aktiven Provider holen
export interface ImapSmtpSettings {
imapServer: string;
imapPort: number;
imapEncryption: MailEncryption; // SSL, STARTTLS oder NONE
smtpServer: string;
smtpPort: number;
smtpEncryption: MailEncryption; // SSL, STARTTLS oder NONE
allowSelfSignedCerts: boolean; // Selbstsignierte Zertifikate erlauben
domain: string;
}
export async function getImapSmtpSettings(): Promise<ImapSmtpSettings | null> {
const config = await getActiveProviderConfig();
if (!config) return null;
// Default-Server: Hostname aus der apiUrl extrahieren (z.B. rs001871.fastrootserver.de aus https://rs001871.fastrootserver.de:8443)
// Der Plesk-Server ist gleichzeitig der Mail-Server
let defaultServer: string;
try {
const url = new URL(config.apiUrl);
defaultServer = url.hostname;
} catch {
// Fallback falls apiUrl ungültig
defaultServer = `mail.${config.domain}`;
}
// Verschlüsselungs-Einstellungen
const imapEncryption = (config.imapEncryption ?? 'SSL') as MailEncryption;
const smtpEncryption = (config.smtpEncryption ?? 'SSL') as MailEncryption;
// Ports basierend auf Verschlüsselung berechnen:
// SSL: IMAP 993, SMTP 465
// STARTTLS: IMAP 143, SMTP 587
// NONE: IMAP 143, SMTP 25
//
// Standard-Ports werden IMMER basierend auf Verschlüsselung berechnet.
// Nur benutzerdefinierte Ports (nicht 993/143/465/587/25) werden aus der DB übernommen.
const getImapPort = (enc: MailEncryption, storedPort: number | null) => {
const standardPorts = [993, 143];
// Wenn ein nicht-standard Port gespeichert ist, diesen verwenden
if (storedPort && !standardPorts.includes(storedPort)) {
return storedPort;
}
// Sonst basierend auf Verschlüsselung
return enc === 'SSL' ? 993 : 143;
};
const getSmtpPort = (enc: MailEncryption, storedPort: number | null) => {
const standardPorts = [465, 587, 25];
// Wenn ein nicht-standard Port gespeichert ist, diesen verwenden
if (storedPort && !standardPorts.includes(storedPort)) {
return storedPort;
}
// Sonst basierend auf Verschlüsselung
if (enc === 'SSL') return 465;
if (enc === 'STARTTLS') return 587;
return 25; // NONE
};
return {
imapServer: config.imapServer || defaultServer,
imapPort: getImapPort(imapEncryption, config.imapPort),
imapEncryption,
smtpServer: config.smtpServer || defaultServer,
smtpPort: getSmtpPort(smtpEncryption, config.smtpPort),
smtpEncryption,
allowSelfSignedCerts: config.allowSelfSignedCerts ?? false,
domain: config.domain,
};
}
// E-Mail löschen
export async function deprovisionEmail(localPart: string): Promise<EmailOperationResult> {
try {
@@ -328,6 +509,13 @@ async function getProviderInstanceById(id: number): Promise<IEmailProvider> {
password,
domain: dbConfig.domain,
defaultForwardEmail: dbConfig.defaultForwardEmail || undefined,
imapServer: dbConfig.imapServer || undefined,
imapPort: dbConfig.imapPort || undefined,
smtpServer: dbConfig.smtpServer || undefined,
smtpPort: dbConfig.smtpPort || undefined,
imapEncryption: dbConfig.imapEncryption as MailEncryption,
smtpEncryption: dbConfig.smtpEncryption as MailEncryption,
allowSelfSignedCerts: dbConfig.allowSelfSignedCerts,
isActive: dbConfig.isActive,
isDefault: dbConfig.isDefault,
};
@@ -7,6 +7,10 @@ import {
EmailExistsResult,
EmailOperationResult,
CreateEmailParams,
CreateEmailWithMailboxParams,
CreateEmailWithMailboxResult,
EnableMailboxParams,
UpdateMailboxPasswordParams,
RenameEmailParams,
} from './types.js';
@@ -173,9 +177,20 @@ export class PleskEmailProvider implements IEmailProvider {
// stdout sollte die Mail-Infos enthalten
const exists = result.stdout?.toLowerCase().includes(localPart.toLowerCase());
// Mailbox-Status aus stdout parsen (Format: "Mailbox: true" oder "Mailbox: false")
let hasMailbox: boolean | undefined;
if (exists && result.stdout) {
const mailboxMatch = result.stdout.match(/Mailbox:\s*(true|false)/i);
if (mailboxMatch) {
hasMailbox = mailboxMatch[1].toLowerCase() === 'true';
}
}
return {
exists,
email: exists ? email : undefined,
hasMailbox,
};
} catch (error) {
// HTTP-Fehler oder Netzwerkfehler
@@ -231,6 +246,127 @@ export class PleskEmailProvider implements IEmailProvider {
}
}
async createEmailWithMailbox(params: CreateEmailWithMailboxParams): Promise<CreateEmailWithMailboxResult> {
const { localPart, forwardTargets, password } = params;
const email = `${localPart}@${this.config.domain}`;
try {
// Prüfen ob schon existiert
const exists = await this.emailExists(localPart);
if (exists.exists) {
return {
success: false,
error: `E-Mail ${email} existiert bereits`,
};
}
// Plesk CLI API: Mail-Account mit echter Mailbox erstellen
// -mailbox true: Echte Mailbox (IMAP/SMTP-Zugang)
// -passwd: Passwort für die Mailbox
// -forwarding true: Zusätzlich Weiterleitung aktivieren
await this.request('POST', '/api/v2/cli/mail/call', {
params: [
'--create', email,
'-mailbox', 'true',
'-passwd', password,
'-forwarding', 'true',
'-forwarding-addresses', `add:${forwardTargets.join(',')}`,
],
});
return {
success: true,
message: `E-Mail ${email} mit Mailbox erfolgreich erstellt`,
email,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
console.error('Plesk createEmailWithMailbox error:', error);
return {
success: false,
error: `Fehler beim Erstellen der E-Mail mit Mailbox: ${errorMessage}`,
};
}
}
async enableMailboxForExisting(params: EnableMailboxParams): Promise<EmailOperationResult> {
const { localPart, password } = params;
const email = `${localPart}@${this.config.domain}`;
try {
// Prüfen ob E-Mail existiert
const exists = await this.emailExists(localPart);
if (!exists.exists) {
return {
success: false,
error: `E-Mail ${email} nicht gefunden`,
};
}
// Plesk CLI API: Mailbox für existierende E-Mail aktivieren
// --update: Existierende E-Mail aktualisieren
// -mailbox true: Mailbox aktivieren
// -passwd: Passwort für die Mailbox setzen
await this.request('POST', '/api/v2/cli/mail/call', {
params: [
'--update', email,
'-mailbox', 'true',
'-passwd', password,
],
});
return {
success: true,
message: `Mailbox für ${email} erfolgreich aktiviert`,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
console.error('Plesk enableMailboxForExisting error:', error);
return {
success: false,
error: `Fehler beim Aktivieren der Mailbox: ${errorMessage}`,
};
}
}
async updateMailboxPassword(params: UpdateMailboxPasswordParams): Promise<EmailOperationResult> {
const { localPart, password } = params;
const email = `${localPart}@${this.config.domain}`;
try {
// Prüfen ob E-Mail existiert
const exists = await this.emailExists(localPart);
if (!exists.exists) {
return {
success: false,
error: `E-Mail ${email} nicht gefunden`,
};
}
// Plesk CLI API: Passwort für existierende E-Mail aktualisieren
// --update: Existierende E-Mail aktualisieren
// -passwd: Neues Passwort setzen
await this.request('POST', '/api/v2/cli/mail/call', {
params: [
'--update', email,
'-passwd', password,
],
});
return {
success: true,
message: `Passwort für ${email} erfolgreich aktualisiert`,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
console.error('Plesk updateMailboxPassword error:', error);
return {
success: false,
error: `Fehler beim Aktualisieren des Passworts: ${errorMessage}`,
};
}
}
async deleteEmail(localPart: string): Promise<EmailOperationResult> {
const email = `${localPart}@${this.config.domain}`;
+44 -1
View File
@@ -1,5 +1,8 @@
// ==================== EMAIL PROVIDER TYPES ====================
// Verschlüsselungstyp für E-Mail-Verbindungen
export type MailEncryption = 'SSL' | 'STARTTLS' | 'NONE';
export interface EmailForwardTarget {
email: string;
}
@@ -9,6 +12,27 @@ export interface CreateEmailParams {
forwardTargets: string[]; // Weiterleitungsziele
}
export interface CreateEmailWithMailboxParams {
localPart: string; // z.B. "max.mustermann"
forwardTargets: string[]; // Weiterleitungsziele
password: string; // Passwort für Mailbox (IMAP/SMTP)
}
export interface CreateEmailWithMailboxResult extends EmailOperationResult {
// Erfolg: Mailbox-Informationen zurückgeben
email?: string; // Vollständige E-Mail-Adresse
}
export interface EnableMailboxParams {
localPart: string; // z.B. "max.mustermann"
password: string; // Passwort für Mailbox (IMAP/SMTP)
}
export interface UpdateMailboxPasswordParams {
localPart: string; // z.B. "max.mustermann"
password: string; // Neues Passwort für Mailbox
}
export interface RenameEmailParams {
oldLocalPart: string;
newLocalPart: string;
@@ -17,6 +41,7 @@ export interface RenameEmailParams {
export interface EmailExistsResult {
exists: boolean;
email?: string;
hasMailbox?: boolean; // true wenn echte Mailbox vorhanden
}
export interface EmailOperationResult {
@@ -36,9 +61,18 @@ export interface IEmailProvider {
// Prüft ob eine E-Mail-Adresse existiert
emailExists(localPart: string): Promise<EmailExistsResult>;
// Erstellt eine neue E-Mail-Weiterleitung
// Erstellt eine neue E-Mail-Weiterleitung (ohne Mailbox)
createEmail(params: CreateEmailParams): Promise<EmailOperationResult>;
// Erstellt eine neue E-Mail mit echter Mailbox (IMAP/SMTP-Zugang)
createEmailWithMailbox(params: CreateEmailWithMailboxParams): Promise<CreateEmailWithMailboxResult>;
// Aktiviert Mailbox für eine existierende E-Mail-Weiterleitung
enableMailboxForExisting(params: EnableMailboxParams): Promise<EmailOperationResult>;
// Aktualisiert das Passwort einer Mailbox
updateMailboxPassword(params: UpdateMailboxPasswordParams): Promise<EmailOperationResult>;
// Löscht eine E-Mail-Adresse
deleteEmail(localPart: string): Promise<EmailOperationResult>;
@@ -60,6 +94,15 @@ export interface EmailProviderConfig {
password?: string; // Entschlüsselt
domain: string;
defaultForwardEmail?: string;
// IMAP/SMTP-Server für E-Mail-Client (optional, default: mail.{domain})
imapServer?: string;
imapPort?: number;
smtpServer?: string;
smtpPort?: number;
// Verschlüsselungs-Einstellungen
imapEncryption?: MailEncryption; // SSL, STARTTLS oder NONE
smtpEncryption?: MailEncryption; // SSL, STARTTLS oder NONE
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
isActive: boolean;
isDefault: boolean;
}
+825
View File
@@ -0,0 +1,825 @@
// ==================== IMAP SERVICE ====================
// Service für IMAP-Zugriff auf Mailboxen
import { ImapFlow, FetchMessageObject } from 'imapflow';
import { simpleParser, ParsedMail, AddressObject } from 'mailparser';
// Verschlüsselungstyp
export type MailEncryption = 'SSL' | 'STARTTLS' | 'NONE';
export interface ImapCredentials {
host: string;
port: number;
user: string;
password: string;
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
}
export interface FetchedEmail {
uid: number;
messageId: string;
subject: string | null;
fromAddress: string;
fromName: string | null;
toAddresses: string[];
ccAddresses: string[];
date: Date;
textBody: string | null;
htmlBody: string | null;
hasAttachments: boolean;
attachmentNames: string[];
}
export interface FetchOptions {
folder?: string; // Default: 'INBOX'
since?: Date; // Nur E-Mails nach diesem Datum
limit?: number; // Max. Anzahl E-Mails
sinceUid?: number; // Nur E-Mails ab dieser UID (für inkrementellen Sync)
}
// Helper: Adressen aus mailparser-Format extrahieren
function extractAddresses(addressObj: AddressObject | AddressObject[] | undefined): string[] {
if (!addressObj) return [];
const addresses = Array.isArray(addressObj) ? addressObj : [addressObj];
const result: string[] = [];
for (const obj of addresses) {
if (obj.value) {
for (const addr of obj.value) {
if (addr.address) {
result.push(addr.address);
}
}
}
}
return result;
}
// Helper: Ersten Absender-Namen extrahieren
function extractFromName(addressObj: AddressObject | AddressObject[] | undefined): string | null {
if (!addressObj) return null;
const addresses = Array.isArray(addressObj) ? addressObj : [addressObj];
for (const obj of addresses) {
if (obj.value && obj.value[0]) {
return obj.value[0].name || null;
}
}
return null;
}
// E-Mails aus einer Mailbox abrufen
export async function fetchEmails(
credentials: ImapCredentials,
options: FetchOptions = {}
): Promise<FetchedEmail[]> {
const {
folder = 'INBOX',
since,
limit = 50,
sinceUid,
} = options;
// Verschlüsselungs-Einstellungen basierend auf Modus
const encryption = credentials.encryption ?? 'SSL';
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
// ImapFlow-Optionen je nach Verschlüsselungstyp
// SSL: secure=true (implicit TLS, Port 993)
// STARTTLS: secure=false (upgrades to TLS, Port 143)
// NONE: secure=false + disableAutoIdle (no encryption, Port 143)
const clientOptions: ConstructorParameters<typeof ImapFlow>[0] = {
host: credentials.host,
port: credentials.port,
secure: encryption === 'SSL',
auth: {
user: credentials.user,
pass: credentials.password,
},
logger: false,
};
// TLS-Optionen nur wenn nicht NONE
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
}
// Debug-Logging
console.log(`[IMAP] Connecting to ${credentials.host}:${credentials.port} (${encryption}), user: ${credentials.user}`);
const client = new ImapFlow(clientOptions);
const emails: FetchedEmail[] = [];
try {
await client.connect();
console.log(`[IMAP] Connected successfully`);
// Mailbox öffnen
await client.mailboxOpen(folder);
// Suchkriterien zusammenstellen
let searchCriteria: { since?: Date; uid?: string } = {};
if (since) {
searchCriteria.since = since;
}
// IMAP SEARCH ausführen
let uids: number[];
if (sinceUid) {
// Inkrementeller Sync: Nur E-Mails ab einer bestimmten UID
// IMAP UID-Range: "sinceUid:*" bedeutet alle E-Mails >= sinceUid
const messages = await client.search({ uid: `${sinceUid}:*` }, { uid: true });
const messageArray = Array.isArray(messages) ? messages : [];
uids = messageArray.filter((uid: number) => uid > sinceUid); // Exkludiere die sinceUid selbst
} else if (since) {
// Nach Datum suchen
const messages = await client.search({ since }, { uid: true });
uids = Array.isArray(messages) ? messages : [];
} else {
// Alle E-Mails (mit Limit)
const messages = await client.search({ all: true }, { uid: true });
uids = Array.isArray(messages) ? messages : [];
}
// Neueste zuerst (absteigend sortieren)
uids.sort((a, b) => b - a);
// Limit anwenden
const limitedUids = uids.slice(0, limit);
console.log(`[IMAP] Found ${uids.length} emails, fetching ${limitedUids.length}`);
if (limitedUids.length === 0) {
console.log(`[IMAP] No emails to fetch`);
await client.logout();
return [];
}
// E-Mails abrufen
for await (const message of client.fetch(limitedUids, {
uid: true,
envelope: true,
source: true, // Vollständige E-Mail für Parsing
})) {
try {
// Source muss vorhanden sein
if (!message.source) {
console.error(`E-Mail UID ${message.uid} hat keine Source`);
continue;
}
// E-Mail mit mailparser parsen
const parsed = await simpleParser(message.source) as ParsedMail;
const email: FetchedEmail = {
uid: message.uid,
messageId: parsed.messageId || `${message.uid}@unknown`,
subject: parsed.subject || null,
fromAddress: extractAddresses(parsed.from)[0] || 'unknown@unknown',
fromName: extractFromName(parsed.from),
toAddresses: extractAddresses(parsed.to),
ccAddresses: extractAddresses(parsed.cc),
date: parsed.date || new Date(),
textBody: parsed.text || null,
htmlBody: parsed.html ? String(parsed.html) : null,
hasAttachments: (parsed.attachments?.length || 0) > 0,
attachmentNames: parsed.attachments?.map((a) => a.filename || 'unnamed') || [],
};
emails.push(email);
} catch (parseError) {
console.error(`Fehler beim Parsen von E-Mail UID ${message.uid}:`, parseError);
// E-Mail überspringen bei Parse-Fehlern
}
}
await client.logout();
} catch (error) {
// Verbindung sauber schließen bei Fehlern
try {
await client.logout();
} catch {
// Ignorieren
}
// Bessere Fehlermeldungen
if (error instanceof Error) {
const msg = error.message.toLowerCase();
const errorCode = (error as NodeJS.ErrnoException).code?.toLowerCase() || '';
if (msg.includes('authentication') || msg.includes('login')) {
throw new Error('IMAP-Authentifizierung fehlgeschlagen - Zugangsdaten prüfen');
}
if (msg.includes('econnrefused') || errorCode === 'econnrefused') {
throw new Error(`IMAP-Server nicht erreichbar: ${credentials.host}:${credentials.port} - Verbindung verweigert`);
}
if (msg.includes('timeout') || msg.includes('etimedout') || errorCode === 'etimedout') {
const enc = credentials.encryption ?? 'SSL';
if (enc === 'STARTTLS' && credentials.port === 143) {
throw new Error(`IMAP Port 143 (STARTTLS) nicht erreichbar auf ${credentials.host}`);
} else if (enc === 'SSL' && credentials.port === 993) {
throw new Error(`IMAP Port 993 (SSL) nicht erreichbar auf ${credentials.host}`);
} else {
throw new Error(`IMAP-Verbindung zu ${credentials.host}:${credentials.port} fehlgeschlagen - Timeout`);
}
}
if (msg.includes('certificate') || msg.includes('cert')) {
throw new Error('IMAP SSL-Zertifikatfehler - Aktiviere "Selbstsignierte Zertifikate erlauben"');
}
}
throw error;
}
return emails;
}
// Verbindung testen
export async function testImapConnection(credentials: ImapCredentials): Promise<void> {
// Verschlüsselungs-Einstellungen basierend auf Modus
const encryption = credentials.encryption ?? 'SSL';
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
const clientOptions: ConstructorParameters<typeof ImapFlow>[0] = {
host: credentials.host,
port: credentials.port,
secure: encryption === 'SSL',
auth: {
user: credentials.user,
pass: credentials.password,
},
logger: false,
};
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
try {
await client.connect();
await client.logout();
} catch (error) {
// Verbindung sauber schließen bei Fehlern
try {
await client.logout();
} catch {
// Ignorieren
}
if (error instanceof Error) {
const msg = error.message.toLowerCase();
const errorCode = (error as NodeJS.ErrnoException).code?.toLowerCase() || '';
if (msg.includes('authentication') || msg.includes('login')) {
throw new Error('IMAP-Authentifizierung fehlgeschlagen');
}
if (msg.includes('econnrefused') || errorCode === 'econnrefused') {
throw new Error(`IMAP-Server nicht erreichbar: ${credentials.host}:${credentials.port} - Verbindung verweigert`);
}
if (msg.includes('timeout') || msg.includes('etimedout') || errorCode === 'etimedout') {
if (encryption === 'STARTTLS' && credentials.port === 143) {
throw new Error(`IMAP Port 143 (STARTTLS) nicht erreichbar auf ${credentials.host}`);
} else if (encryption === 'SSL' && credentials.port === 993) {
throw new Error(`IMAP Port 993 (SSL) nicht erreichbar auf ${credentials.host}`);
} else {
throw new Error(`IMAP-Verbindung zu ${credentials.host}:${credentials.port} fehlgeschlagen - Timeout`);
}
}
if (msg.includes('certificate') || msg.includes('cert')) {
throw new Error('IMAP SSL-Zertifikatfehler - Aktiviere "Selbstsignierte Zertifikate erlauben"');
}
}
throw error;
}
}
// Höchste UID in einer Mailbox ermitteln (für inkrementellen Sync)
export async function getHighestUid(
credentials: ImapCredentials,
folder: string = 'INBOX'
): Promise<number> {
// Verschlüsselungs-Einstellungen basierend auf Modus
const encryption = credentials.encryption ?? 'SSL';
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
const clientOptions: ConstructorParameters<typeof ImapFlow>[0] = {
host: credentials.host,
port: credentials.port,
secure: encryption === 'SSL',
auth: {
user: credentials.user,
pass: credentials.password,
},
logger: false,
};
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
try {
await client.connect();
const mailbox = await client.mailboxOpen(folder);
const highestUid = mailbox.uidNext ? mailbox.uidNext - 1 : 0;
await client.logout();
return highestUid;
} catch (error) {
try {
await client.logout();
} catch {
// Ignorieren
}
throw error;
}
}
// Anhang-Interface
export interface EmailAttachmentData {
filename: string;
content: Buffer;
contentType: string;
size: number;
}
// Anhang einer E-Mail per UID abrufen
export async function fetchAttachment(
credentials: ImapCredentials,
uid: number,
attachmentFilename: string,
folder: string = 'INBOX'
): Promise<EmailAttachmentData | null> {
// Verschlüsselungs-Einstellungen basierend auf Modus
const encryption = credentials.encryption ?? 'SSL';
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
const clientOptions: ConstructorParameters<typeof ImapFlow>[0] = {
host: credentials.host,
port: credentials.port,
secure: encryption === 'SSL',
auth: {
user: credentials.user,
pass: credentials.password,
},
logger: false,
};
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
try {
await client.connect();
await client.mailboxOpen(folder);
// E-Mail per UID abrufen
let attachment: EmailAttachmentData | null = null;
for await (const message of client.fetch([uid], {
uid: true,
source: true,
})) {
if (!message.source) continue;
// E-Mail parsen
const parsed = await simpleParser(message.source);
// Anhang suchen
if (parsed.attachments) {
for (const att of parsed.attachments) {
const filename = att.filename || 'unnamed';
if (filename === attachmentFilename) {
attachment = {
filename,
content: att.content,
contentType: att.contentType || 'application/octet-stream',
size: att.size,
};
break;
}
}
}
}
await client.logout();
return attachment;
} catch (error) {
try {
await client.logout();
} catch {
// Ignorieren
}
throw error;
}
}
// Gesendete E-Mail im IMAP Sent-Ordner speichern (für Attachment-Download)
export interface AppendToSentParams {
rawEmail: Buffer | string; // RFC 5322 formatierte E-Mail
sentFolder?: string; // Standard: 'Sent'
}
export interface AppendToSentResult {
success: boolean;
uid?: number; // UID der gespeicherten Nachricht
error?: string;
}
export async function appendToSent(
credentials: ImapCredentials,
params: AppendToSentParams
): Promise<AppendToSentResult> {
const { rawEmail, sentFolder = 'Sent' } = params;
const encryption = credentials.encryption ?? 'SSL';
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
const clientOptions: ConstructorParameters<typeof ImapFlow>[0] = {
host: credentials.host,
port: credentials.port,
secure: encryption === 'SSL',
auth: {
user: credentials.user,
pass: credentials.password,
},
logger: false,
};
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
}
console.log(`[IMAP] Appending email to ${sentFolder} folder...`);
const client = new ImapFlow(clientOptions);
try {
await client.connect();
// E-Mail im Sent-Ordner speichern
const result = await client.append(sentFolder, rawEmail, ['\\Seen'], new Date());
await client.logout();
// append kann false zurückgeben bei Fehler
if (!result) {
return { success: false, error: 'IMAP append fehlgeschlagen' };
}
console.log(`[IMAP] Email appended successfully, UID: ${result.uid}`);
return {
success: true,
uid: result.uid,
};
} catch (error) {
try {
await client.logout();
} catch {
// Ignorieren
}
console.error('[IMAP] Error appending to Sent folder:', error);
// Versuche mit alternativen Ordnernamen falls 'Sent' nicht existiert
if (sentFolder === 'Sent' && error instanceof Error) {
// Typische alternative Namen für Sent-Ordner
const alternativeNames = ['INBOX.Sent', 'Sent Messages', 'Sent Items'];
for (const altFolder of alternativeNames) {
try {
const altClient = new ImapFlow(clientOptions);
await altClient.connect();
const altResult = await altClient.append(altFolder, rawEmail, ['\\Seen'], new Date());
await altClient.logout();
if (altResult) {
console.log(`[IMAP] Email appended to ${altFolder}, UID: ${altResult.uid}`);
return { success: true, uid: altResult.uid };
}
} catch {
// Nächsten Namen versuchen
}
}
}
return {
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Speichern im Sent-Ordner',
};
}
}
// Alle Anhänge einer E-Mail per UID abrufen (Metadaten)
export async function fetchAttachmentList(
credentials: ImapCredentials,
uid: number,
folder: string = 'INBOX'
): Promise<Array<{ filename: string; contentType: string; size: number }>> {
// Verschlüsselungs-Einstellungen basierend auf Modus
const encryption = credentials.encryption ?? 'SSL';
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
const clientOptions: ConstructorParameters<typeof ImapFlow>[0] = {
host: credentials.host,
port: credentials.port,
secure: encryption === 'SSL',
auth: {
user: credentials.user,
pass: credentials.password,
},
logger: false,
};
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
const attachments: Array<{ filename: string; contentType: string; size: number }> = [];
try {
await client.connect();
await client.mailboxOpen(folder);
for await (const message of client.fetch([uid], {
uid: true,
source: true,
})) {
if (!message.source) continue;
const parsed = await simpleParser(message.source);
if (parsed.attachments) {
for (const att of parsed.attachments) {
attachments.push({
filename: att.filename || 'unnamed',
contentType: att.contentType || 'application/octet-stream',
size: att.size,
});
}
}
}
await client.logout();
return attachments;
} catch (error) {
try {
await client.logout();
} catch {
// Ignorieren
}
throw error;
}
}
// ==================== TRASH OPERATIONS ====================
// Typische Namen für Trash-Ordner (verschiedene Server/Sprachen)
const TRASH_FOLDER_NAMES = ['Trash', 'INBOX.Trash', 'Deleted', 'Deleted Items', 'Deleted Messages', 'Papierkorb'];
// Helper: Trash-Ordner finden
async function findTrashFolder(client: ImapFlow): Promise<string | null> {
const mailboxes = await client.list();
for (const name of TRASH_FOLDER_NAMES) {
const found = mailboxes.find(m =>
m.path.toLowerCase() === name.toLowerCase() ||
m.name.toLowerCase() === name.toLowerCase()
);
if (found) return found.path;
}
// Suche nach Ordner mit \Trash Flag
const trashByFlag = mailboxes.find(m => m.specialUse === '\\Trash');
if (trashByFlag) return trashByFlag.path;
return null;
}
export interface MoveToTrashResult {
success: boolean;
newUid?: number; // UID im Trash-Ordner
error?: string;
}
// E-Mail in Papierkorb verschieben
export async function moveToTrash(
credentials: ImapCredentials,
uid: number,
sourceFolder: string = 'INBOX'
): Promise<MoveToTrashResult> {
const encryption = credentials.encryption ?? 'SSL';
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
const clientOptions: ConstructorParameters<typeof ImapFlow>[0] = {
host: credentials.host,
port: credentials.port,
secure: encryption === 'SSL',
auth: {
user: credentials.user,
pass: credentials.password,
},
logger: false,
};
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
try {
await client.connect();
// Trash-Ordner finden
const trashFolder = await findTrashFolder(client);
if (!trashFolder) {
await client.logout();
return { success: false, error: 'Trash-Ordner nicht gefunden' };
}
// Source-Ordner öffnen
await client.mailboxOpen(sourceFolder);
// E-Mail verschieben (kopieren + löschen)
const moveResult = await client.messageMove([uid], trashFolder, { uid: true });
await client.logout();
if (moveResult && moveResult.uidMap) {
// uidMap ist Map<number, number> - alte UID -> neue UID
const newUid = moveResult.uidMap.get(uid);
console.log(`[IMAP] Email moved to ${trashFolder}, new UID: ${newUid}`);
return { success: true, newUid };
}
return { success: true };
} catch (error) {
try {
await client.logout();
} catch {
// Ignorieren
}
console.error('[IMAP] Error moving to trash:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Verschieben in Papierkorb',
};
}
}
export interface RestoreFromTrashResult {
success: boolean;
newUid?: number; // UID im wiederhergestellten Ordner
error?: string;
}
// E-Mail aus Papierkorb wiederherstellen
export async function restoreFromTrash(
credentials: ImapCredentials,
uid: number,
targetFolder: string = 'INBOX'
): Promise<RestoreFromTrashResult> {
const encryption = credentials.encryption ?? 'SSL';
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
const clientOptions: ConstructorParameters<typeof ImapFlow>[0] = {
host: credentials.host,
port: credentials.port,
secure: encryption === 'SSL',
auth: {
user: credentials.user,
pass: credentials.password,
},
logger: false,
};
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
try {
await client.connect();
// Trash-Ordner finden
const trashFolder = await findTrashFolder(client);
if (!trashFolder) {
await client.logout();
return { success: false, error: 'Trash-Ordner nicht gefunden' };
}
// Trash-Ordner öffnen
await client.mailboxOpen(trashFolder);
// E-Mail zurück verschieben
const moveResult = await client.messageMove([uid], targetFolder, { uid: true });
await client.logout();
if (moveResult && moveResult.uidMap) {
const newUid = moveResult.uidMap.get(uid);
console.log(`[IMAP] Email restored to ${targetFolder}, new UID: ${newUid}`);
return { success: true, newUid };
}
return { success: true };
} catch (error) {
try {
await client.logout();
} catch {
// Ignorieren
}
console.error('[IMAP] Error restoring from trash:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Wiederherstellen',
};
}
}
export interface PermanentDeleteResult {
success: boolean;
error?: string;
}
// E-Mail endgültig löschen (aus Trash-Ordner)
export async function permanentDelete(
credentials: ImapCredentials,
uid: number,
folder?: string // Optional: Ordner angeben, sonst wird Trash verwendet
): Promise<PermanentDeleteResult> {
const encryption = credentials.encryption ?? 'SSL';
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
const clientOptions: ConstructorParameters<typeof ImapFlow>[0] = {
host: credentials.host,
port: credentials.port,
secure: encryption === 'SSL',
auth: {
user: credentials.user,
pass: credentials.password,
},
logger: false,
};
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
try {
await client.connect();
// Ordner bestimmen
let targetFolder: string | null | undefined = folder;
if (!targetFolder) {
targetFolder = await findTrashFolder(client);
if (!targetFolder) {
await client.logout();
return { success: false, error: 'Trash-Ordner nicht gefunden' };
}
}
// Ordner öffnen
await client.mailboxOpen(targetFolder);
// E-Mail als gelöscht markieren und expunge
await client.messageDelete([uid], { uid: true });
await client.logout();
console.log(`[IMAP] Email permanently deleted from ${targetFolder}, UID: ${uid}`);
return { success: true };
} catch (error) {
try {
await client.logout();
} catch {
// Ignorieren
}
console.error('[IMAP] Error permanently deleting:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Fehler beim endgültigen Löschen',
};
}
}
+304
View File
@@ -0,0 +1,304 @@
// ==================== SMTP SERVICE ====================
// Service für E-Mail-Versand via SMTP
import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
import MailComposer from 'nodemailer/lib/mail-composer';
// Verschlüsselungstyp
export type MailEncryption = 'SSL' | 'STARTTLS' | 'NONE';
export interface SmtpCredentials {
host: string;
port: number;
user: string;
password: string;
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
}
// Anhang-Interface
export interface EmailAttachment {
filename: string;
content: string; // Base64-kodierter Inhalt
contentType?: string; // MIME-Type (z.B. 'application/pdf')
}
export interface SendEmailParams {
to: string | string[];
cc?: string | string[];
subject: string;
text?: string;
html?: string;
inReplyTo?: string; // Message-ID der E-Mail auf die geantwortet wird
references?: string[]; // Thread-Referenzen
attachments?: EmailAttachment[]; // Anhänge
}
export interface SendEmailResult {
success: boolean;
messageId?: string;
rawEmail?: Buffer; // RFC 5322 formatierte E-Mail für IMAP-Speicherung
error?: string;
}
// E-Mail senden
export async function sendEmail(
credentials: SmtpCredentials,
fromAddress: string,
params: SendEmailParams
): Promise<SendEmailResult> {
// Verschlüsselungs-Einstellungen basierend auf Modus
const encryption = credentials.encryption ?? 'SSL';
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
// Transport-Optionen je nach Verschlüsselungstyp
// SSL: secure=true (implicit TLS, Port 465)
// STARTTLS: secure=false (upgrades to TLS, Port 587)
// NONE: secure=false + ignoreTLS=true (no encryption, Port 25)
const transportOptions: nodemailer.TransportOptions & {
host: string;
port: number;
secure: boolean;
auth: { user: string; pass: string };
tls?: { rejectUnauthorized: boolean };
ignoreTLS?: boolean;
requireTLS?: boolean;
connectionTimeout: number;
greetingTimeout: number;
socketTimeout: number;
} = {
host: credentials.host,
port: credentials.port,
secure: encryption === 'SSL',
auth: {
user: credentials.user,
pass: credentials.password,
},
connectionTimeout: 10000,
greetingTimeout: 10000,
socketTimeout: 30000,
};
// TLS-Optionen nur wenn nicht NONE
if (encryption !== 'NONE') {
transportOptions.tls = { rejectUnauthorized };
} else {
// Keine Verschlüsselung: STARTTLS ignorieren
transportOptions.ignoreTLS = true;
}
// Bei STARTTLS: requireTLS erzwingen
if (encryption === 'STARTTLS') {
transportOptions.requireTLS = true;
}
// Debug-Logging für Entwicklung
console.log(`[SMTP] Connecting to ${credentials.host}:${credentials.port} (${encryption}), user: ${credentials.user}`);
// Transporter erstellen
const transporter: Transporter = nodemailer.createTransport(transportOptions);
try {
// E-Mail-Optionen zusammenstellen
const mailOptions: nodemailer.SendMailOptions = {
from: fromAddress,
to: Array.isArray(params.to) ? params.to.join(', ') : params.to,
subject: params.subject,
};
// CC hinzufügen falls vorhanden
if (params.cc) {
mailOptions.cc = Array.isArray(params.cc) ? params.cc.join(', ') : params.cc;
}
// Body hinzufügen
if (params.html) {
mailOptions.html = params.html;
// Auch Text-Version für Clients ohne HTML-Support
mailOptions.text = params.text || stripHtml(params.html);
} else if (params.text) {
mailOptions.text = params.text;
}
// Threading-Header für Antworten
if (params.inReplyTo) {
mailOptions.inReplyTo = params.inReplyTo;
}
if (params.references && params.references.length > 0) {
mailOptions.references = params.references.join(' ');
}
// Anhänge hinzufügen
if (params.attachments && params.attachments.length > 0) {
mailOptions.attachments = params.attachments.map((att) => ({
filename: att.filename,
content: Buffer.from(att.content, 'base64'),
contentType: att.contentType,
}));
}
// E-Mail senden
const result = await transporter.sendMail(mailOptions);
// Raw E-Mail für IMAP-Speicherung bauen (mit tatsächlicher Message-ID)
let rawEmail: Buffer | undefined;
try {
const composerOptions = {
...mailOptions,
messageId: result.messageId, // Tatsächliche Message-ID vom Server
};
const composer = new MailComposer(composerOptions);
rawEmail = await composer.compile().build();
} catch (compileError) {
console.error('Error compiling raw email:', compileError);
// Nicht kritisch - E-Mail wurde trotzdem gesendet
}
return {
success: true,
messageId: result.messageId,
rawEmail,
};
} catch (error) {
console.error('SMTP sendEmail error:', error);
// Bessere Fehlermeldungen
let errorMessage = 'Unbekannter Fehler beim E-Mail-Versand';
if (error instanceof Error) {
const msg = error.message.toLowerCase();
const errorCode = (error as NodeJS.ErrnoException).code?.toLowerCase() || '';
if (msg.includes('authentication') || msg.includes('auth') || msg.includes('login')) {
errorMessage = 'SMTP-Authentifizierung fehlgeschlagen - Zugangsdaten prüfen';
} else if (msg.includes('econnrefused') || errorCode === 'econnrefused') {
errorMessage = `SMTP-Server nicht erreichbar: ${credentials.host}:${credentials.port} - Verbindung verweigert`;
} else if (msg.includes('greeting never received') || msg.includes('etimedout') || errorCode === 'etimedout') {
// Detaillierte Fehlermeldung je nach Port/Verschlüsselung
const enc = credentials.encryption ?? 'SSL';
if (enc === 'STARTTLS' && credentials.port === 587) {
errorMessage = `SMTP-Verbindung zu Port 587 fehlgeschlagen - STARTTLS (Submission) ist möglicherweise nicht aktiviert auf ${credentials.host}`;
} else if (enc === 'NONE' && credentials.port === 25) {
errorMessage = `SMTP-Verbindung zu Port 25 fehlgeschlagen - Port möglicherweise blockiert oder nicht erreichbar auf ${credentials.host}`;
} else {
errorMessage = `SMTP-Verbindung zu ${credentials.host}:${credentials.port} fehlgeschlagen - Port nicht erreichbar oder Timeout`;
}
} else if (msg.includes('timeout')) {
errorMessage = `SMTP-Verbindung: Zeitüberschreitung bei ${credentials.host}:${credentials.port}`;
} else if (msg.includes('recipient') || msg.includes('rejected')) {
errorMessage = 'Empfänger-Adresse wurde vom Server abgelehnt';
} else if (msg.includes('certificate') || msg.includes('cert')) {
errorMessage = 'SSL-Zertifikatfehler - Aktiviere "Selbstsignierte Zertifikate erlauben" in den Provider-Einstellungen';
} else if (msg.includes('socket close') || msg.includes('socket hang up') || msg.includes('econnreset') || errorCode === 'econnreset') {
// Server schließt Verbindung unerwartet - oft TLS-Problem bei STARTTLS
const enc = credentials.encryption ?? 'SSL';
if (enc === 'STARTTLS') {
errorMessage = `SMTP-Verbindung abgebrochen bei STARTTLS - Aktiviere "Selbstsignierte Zertifikate erlauben" oder verwende SSL/TLS auf Port 465`;
} else {
errorMessage = `SMTP-Verbindung unerwartet geschlossen von ${credentials.host}:${credentials.port}`;
}
} else {
errorMessage = error.message;
}
}
return {
success: false,
error: errorMessage,
};
} finally {
// Transporter schließen
transporter.close();
}
}
// SMTP-Verbindung testen
export async function testSmtpConnection(credentials: SmtpCredentials): Promise<void> {
// Verschlüsselungs-Einstellungen basierend auf Modus
const encryption = credentials.encryption ?? 'SSL';
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
const transportOptions: nodemailer.TransportOptions & {
host: string;
port: number;
secure: boolean;
auth: { user: string; pass: string };
tls?: { rejectUnauthorized: boolean };
ignoreTLS?: boolean;
connectionTimeout: number;
greetingTimeout: number;
} = {
host: credentials.host,
port: credentials.port,
secure: encryption === 'SSL',
auth: {
user: credentials.user,
pass: credentials.password,
},
connectionTimeout: 10000,
greetingTimeout: 10000,
};
if (encryption !== 'NONE') {
transportOptions.tls = { rejectUnauthorized };
} else {
transportOptions.ignoreTLS = true;
}
const transporter: Transporter = nodemailer.createTransport(transportOptions);
try {
// Verbindung verifizieren
await transporter.verify();
} catch (error) {
if (error instanceof Error) {
const msg = error.message.toLowerCase();
const errorCode = (error as NodeJS.ErrnoException).code?.toLowerCase() || '';
if (msg.includes('authentication') || msg.includes('auth') || msg.includes('login')) {
throw new Error('SMTP-Authentifizierung fehlgeschlagen');
}
if (msg.includes('econnrefused') || errorCode === 'econnrefused') {
throw new Error(`SMTP-Server nicht erreichbar: ${credentials.host}:${credentials.port} - Verbindung verweigert`);
}
if (msg.includes('greeting never received') || msg.includes('etimedout') || errorCode === 'etimedout') {
if (encryption === 'STARTTLS' && credentials.port === 587) {
throw new Error(`SMTP Port 587 (STARTTLS/Submission) ist nicht erreichbar - In Plesk unter Tools & Settings > Mail Server Settings aktivieren`);
} else if (encryption === 'NONE' && credentials.port === 25) {
throw new Error(`SMTP Port 25 ist nicht erreichbar auf ${credentials.host}`);
} else {
throw new Error(`SMTP-Verbindung zu ${credentials.host}:${credentials.port} fehlgeschlagen - Port nicht erreichbar`);
}
}
if (msg.includes('certificate') || msg.includes('cert')) {
throw new Error('SSL-Zertifikatfehler - Aktiviere "Selbstsignierte Zertifikate erlauben"');
}
}
throw error;
} finally {
transporter.close();
}
}
// Helper: HTML zu Text konvertieren (einfache Version)
function stripHtml(html: string): string {
return html
// Zeilenumbrüche für Block-Elemente
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<\/div>/gi, '\n')
.replace(/<\/li>/gi, '\n')
// Alle HTML-Tags entfernen
.replace(/<[^>]+>/g, '')
// HTML-Entities dekodieren
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
// Mehrfache Leerzeilen reduzieren
.replace(/\n{3,}/g, '\n\n')
.trim();
}
+240 -3
View File
@@ -1,4 +1,14 @@
import { PrismaClient } from '@prisma/client';
import { encrypt, decrypt } from '../utils/encryption.js';
import {
provisionEmail,
provisionEmailWithMailbox,
enableMailboxForExistingEmail,
checkEmailExists,
getProviderDomain,
updateMailboxPassword,
} from './emailProvider/emailProviderService.js';
import { generateSecurePassword } from '../utils/passwordGenerator.js';
const prisma = new PrismaClient();
@@ -13,22 +23,116 @@ export async function getEmailsByCustomerId(customerId: number, includeInactive
});
}
// Mit Mailbox-Status für E-Mail-Client
export async function getEmailsWithMailboxByCustomerId(customerId: number) {
return prisma.stressfreiEmail.findMany({
where: {
customerId,
isActive: true,
hasMailbox: true,
},
select: {
id: true,
email: true,
notes: true,
hasMailbox: true,
_count: {
select: {
cachedEmails: true,
},
},
},
orderBy: { email: 'asc' },
});
}
export async function getEmailById(id: number) {
return prisma.stressfreiEmail.findUnique({
where: { id },
});
}
export async function createEmail(data: {
// E-Mail mit Mailbox-Status laden
export async function getEmailWithMailboxById(id: number) {
return prisma.stressfreiEmail.findUnique({
where: { id },
select: {
id: true,
customerId: true,
email: true,
platform: true,
notes: true,
isActive: true,
hasMailbox: true,
emailPasswordEncrypted: true,
createdAt: true,
updatedAt: true,
},
});
}
export interface CreateEmailData {
customerId: number;
email: string;
platform?: string;
notes?: string;
}) {
provisionAtProvider?: boolean;
createMailbox?: boolean;
}
export async function createEmail(data: CreateEmailData) {
const { provisionAtProvider, createMailbox, ...emailData } = data;
// Falls beim Provider anlegen gewünscht
if (provisionAtProvider) {
// Kunde laden für Weiterleitung
const customer = await prisma.customer.findUnique({
where: { id: data.customerId },
select: { email: true },
});
if (!customer?.email) {
throw new Error('Kunde hat keine E-Mail-Adresse für Weiterleitung');
}
// LocalPart extrahieren
const localPart = data.email.split('@')[0];
if (createMailbox) {
// Mit echter Mailbox anlegen
const password = generateSecurePassword();
const result = await provisionEmailWithMailbox(localPart, customer.email, password);
if (!result.success) {
throw new Error(result.error || 'Fehler beim Anlegen der Mailbox');
}
// Passwort verschlüsseln und speichern
const passwordEncrypted = encrypt(password);
return prisma.stressfreiEmail.create({
data: {
...emailData,
isActive: true,
hasMailbox: true,
emailPasswordEncrypted: passwordEncrypted,
},
});
} else {
// Nur Weiterleitung anlegen
const result = await provisionEmail(localPart, customer.email);
if (!result.success && !result.message?.includes('existiert bereits')) {
throw new Error(result.error || 'Fehler beim Anlegen der E-Mail');
}
}
}
return prisma.stressfreiEmail.create({
data: {
...data,
...emailData,
isActive: true,
hasMailbox: createMailbox || false,
},
});
}
@@ -51,3 +155,136 @@ export async function updateEmail(
export async function deleteEmail(id: number) {
return prisma.stressfreiEmail.delete({ where: { id } });
}
// Mailbox nachträglich aktivieren (für existierende E-Mail-Weiterleitung)
export async function enableMailbox(id: number): Promise<{ success: boolean; error?: string }> {
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
where: { id },
});
if (!stressfreiEmail) {
return { success: false, error: 'StressfreiEmail nicht gefunden' };
}
if (stressfreiEmail.hasMailbox) {
return { success: false, error: 'Mailbox ist bereits aktiviert' };
}
const localPart = stressfreiEmail.email.split('@')[0];
const password = generateSecurePassword();
// Mailbox für existierende E-Mail aktivieren (nicht neu erstellen!)
const result = await enableMailboxForExistingEmail(localPart, password);
if (!result.success) {
return { success: false, error: result.error || 'Fehler beim Aktivieren der Mailbox' };
}
// Passwort verschlüsseln und speichern
const passwordEncrypted = encrypt(password);
await prisma.stressfreiEmail.update({
where: { id },
data: {
hasMailbox: true,
emailPasswordEncrypted: passwordEncrypted,
},
});
return { success: true };
}
// Mailbox-Status mit Provider synchronisieren
export async function syncMailboxStatus(id: number): Promise<{
success: boolean;
hasMailbox?: boolean;
wasUpdated?: boolean;
error?: string
}> {
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
where: { id },
select: { email: true, hasMailbox: true },
});
if (!stressfreiEmail) {
return { success: false, error: 'StressfreiEmail nicht gefunden' };
}
const localPart = stressfreiEmail.email.split('@')[0];
// Provider-Status prüfen
const providerStatus = await checkEmailExists(localPart);
if (!providerStatus.exists) {
return { success: true, hasMailbox: false, wasUpdated: false };
}
const providerHasMailbox = providerStatus.hasMailbox === true;
// DB aktualisieren wenn Status abweicht
if (stressfreiEmail.hasMailbox !== providerHasMailbox) {
await prisma.stressfreiEmail.update({
where: { id },
data: { hasMailbox: providerHasMailbox },
});
console.log(`Mailbox-Status für ${stressfreiEmail.email} aktualisiert: ${stressfreiEmail.hasMailbox} -> ${providerHasMailbox}`);
return { success: true, hasMailbox: providerHasMailbox, wasUpdated: true };
}
return { success: true, hasMailbox: providerHasMailbox, wasUpdated: false };
}
// Passwort für IMAP/SMTP-Zugang entschlüsseln (nur für autorisierte Nutzung)
export async function getDecryptedPassword(id: number): Promise<string | null> {
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
where: { id },
select: { emailPasswordEncrypted: true },
});
if (!stressfreiEmail?.emailPasswordEncrypted) {
return null;
}
try {
return decrypt(stressfreiEmail.emailPasswordEncrypted);
} catch {
console.error('Fehler beim Entschlüsseln des Passworts');
return null;
}
}
// Passwort neu generieren und beim Provider setzen
export async function resetMailboxPassword(id: number): Promise<{ success: boolean; password?: string; error?: string }> {
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
where: { id },
select: { email: true, hasMailbox: true },
});
if (!stressfreiEmail) {
return { success: false, error: 'StressfreiEmail nicht gefunden' };
}
if (!stressfreiEmail.hasMailbox) {
return { success: false, error: 'Keine Mailbox für diese E-Mail-Adresse' };
}
// Neues Passwort generieren
const newPassword = generateSecurePassword();
const localPart = stressfreiEmail.email.split('@')[0];
// Passwort beim Provider ändern
const providerResult = await updateMailboxPassword(localPart, newPassword);
if (!providerResult.success) {
return { success: false, error: providerResult.error || 'Fehler beim Aktualisieren des Passworts beim Provider' };
}
// Passwort verschlüsseln und lokal speichern
const passwordEncrypted = encrypt(newPassword);
await prisma.stressfreiEmail.update({
where: { id },
data: { emailPasswordEncrypted: passwordEncrypted },
});
return { success: true, password: newPassword };
}
+39 -3
View File
@@ -134,10 +134,11 @@ export async function createUser(data: {
lastName: string;
roleIds: number[];
customerId?: number;
hasDeveloperAccess?: boolean;
}) {
const hashedPassword = await bcrypt.hash(data.password, 10);
return prisma.user.create({
const user = await prisma.user.create({
data: {
email: data.email,
password: hashedPassword,
@@ -160,6 +161,13 @@ export async function createUser(data: {
},
},
});
// Entwicklerzugriff setzen falls aktiviert
if (data.hasDeveloperAccess) {
await setUserDeveloperAccess(user.id, true);
}
return user;
}
export async function updateUser(
@@ -270,10 +278,28 @@ export async function updateUser(
(userData as Record<string, unknown>).password = await bcrypt.hash(password, 10);
}
// Update user
// Prüfen ob Rollen geändert werden (für Zwangslogout)
let rolesChanged = false;
if (roleIds !== undefined) {
const currentRoles = await prisma.userRole.findMany({
where: { userId: id },
select: { roleId: true },
});
const currentRoleIds = currentRoles.map((r) => r.roleId).sort();
const newRoleIds = [...roleIds].sort();
rolesChanged =
currentRoleIds.length !== newRoleIds.length ||
!currentRoleIds.every((id, i) => id === newRoleIds[i]);
}
// Update user - bei Rollenänderung Token invalidieren
await prisma.user.update({
where: { id },
data: userData,
data: {
...userData,
// Token invalidieren wenn Rollen geändert werden
...(rolesChanged && { tokenInvalidatedAt: new Date() }),
},
});
// Update roles if provided
@@ -338,12 +364,22 @@ async function setUserDeveloperAccess(userId: number, enabled: boolean) {
await prisma.userRole.create({
data: { userId, roleId: developerRole.id },
});
// Token invalidieren bei Rechteänderung
await prisma.user.update({
where: { id: userId },
data: { tokenInvalidatedAt: new Date() },
});
} else if (!enabled && hasRole) {
// Remove Developer role
console.log('Removing Developer role');
await prisma.userRole.delete({
where: { userId_roleId: { userId, roleId: developerRole.id } },
});
// Token invalidieren bei Rechteänderung
await prisma.user.update({
where: { id: userId },
data: { tokenInvalidatedAt: new Date() },
});
} else {
console.log('No action needed - enabled:', enabled, 'hasRole:', !!hasRole);
}