added backup and email client
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as backupService from '../services/backup.service.js';
|
||||
|
||||
/**
|
||||
* Liste aller Backups abrufen
|
||||
* GET /api/settings/backups
|
||||
*/
|
||||
export async function listBackups(req: Request, res: Response) {
|
||||
try {
|
||||
const backups = await backupService.listBackups();
|
||||
res.json({ data: backups });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: 'Fehler beim Laden der Backups', details: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Neues Backup erstellen
|
||||
* POST /api/settings/backup
|
||||
*/
|
||||
export async function createBackup(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await backupService.createBackup();
|
||||
|
||||
if (result.success) {
|
||||
res.json({ data: { backupName: result.backupName }, message: 'Backup erfolgreich erstellt' });
|
||||
} else {
|
||||
res.status(500).json({ error: 'Backup fehlgeschlagen', details: result.error });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: 'Fehler beim Erstellen des Backups', details: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup wiederherstellen
|
||||
* POST /api/settings/backup/:name/restore
|
||||
*/
|
||||
export async function restoreBackup(req: Request, res: Response) {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Backup-Name erforderlich' });
|
||||
}
|
||||
|
||||
const result = await backupService.restoreBackup(name);
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
data: {
|
||||
restoredRecords: result.restoredRecords,
|
||||
restoredFiles: result.restoredFiles,
|
||||
},
|
||||
message: `${result.restoredRecords} Datensätze und ${result.restoredFiles || 0} Dateien wiederhergestellt`,
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ error: 'Wiederherstellung fehlgeschlagen', details: result.error });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: 'Fehler bei der Wiederherstellung', details: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup löschen
|
||||
* DELETE /api/settings/backup/:name
|
||||
*/
|
||||
export async function deleteBackup(req: Request, res: Response) {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Backup-Name erforderlich' });
|
||||
}
|
||||
|
||||
const result = await backupService.deleteBackup(name);
|
||||
|
||||
if (result.success) {
|
||||
res.json({ message: 'Backup gelöscht' });
|
||||
} else {
|
||||
res.status(500).json({ error: 'Löschen fehlgeschlagen', details: result.error });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: 'Fehler beim Löschen des Backups', details: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup als ZIP herunterladen
|
||||
* GET /api/settings/backup/:name/download
|
||||
*/
|
||||
export async function downloadBackup(req: Request, res: Response) {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Backup-Name erforderlich' });
|
||||
}
|
||||
|
||||
const result = await backupService.createBackupZip(name);
|
||||
|
||||
if ('error' in result) {
|
||||
return res.status(404).json({ error: result.error });
|
||||
}
|
||||
|
||||
// Response-Header setzen
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
|
||||
|
||||
// Archiver zum Response pipen
|
||||
result.stream.pipe(res);
|
||||
|
||||
// Archiver finalisieren (startet das Schreiben)
|
||||
result.stream.finalize();
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: 'Fehler beim Download', details: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup-ZIP hochladen
|
||||
* POST /api/settings/backup/upload
|
||||
*/
|
||||
export async function uploadBackup(req: Request, res: Response) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'Keine Datei hochgeladen' });
|
||||
}
|
||||
|
||||
// Prüfen ob es eine ZIP-Datei ist
|
||||
if (!req.file.originalname.endsWith('.zip')) {
|
||||
return res.status(400).json({ error: 'Nur ZIP-Dateien sind erlaubt' });
|
||||
}
|
||||
|
||||
const result = await backupService.uploadBackupZip(req.file.buffer);
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
data: { backupName: result.backupName },
|
||||
message: 'Backup erfolgreich hochgeladen',
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({ error: 'Upload fehlgeschlagen', details: result.error });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: 'Fehler beim Upload', details: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Werkseinstellungen - Alle Daten löschen
|
||||
* POST /api/settings/factory-reset
|
||||
*/
|
||||
export async function factoryReset(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await backupService.factoryReset();
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
message: 'Werkseinstellungen wiederhergestellt. Bitte melden Sie sich mit admin@admin.com / admin an.',
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: 'Fehler bei Werkseinstellungen', details: error.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,805 @@
|
||||
// ==================== CACHED EMAIL CONTROLLER ====================
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import * as cachedEmailService from '../services/cachedEmail.service.js';
|
||||
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
||||
import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '../services/smtpService.js';
|
||||
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
|
||||
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
||||
import { decrypt } from '../utils/encryption.js';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
|
||||
// ==================== E-MAIL LIST ====================
|
||||
|
||||
// E-Mails für einen Kunden abrufen
|
||||
export async function getEmailsForCustomer(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined;
|
||||
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
||||
|
||||
const emails = await cachedEmailService.getCachedEmails({
|
||||
customerId,
|
||||
stressfreiEmailId,
|
||||
folder,
|
||||
limit,
|
||||
offset,
|
||||
includeBody: false,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: emails } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('getEmailsForCustomer error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Laden der E-Mails',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// E-Mails für einen Vertrag abrufen
|
||||
export async function getEmailsForContract(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
||||
|
||||
const emails = await cachedEmailService.getCachedEmails({
|
||||
contractId,
|
||||
folder,
|
||||
limit,
|
||||
offset,
|
||||
includeBody: false,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: emails } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('getEmailsForContract error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Laden der Vertrags-E-Mails',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== SINGLE EMAIL ====================
|
||||
|
||||
// Einzelne E-Mail abrufen (mit Body)
|
||||
export async function getEmail(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const email = await cachedEmailService.getCachedEmailById(id);
|
||||
|
||||
if (!email) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'E-Mail nicht gefunden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Als gelesen markieren
|
||||
await cachedEmailService.markEmailAsRead(id);
|
||||
|
||||
res.json({ success: true, data: { ...email, isRead: true } } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('getEmail error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Laden der E-Mail',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// E-Mail als gelesen/ungelesen markieren
|
||||
export async function markAsRead(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const { isRead } = req.body;
|
||||
|
||||
if (isRead) {
|
||||
await cachedEmailService.markEmailAsRead(id);
|
||||
} else {
|
||||
await cachedEmailService.markEmailAsUnread(id);
|
||||
}
|
||||
|
||||
res.json({ success: true } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('markAsRead error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Markieren der E-Mail',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// E-Mail Stern umschalten
|
||||
export async function toggleStar(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const isStarred = await cachedEmailService.toggleEmailStar(id);
|
||||
|
||||
res.json({ success: true, data: { isStarred } } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('toggleStar error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Ändern des Sterns',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== CONTRACT ASSIGNMENT ====================
|
||||
|
||||
// E-Mail einem Vertrag zuordnen
|
||||
export async function assignToContract(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
const { contractId } = req.body;
|
||||
const userId = (req as any).userId; // Falls Auth-Middleware userId setzt
|
||||
|
||||
const email = await cachedEmailService.assignEmailToContract(emailId, contractId, userId);
|
||||
|
||||
res.json({ success: true, data: email } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('assignToContract error:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Zuordnen der E-Mail',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// Vertragszuordnung aufheben
|
||||
export async function unassignFromContract(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.id);
|
||||
|
||||
const email = await cachedEmailService.unassignEmailFromContract(emailId);
|
||||
|
||||
res.json({ success: true, data: email } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('unassignFromContract error:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aufheben der Zuordnung',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// E-Mail-Anzahl pro Ordner für ein Konto
|
||||
export async function getFolderCounts(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const stressfreiEmailId = parseInt(req.params.id);
|
||||
|
||||
const counts = await cachedEmailService.getFolderCountsForAccount(stressfreiEmailId);
|
||||
|
||||
res.json({ success: true, data: counts } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('getFolderCounts error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Laden der Ordner-Anzahlen',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// E-Mail-Anzahl pro Ordner für einen Vertrag
|
||||
export async function getContractFolderCounts(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
|
||||
const counts = await cachedEmailService.getFolderCountsForContract(contractId);
|
||||
|
||||
res.json({ success: true, data: counts } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('getContractFolderCounts error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Laden der Ordner-Anzahlen',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== SYNC & SEND ====================
|
||||
|
||||
// E-Mails für ein Konto synchronisieren (INBOX + SENT)
|
||||
export async function syncAccount(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const stressfreiEmailId = parseInt(req.params.id);
|
||||
const fullSync = req.query.full === 'true';
|
||||
|
||||
// Synchronisiert sowohl INBOX als auch SENT
|
||||
const result = await cachedEmailService.syncAllFoldersForAccount(stressfreiEmailId, { fullSync });
|
||||
|
||||
if (!result.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
newEmails: result.newEmails,
|
||||
totalEmails: result.totalEmails,
|
||||
},
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('syncAccount error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Synchronisieren der E-Mails',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// E-Mail senden
|
||||
export async function sendEmailFromAccount(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const stressfreiEmailId = parseInt(req.params.id);
|
||||
const { to, cc, subject, text, html, inReplyTo, references, attachments, contractId } = req.body;
|
||||
|
||||
// StressfreiEmail laden
|
||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
||||
|
||||
if (!stressfreiEmail) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'E-Mail-Konto nicht gefunden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stressfreiEmail.hasMailbox) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Dieses Konto hat keine Mailbox für den Versand',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Passwort entschlüsseln
|
||||
const password = await stressfreiEmailService.getDecryptedPassword(stressfreiEmailId);
|
||||
|
||||
if (!password) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Passwort für E-Mail-Versand nicht verfügbar',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// SMTP-Einstellungen vom Provider
|
||||
const settings = await getImapSmtpSettings();
|
||||
|
||||
if (!settings) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Keine SMTP-Einstellungen konfiguriert',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// SMTP-Credentials
|
||||
const credentials: SmtpCredentials = {
|
||||
host: settings.smtpServer,
|
||||
port: settings.smtpPort,
|
||||
user: stressfreiEmail.email,
|
||||
password,
|
||||
encryption: settings.smtpEncryption,
|
||||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||||
};
|
||||
|
||||
// E-Mail-Parameter
|
||||
const emailParams: SendEmailParams = {
|
||||
to,
|
||||
cc,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
inReplyTo,
|
||||
references,
|
||||
attachments: attachments as EmailAttachment[] | undefined,
|
||||
};
|
||||
|
||||
// E-Mail senden
|
||||
const result = await sendEmail(credentials, stressfreiEmail.email, emailParams);
|
||||
|
||||
if (!result.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Gesendete E-Mail im IMAP Sent-Ordner speichern (für Attachment-Download)
|
||||
let sentUid: number | undefined;
|
||||
if (result.rawEmail) {
|
||||
try {
|
||||
// IMAP-Credentials für Sent-Ordner
|
||||
const imapCredentials: ImapCredentials = {
|
||||
host: settings.imapServer,
|
||||
port: settings.imapPort,
|
||||
user: stressfreiEmail.email,
|
||||
password,
|
||||
encryption: settings.imapEncryption,
|
||||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||||
};
|
||||
|
||||
const appendResult = await appendToSent(imapCredentials, {
|
||||
rawEmail: result.rawEmail,
|
||||
});
|
||||
|
||||
if (appendResult.success && appendResult.uid) {
|
||||
sentUid = appendResult.uid;
|
||||
console.log(`[SMTP] Email stored in Sent folder with UID ${sentUid}`);
|
||||
}
|
||||
} catch (appendError) {
|
||||
// Nicht kritisch - E-Mail wurde trotzdem gesendet
|
||||
console.error('Error appending to IMAP Sent folder:', appendError);
|
||||
}
|
||||
}
|
||||
|
||||
// Gesendete E-Mail im Cache speichern
|
||||
try {
|
||||
// Anhangsnamen extrahieren falls vorhanden
|
||||
const attachmentNames = attachments?.map((a: EmailAttachment) => a.filename) || [];
|
||||
|
||||
await cachedEmailService.createSentEmail(stressfreiEmailId, {
|
||||
to,
|
||||
cc,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
messageId: result.messageId || `sent-${Date.now()}@opencrm.local`,
|
||||
contractId: contractId ? parseInt(contractId) : undefined,
|
||||
attachmentNames: attachmentNames.length > 0 ? attachmentNames : undefined,
|
||||
uid: sentUid, // UID vom IMAP Sent-Ordner für Attachment-Download
|
||||
});
|
||||
} catch (saveError) {
|
||||
// Fehler beim Speichern nicht kritisch - E-Mail wurde trotzdem gesendet
|
||||
console.error('Error saving sent email to cache:', saveError);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { messageId: result.messageId },
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('sendEmailFromAccount error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Senden der E-Mail',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ATTACHMENTS ====================
|
||||
|
||||
// Anhang-Liste einer E-Mail abrufen
|
||||
export async function getAttachments(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.emailId);
|
||||
|
||||
// E-Mail aus Cache laden
|
||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||
if (!email) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'E-Mail nicht gefunden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Anhänge aus attachmentNames parsen (JSON Array)
|
||||
const attachmentNames: string[] = email.attachmentNames
|
||||
? JSON.parse(email.attachmentNames)
|
||||
: [];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: attachmentNames.map((name) => ({ filename: name })),
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('getAttachments error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Laden der Anhänge',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// Einzelnen Anhang herunterladen
|
||||
export async function downloadAttachment(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const emailId = parseInt(req.params.emailId);
|
||||
const filename = decodeURIComponent(req.params.filename);
|
||||
|
||||
// E-Mail aus Cache laden
|
||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||
if (!email) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'E-Mail nicht gefunden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Für gesendete E-Mails: Prüfen ob UID vorhanden (im IMAP Sent gespeichert)
|
||||
if (email.folder === 'SENT' && email.uid === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Anhang nicht verfügbar - E-Mail wurde vor der IMAP-Speicherung gesendet',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// StressfreiEmail laden um Zugangsdaten zu bekommen
|
||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(email.stressfreiEmailId);
|
||||
if (!stressfreiEmail || !stressfreiEmail.emailPasswordEncrypted) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Keine Mailbox-Zugangsdaten verfügbar',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// IMAP-Einstellungen laden
|
||||
const settings = await getImapSmtpSettings();
|
||||
if (!settings) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Keine E-Mail-Provider-Einstellungen gefunden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Passwort entschlüsseln
|
||||
const password = decrypt(stressfreiEmail.emailPasswordEncrypted);
|
||||
|
||||
// IMAP-Credentials
|
||||
const credentials: ImapCredentials = {
|
||||
host: settings.imapServer,
|
||||
port: settings.imapPort,
|
||||
user: stressfreiEmail.email,
|
||||
password,
|
||||
encryption: settings.imapEncryption,
|
||||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||||
};
|
||||
|
||||
// Ordner basierend auf E-Mail-Typ bestimmen (INBOX oder Sent)
|
||||
const imapFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
|
||||
|
||||
// Anhang per IMAP abrufen
|
||||
const attachment = await fetchAttachment(credentials, email.uid, filename, imapFolder);
|
||||
|
||||
if (!attachment) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Anhang nicht gefunden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Datei senden - inline (öffnen) oder attachment (download)
|
||||
const disposition = req.query.view === 'true' ? 'inline' : 'attachment';
|
||||
res.setHeader('Content-Type', attachment.contentType);
|
||||
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(attachment.filename)}"`);
|
||||
res.setHeader('Content-Length', attachment.size);
|
||||
res.send(attachment.content);
|
||||
} catch (error) {
|
||||
console.error('downloadAttachment error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Herunterladen des Anhangs',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== MAILBOX ACCOUNTS ====================
|
||||
|
||||
// Mailbox-Konten eines Kunden abrufen
|
||||
export async function getMailboxAccounts(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
|
||||
const accounts = await cachedEmailService.getMailboxAccountsForCustomer(customerId);
|
||||
|
||||
res.json({ success: true, data: accounts } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('getMailboxAccounts error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Laden der E-Mail-Konten',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// Mailbox nachträglich aktivieren
|
||||
export async function enableMailbox(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
const result = await stressfreiEmailService.enableMailbox(id);
|
||||
|
||||
if (!result.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('enableMailbox error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Aktivieren der Mailbox',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// Mailbox-Status mit Provider synchronisieren
|
||||
export async function syncMailboxStatus(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
const result = await stressfreiEmailService.syncMailboxStatus(id);
|
||||
|
||||
if (!result.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
hasMailbox: result.hasMailbox,
|
||||
wasUpdated: result.wasUpdated,
|
||||
},
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('syncMailboxStatus error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Synchronisieren des Mailbox-Status',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// E-Mail-Thread abrufen
|
||||
export async function getThread(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
const thread = await cachedEmailService.getEmailThread(id);
|
||||
|
||||
res.json({ success: true, data: thread } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('getThread error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Laden des E-Mail-Threads',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// Mailbox-Zugangsdaten abrufen (IMAP/SMTP)
|
||||
export async function getMailboxCredentials(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
// StressfreiEmail laden
|
||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(id);
|
||||
|
||||
if (!stressfreiEmail) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'E-Mail-Konto nicht gefunden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stressfreiEmail.hasMailbox) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Keine Mailbox für diese E-Mail-Adresse',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Passwort entschlüsseln
|
||||
const password = await stressfreiEmailService.getDecryptedPassword(id);
|
||||
|
||||
if (!password) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Passwort konnte nicht entschlüsselt werden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// IMAP/SMTP-Einstellungen laden
|
||||
const settings = await getImapSmtpSettings();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
email: stressfreiEmail.email,
|
||||
password,
|
||||
imap: settings ? {
|
||||
server: settings.imapServer,
|
||||
port: settings.imapPort,
|
||||
encryption: settings.imapEncryption,
|
||||
} : null,
|
||||
smtp: settings ? {
|
||||
server: settings.smtpServer,
|
||||
port: settings.smtpPort,
|
||||
encryption: settings.smtpEncryption,
|
||||
} : null,
|
||||
},
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('getMailboxCredentials error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Laden der Mailbox-Zugangsdaten',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// Ungelesene E-Mails zählen
|
||||
export async function getUnreadCount(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = req.query.customerId ? parseInt(req.query.customerId as string) : undefined;
|
||||
const contractId = req.query.contractId ? parseInt(req.query.contractId as string) : undefined;
|
||||
|
||||
let count = 0;
|
||||
|
||||
if (customerId) {
|
||||
count = await cachedEmailService.getUnreadCountForCustomer(customerId);
|
||||
} else if (contractId) {
|
||||
count = await cachedEmailService.getUnreadCountForContract(contractId);
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { count } } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('getUnreadCount error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Zählen der ungelesenen E-Mails',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// E-Mail in Papierkorb verschieben (nur Admin)
|
||||
export async function deleteEmail(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
// Prüfen ob E-Mail existiert
|
||||
const email = await cachedEmailService.getCachedEmailById(id);
|
||||
if (!email) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'E-Mail nicht gefunden',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await cachedEmailService.moveEmailToTrash(id);
|
||||
|
||||
if (!result.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'E-Mail in Papierkorb verschoben' } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('deleteEmail error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Löschen der E-Mail',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== TRASH OPERATIONS ====================
|
||||
|
||||
// Papierkorb-E-Mails für einen Kunden abrufen
|
||||
export async function getTrashEmails(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
|
||||
const emails = await cachedEmailService.getTrashEmails(customerId);
|
||||
|
||||
res.json({ success: true, data: emails } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('getTrashEmails error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Laden der Papierkorb-E-Mails',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// Papierkorb-Anzahl für einen Kunden
|
||||
export async function getTrashCount(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
|
||||
const count = await cachedEmailService.getTrashCount(customerId);
|
||||
|
||||
res.json({ success: true, data: { count } } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('getTrashCount error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Zählen der Papierkorb-E-Mails',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// E-Mail aus Papierkorb wiederherstellen
|
||||
export async function restoreEmail(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
const result = await cachedEmailService.restoreEmailFromTrash(id);
|
||||
|
||||
if (!result.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'E-Mail wiederhergestellt' } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('restoreEmail error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Wiederherstellen der E-Mail',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// E-Mail endgültig löschen (aus Papierkorb)
|
||||
export async function permanentDeleteEmail(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
const result = await cachedEmailService.permanentDeleteEmail(id);
|
||||
|
||||
if (!result.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'E-Mail endgültig gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('permanentDeleteEmail error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim endgültigen Löschen der E-Mail',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
@@ -74,3 +74,26 @@ export async function deleteEmail(req: Request, res: Response): Promise<void> {
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetPassword(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const result = await stressfreiEmailService.resetMailboxPassword(parseInt(req.params.id));
|
||||
if (!result.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
data: { password: result.password },
|
||||
message: 'Passwort wurde zurückgesetzt',
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen des Passworts',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import contractCategoryRoutes from './routes/contractCategory.routes.js';
|
||||
import contractTaskRoutes from './routes/contractTask.routes.js';
|
||||
import appSettingRoutes from './routes/appSetting.routes.js';
|
||||
import emailProviderRoutes from './routes/emailProvider.routes.js';
|
||||
import cachedEmailRoutes from './routes/cachedEmail.routes.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -57,6 +58,7 @@ app.use('/api/contract-categories', contractCategoryRoutes);
|
||||
app.use('/api', contractTaskRoutes);
|
||||
app.use('/api/settings', appSettingRoutes);
|
||||
app.use('/api/email-providers', emailProviderRoutes);
|
||||
app.use('/api', cachedEmailRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
|
||||
@@ -1,26 +1,64 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { AuthRequest, JwtPayload } from '../types/index.js';
|
||||
|
||||
export function authenticate(
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function authenticate(
|
||||
req: AuthRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
// Token aus Header oder Query-Parameter (für Downloads)
|
||||
let token: string | null = null;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.split(' ')[1];
|
||||
} else if (req.query.token && typeof req.query.token === 'string') {
|
||||
// Fallback für Downloads: Token als Query-Parameter
|
||||
token = req.query.token;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ success: false, error: 'Nicht authentifiziert' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(
|
||||
token,
|
||||
process.env.JWT_SECRET || 'fallback-secret'
|
||||
) as JwtPayload;
|
||||
|
||||
// Prüfen ob Token durch Rechteänderung invalidiert wurde (nur für Mitarbeiter)
|
||||
if (decoded.userId && decoded.iat) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: { tokenInvalidatedAt: true, isActive: true },
|
||||
});
|
||||
|
||||
// Benutzer nicht gefunden oder deaktiviert
|
||||
if (!user || !user.isActive) {
|
||||
res.status(401).json({ success: false, error: 'Benutzer nicht mehr aktiv' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Token wurde vor der Invalidierung ausgestellt
|
||||
if (user.tokenInvalidatedAt) {
|
||||
const tokenIssuedAt = decoded.iat * 1000; // iat ist in Sekunden, Date ist in Millisekunden
|
||||
if (tokenIssuedAt < user.tokenInvalidatedAt.getTime()) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Ihre Berechtigungen wurden geändert. Bitte melden Sie sich erneut an.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch {
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import * as appSettingController from '../controllers/appSetting.controller.js';
|
||||
import * as backupController from '../controllers/backup.controller.js';
|
||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
|
||||
// Multer für Backup-Upload (in Memory speichern)
|
||||
const backupUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 500 * 1024 * 1024 }, // 500MB max
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype === 'application/zip' || file.originalname.endsWith('.zip')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Nur ZIP-Dateien sind erlaubt'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Öffentliche Einstellungen (für alle authentifizierten Benutzer, inkl. Kunden)
|
||||
@@ -26,4 +41,63 @@ router.put(
|
||||
appSettingController.updateSettings
|
||||
);
|
||||
|
||||
// ==================== BACKUP & RESTORE ====================
|
||||
|
||||
// Liste aller Backups
|
||||
router.get(
|
||||
'/backups',
|
||||
authenticate,
|
||||
requirePermission('settings:update'),
|
||||
backupController.listBackups
|
||||
);
|
||||
|
||||
// Neues Backup erstellen
|
||||
router.post(
|
||||
'/backup',
|
||||
authenticate,
|
||||
requirePermission('settings:update'),
|
||||
backupController.createBackup
|
||||
);
|
||||
|
||||
// Backup wiederherstellen
|
||||
router.post(
|
||||
'/backup/:name/restore',
|
||||
authenticate,
|
||||
requirePermission('settings:update'),
|
||||
backupController.restoreBackup
|
||||
);
|
||||
|
||||
// Backup löschen
|
||||
router.delete(
|
||||
'/backup/:name',
|
||||
authenticate,
|
||||
requirePermission('settings:update'),
|
||||
backupController.deleteBackup
|
||||
);
|
||||
|
||||
// Backup als ZIP herunterladen
|
||||
router.get(
|
||||
'/backup/:name/download',
|
||||
authenticate,
|
||||
requirePermission('settings:update'),
|
||||
backupController.downloadBackup
|
||||
);
|
||||
|
||||
// Backup-ZIP hochladen
|
||||
router.post(
|
||||
'/backup/upload',
|
||||
authenticate,
|
||||
requirePermission('settings:update'),
|
||||
backupUpload.single('backup'),
|
||||
backupController.uploadBackup
|
||||
);
|
||||
|
||||
// Werkseinstellungen (alles löschen)
|
||||
router.post(
|
||||
'/factory-reset',
|
||||
authenticate,
|
||||
requirePermission('settings:update'),
|
||||
backupController.factoryReset
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// ==================== CACHED EMAIL ROUTES ====================
|
||||
|
||||
import { Router } from 'express';
|
||||
import * as cachedEmailController from '../controllers/cachedEmail.controller.js';
|
||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ==================== E-MAIL LISTEN ====================
|
||||
|
||||
// E-Mails für Kunden (mit optionalem Account-Filter)
|
||||
// GET /api/customers/:customerId/emails?accountId=1&limit=50&offset=0
|
||||
router.get(
|
||||
'/customers/:customerId/emails',
|
||||
authenticate,
|
||||
requirePermission('customers:read'),
|
||||
cachedEmailController.getEmailsForCustomer
|
||||
);
|
||||
|
||||
// E-Mails für Vertrag
|
||||
// GET /api/contracts/:contractId/emails?limit=50&offset=0
|
||||
router.get(
|
||||
'/contracts/:contractId/emails',
|
||||
authenticate,
|
||||
requirePermission('contracts:read'),
|
||||
cachedEmailController.getEmailsForContract
|
||||
);
|
||||
|
||||
// Ordner-Anzahlen für Vertrag (zugeordnete E-Mails)
|
||||
// GET /api/contracts/:contractId/emails/folder-counts
|
||||
router.get(
|
||||
'/contracts/:contractId/emails/folder-counts',
|
||||
authenticate,
|
||||
requirePermission('contracts:read'),
|
||||
cachedEmailController.getContractFolderCounts
|
||||
);
|
||||
|
||||
// Mailbox-Konten eines Kunden (für Dropdown)
|
||||
// GET /api/customers/:customerId/mailbox-accounts
|
||||
router.get(
|
||||
'/customers/:customerId/mailbox-accounts',
|
||||
authenticate,
|
||||
requirePermission('customers:read'),
|
||||
cachedEmailController.getMailboxAccounts
|
||||
);
|
||||
|
||||
// Ungelesene E-Mails zählen
|
||||
// GET /api/emails/unread-count?customerId=1 oder ?contractId=1
|
||||
router.get(
|
||||
'/emails/unread-count',
|
||||
authenticate,
|
||||
requirePermission('customers:read'),
|
||||
cachedEmailController.getUnreadCount
|
||||
);
|
||||
|
||||
// ==================== EINZELNE E-MAIL ====================
|
||||
|
||||
// Einzelne E-Mail abrufen (mit Body, markiert als gelesen)
|
||||
// GET /api/emails/:id
|
||||
router.get(
|
||||
'/emails/:id',
|
||||
authenticate,
|
||||
requirePermission('customers:read'),
|
||||
cachedEmailController.getEmail
|
||||
);
|
||||
|
||||
// E-Mail in Papierkorb verschieben (nur User mit emails:delete Permission)
|
||||
// DELETE /api/emails/:id
|
||||
router.delete(
|
||||
'/emails/:id',
|
||||
authenticate,
|
||||
requirePermission('emails:delete'),
|
||||
cachedEmailController.deleteEmail
|
||||
);
|
||||
|
||||
// ==================== PAPIERKORB ====================
|
||||
|
||||
// Papierkorb-E-Mails für Kunden abrufen
|
||||
// GET /api/customers/:customerId/emails/trash
|
||||
router.get(
|
||||
'/customers/:customerId/emails/trash',
|
||||
authenticate,
|
||||
requirePermission('emails:delete'),
|
||||
cachedEmailController.getTrashEmails
|
||||
);
|
||||
|
||||
// Papierkorb-Anzahl für Kunden
|
||||
// GET /api/customers/:customerId/emails/trash/count
|
||||
router.get(
|
||||
'/customers/:customerId/emails/trash/count',
|
||||
authenticate,
|
||||
requirePermission('emails:delete'),
|
||||
cachedEmailController.getTrashCount
|
||||
);
|
||||
|
||||
// E-Mail aus Papierkorb wiederherstellen
|
||||
// POST /api/emails/:id/restore
|
||||
router.post(
|
||||
'/emails/:id/restore',
|
||||
authenticate,
|
||||
requirePermission('emails:delete'),
|
||||
cachedEmailController.restoreEmail
|
||||
);
|
||||
|
||||
// E-Mail endgültig löschen (nur aus Papierkorb)
|
||||
// DELETE /api/emails/:id/permanent
|
||||
router.delete(
|
||||
'/emails/:id/permanent',
|
||||
authenticate,
|
||||
requirePermission('emails:delete'),
|
||||
cachedEmailController.permanentDeleteEmail
|
||||
);
|
||||
|
||||
// E-Mail-Thread abrufen
|
||||
// GET /api/emails/:id/thread
|
||||
router.get(
|
||||
'/emails/:id/thread',
|
||||
authenticate,
|
||||
requirePermission('customers:read'),
|
||||
cachedEmailController.getThread
|
||||
);
|
||||
|
||||
// Als gelesen/ungelesen markieren
|
||||
// PATCH /api/emails/:id/read
|
||||
router.patch(
|
||||
'/emails/:id/read',
|
||||
authenticate,
|
||||
requirePermission('customers:update'),
|
||||
cachedEmailController.markAsRead
|
||||
);
|
||||
|
||||
// Stern umschalten
|
||||
// POST /api/emails/:id/star
|
||||
router.post(
|
||||
'/emails/:id/star',
|
||||
authenticate,
|
||||
requirePermission('customers:update'),
|
||||
cachedEmailController.toggleStar
|
||||
);
|
||||
|
||||
// ==================== ANHÄNGE ====================
|
||||
|
||||
// Anhang-Liste einer E-Mail
|
||||
// GET /api/emails/:emailId/attachments
|
||||
router.get(
|
||||
'/emails/:emailId/attachments',
|
||||
authenticate,
|
||||
requirePermission('customers:read'),
|
||||
cachedEmailController.getAttachments
|
||||
);
|
||||
|
||||
// Einzelnen Anhang herunterladen
|
||||
// GET /api/emails/:emailId/attachments/:filename
|
||||
router.get(
|
||||
'/emails/:emailId/attachments/:filename',
|
||||
authenticate,
|
||||
requirePermission('customers:read'),
|
||||
cachedEmailController.downloadAttachment
|
||||
);
|
||||
|
||||
// ==================== VERTRAGSZUORDNUNG ====================
|
||||
|
||||
// E-Mail Vertrag zuordnen
|
||||
// POST /api/emails/:id/assign { contractId: number }
|
||||
router.post(
|
||||
'/emails/:id/assign',
|
||||
authenticate,
|
||||
requirePermission('contracts:update'),
|
||||
cachedEmailController.assignToContract
|
||||
);
|
||||
|
||||
// Zuordnung aufheben
|
||||
// DELETE /api/emails/:id/assign
|
||||
router.delete(
|
||||
'/emails/:id/assign',
|
||||
authenticate,
|
||||
requirePermission('contracts:update'),
|
||||
cachedEmailController.unassignFromContract
|
||||
);
|
||||
|
||||
// ==================== STRESSFREI-EMAIL OPERATIONEN ====================
|
||||
|
||||
// E-Mails für ein Konto synchronisieren
|
||||
// POST /api/stressfrei-emails/:id/sync?full=true
|
||||
router.post(
|
||||
'/stressfrei-emails/:id/sync',
|
||||
authenticate,
|
||||
requirePermission('customers:update'),
|
||||
cachedEmailController.syncAccount
|
||||
);
|
||||
|
||||
// E-Mail senden
|
||||
// POST /api/stressfrei-emails/:id/send { to, cc, subject, text, html, inReplyTo, references }
|
||||
router.post(
|
||||
'/stressfrei-emails/:id/send',
|
||||
authenticate,
|
||||
requirePermission('customers:update'),
|
||||
cachedEmailController.sendEmailFromAccount
|
||||
);
|
||||
|
||||
// Mailbox nachträglich aktivieren
|
||||
// POST /api/stressfrei-emails/:id/enable-mailbox
|
||||
router.post(
|
||||
'/stressfrei-emails/:id/enable-mailbox',
|
||||
authenticate,
|
||||
requirePermission('customers:update'),
|
||||
cachedEmailController.enableMailbox
|
||||
);
|
||||
|
||||
// Mailbox-Status mit Provider synchronisieren
|
||||
// POST /api/stressfrei-emails/:id/sync-mailbox-status
|
||||
router.post(
|
||||
'/stressfrei-emails/:id/sync-mailbox-status',
|
||||
authenticate,
|
||||
requirePermission('customers:read'),
|
||||
cachedEmailController.syncMailboxStatus
|
||||
);
|
||||
|
||||
// Mailbox-Zugangsdaten abrufen (IMAP/SMTP)
|
||||
// GET /api/stressfrei-emails/:id/credentials
|
||||
router.get(
|
||||
'/stressfrei-emails/:id/credentials',
|
||||
authenticate,
|
||||
requirePermission('customers:read'),
|
||||
cachedEmailController.getMailboxCredentials
|
||||
);
|
||||
|
||||
// Ordner-Anzahlen für ein Konto (INBOX, SENT, ungelesen)
|
||||
// GET /api/stressfrei-emails/:id/folder-counts
|
||||
router.get(
|
||||
'/stressfrei-emails/:id/folder-counts',
|
||||
authenticate,
|
||||
requirePermission('customers:read'),
|
||||
cachedEmailController.getFolderCounts
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -4,10 +4,13 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Lesen für alle authentifizierten Benutzer
|
||||
router.get('/', authenticate, contractCategoryController.getContractCategories);
|
||||
router.post('/', authenticate, requirePermission('platforms:create'), contractCategoryController.createContractCategory);
|
||||
router.get('/:id', authenticate, contractCategoryController.getContractCategory);
|
||||
router.put('/:id', authenticate, requirePermission('platforms:update'), contractCategoryController.updateContractCategory);
|
||||
router.delete('/:id', authenticate, requirePermission('platforms:delete'), contractCategoryController.deleteContractCategory);
|
||||
|
||||
// Ändern/Löschen nur mit Entwickler-Berechtigung (Vertragstypen erfordern Formular-Anpassungen)
|
||||
router.post('/', authenticate, requirePermission('developer:access'), contractCategoryController.createContractCategory);
|
||||
router.put('/:id', authenticate, requirePermission('developer:access'), contractCategoryController.updateContractCategory);
|
||||
router.delete('/:id', authenticate, requirePermission('developer:access'), contractCategoryController.deleteContractCategory);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -9,4 +9,7 @@ router.get('/:id', authenticate, requirePermission('customers:read'), stressfrei
|
||||
router.put('/:id', authenticate, requirePermission('customers:update'), stressfreiEmailController.updateEmail);
|
||||
router.delete('/:id', authenticate, requirePermission('customers:delete'), stressfreiEmailController.deleteEmail);
|
||||
|
||||
// Passwort zurücksetzen (generiert neues Passwort und setzt es beim Provider)
|
||||
router.post('/:id/reset-password', authenticate, requirePermission('customers:update'), stressfreiEmailController.resetPassword);
|
||||
|
||||
export default router;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
// Mehrfache Leerzeilen reduzieren
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface JwtPayload {
|
||||
customerId?: number; // Eigene Kunden-ID (bei Kundenportal-Login)
|
||||
isCustomerPortal?: boolean; // Ist dies ein Kundenportal-Login?
|
||||
representedCustomerIds?: number[]; // IDs der Kunden, die dieser Kunde vertreten kann
|
||||
iat?: number; // Token ausgestellt am (Unix Timestamp, automatisch von JWT)
|
||||
}
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// ==================== PASSWORD GENERATOR ====================
|
||||
// Generiert sichere, zufällige Passwörter
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
// Zeichensätze für Passwort-Generierung
|
||||
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const NUMBERS = '0123456789';
|
||||
const SPECIAL = '!@#$%^&*()_+-=[]{}|;:,.<>?';
|
||||
|
||||
// Standard-Passwortlänge
|
||||
const DEFAULT_LENGTH = 16;
|
||||
|
||||
export interface PasswordOptions {
|
||||
length?: number;
|
||||
includeLowercase?: boolean;
|
||||
includeUppercase?: boolean;
|
||||
includeNumbers?: boolean;
|
||||
includeSpecial?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert ein kryptografisch sicheres Passwort
|
||||
*/
|
||||
export function generateSecurePassword(options: PasswordOptions = {}): string {
|
||||
const {
|
||||
length = DEFAULT_LENGTH,
|
||||
includeLowercase = true,
|
||||
includeUppercase = true,
|
||||
includeNumbers = true,
|
||||
includeSpecial = true,
|
||||
} = options;
|
||||
|
||||
// Zeichensatz zusammenstellen
|
||||
let charset = '';
|
||||
const requiredChars: string[] = [];
|
||||
|
||||
if (includeLowercase) {
|
||||
charset += LOWERCASE;
|
||||
requiredChars.push(getRandomChar(LOWERCASE));
|
||||
}
|
||||
if (includeUppercase) {
|
||||
charset += UPPERCASE;
|
||||
requiredChars.push(getRandomChar(UPPERCASE));
|
||||
}
|
||||
if (includeNumbers) {
|
||||
charset += NUMBERS;
|
||||
requiredChars.push(getRandomChar(NUMBERS));
|
||||
}
|
||||
if (includeSpecial) {
|
||||
charset += SPECIAL;
|
||||
requiredChars.push(getRandomChar(SPECIAL));
|
||||
}
|
||||
|
||||
if (charset.length === 0) {
|
||||
throw new Error('Mindestens ein Zeichensatz muss aktiviert sein');
|
||||
}
|
||||
|
||||
// Restliche Zeichen auffüllen
|
||||
const remainingLength = Math.max(0, length - requiredChars.length);
|
||||
const randomChars: string[] = [];
|
||||
|
||||
for (let i = 0; i < remainingLength; i++) {
|
||||
randomChars.push(getRandomChar(charset));
|
||||
}
|
||||
|
||||
// Alle Zeichen mischen (Fisher-Yates Shuffle)
|
||||
const allChars = [...requiredChars, ...randomChars];
|
||||
for (let i = allChars.length - 1; i > 0; i--) {
|
||||
const j = getRandomInt(i + 1);
|
||||
[allChars[i], allChars[j]] = [allChars[j], allChars[i]];
|
||||
}
|
||||
|
||||
return allChars.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert ein einfaches Passwort ohne Sonderzeichen (für APIs die das nicht mögen)
|
||||
*/
|
||||
export function generateSimplePassword(length = 12): string {
|
||||
return generateSecurePassword({
|
||||
length,
|
||||
includeLowercase: true,
|
||||
includeUppercase: true,
|
||||
includeNumbers: true,
|
||||
includeSpecial: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Kryptografisch sichere Zufallszahl
|
||||
function getRandomInt(max: number): number {
|
||||
const bytes = randomBytes(4);
|
||||
const value = bytes.readUInt32BE(0);
|
||||
return value % max;
|
||||
}
|
||||
|
||||
// Zufälliges Zeichen aus einem Zeichensatz
|
||||
function getRandomChar(charset: string): string {
|
||||
return charset[getRandomInt(charset.length)];
|
||||
}
|
||||
Reference in New Issue
Block a user