E-Mail-Client: Spam-Ordner als eigener Tab
Neuer Tab "Spam" (zwischen Gesendet und Papierkorb), zeigt den Junk-/ Spam-Ordner des gewählten Postfachs – damit fälschlich als Spam einsortierte Mails auffindbar sind. Backend: - EmailFolder-Enum um SPAM erweitert (Migration, Wert angehängt → kein Rewrite bestehender Zeilen). - imapService.findJunkFolderPath: ermittelt den Junk-Ordner per Special-Use-Flag \Junk + üblicher Namensliste (Junk/Spam/…). - syncAllFoldersForAccount synct den Junk-Ordner zusätzlich als dbFolder=SPAM (syncEmailsForAccount bekommt dbFolder-Option). - getCachedEmails + getFolderCountsForAccount um SPAM erweitert. - Papierkorb-Move/Restore für Spam-Mails nutzt den echten Junk-Pfad als Quell-/Zielordner. Frontend: Tab + Badge (ungelesen/gesamt), gleicher List-/Detail-Pfad wie INBOX; Zuordnen-zu-Vertrag auch aus Spam möglich. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
import { CachedEmail, Prisma, EmailFolder } from '@prisma/client';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { decrypt } from '../utils/encryption.js';
|
||||
import { fetchEmails, ImapCredentials, FetchedEmail, moveToTrash, restoreFromTrash, permanentDelete, humanizeImapError } from './imapService.js';
|
||||
import { fetchEmails, ImapCredentials, FetchedEmail, moveToTrash, restoreFromTrash, permanentDelete, humanizeImapError, findJunkFolderPath } from './imapService.js';
|
||||
import { getImapSmtpSettings } from './emailProvider/emailProviderService.js';
|
||||
|
||||
// ==================== TYPES ====================
|
||||
@@ -68,7 +68,7 @@ export interface EmailListOptions {
|
||||
// E-Mails für eine StressfreiEmail synchronisieren
|
||||
export async function syncEmailsForAccount(
|
||||
stressfreiEmailId: number,
|
||||
options?: { folder?: string; fullSync?: boolean }
|
||||
options?: { folder?: string; fullSync?: boolean; dbFolder?: EmailFolder }
|
||||
): Promise<SyncResult> {
|
||||
const folder = options?.folder || 'INBOX';
|
||||
const fullSync = options?.fullSync || false;
|
||||
@@ -111,10 +111,12 @@ export async function syncEmailsForAccount(
|
||||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||||
};
|
||||
|
||||
// Folder-Mapping: IMAP-Ordner zu DB-Ordner
|
||||
const dbFolder = folder.toUpperCase() === 'SENT' || folder.toLowerCase() === 'sent'
|
||||
? EmailFolder.SENT
|
||||
: EmailFolder.INBOX;
|
||||
// Folder-Mapping: IMAP-Ordner zu DB-Ordner. Explizites dbFolder (z.B.
|
||||
// SPAM für den Junk-Ordner, dessen IMAP-Name variiert) hat Vorrang.
|
||||
const dbFolder = options?.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;
|
||||
@@ -168,7 +170,33 @@ export async function syncEmailsForAccount(
|
||||
}
|
||||
}
|
||||
|
||||
// Alle Ordner synchronisieren (INBOX + SENT)
|
||||
/**
|
||||
* Baut die IMAP-Credentials für ein Postfach (für Operationen, die eine
|
||||
* eigene Verbindung brauchen – z.B. Junk-Ordner-Pfad ermitteln). Gibt null
|
||||
* zurück, wenn kein Postfach/Passwort/Provider-Settings vorhanden sind.
|
||||
*/
|
||||
async function getImapCredentialsForAccount(stressfreiEmailId: number): Promise<ImapCredentials | null> {
|
||||
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({ where: { id: stressfreiEmailId } });
|
||||
if (!stressfreiEmail?.hasMailbox || !stressfreiEmail.emailPasswordEncrypted) return null;
|
||||
const settings = await getImapSmtpSettings();
|
||||
if (!settings) return null;
|
||||
let password: string;
|
||||
try {
|
||||
password = decrypt(stressfreiEmail.emailPasswordEncrypted);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
host: settings.imapServer,
|
||||
port: settings.imapPort,
|
||||
user: stressfreiEmail.email,
|
||||
password,
|
||||
encryption: settings.imapEncryption,
|
||||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||||
};
|
||||
}
|
||||
|
||||
// Alle Ordner synchronisieren (INBOX + SENT + SPAM/Junk)
|
||||
export async function syncAllFoldersForAccount(
|
||||
stressfreiEmailId: number,
|
||||
options?: { fullSync?: boolean }
|
||||
@@ -181,6 +209,23 @@ export async function syncAllFoldersForAccount(
|
||||
// SENT synchronisieren (Plesk verwendet "Sent" als Ordnername)
|
||||
const sentResult = await syncEmailsForAccount(stressfreiEmailId, { folder: 'Sent', fullSync });
|
||||
|
||||
// SPAM/Junk synchronisieren – Ordnername variiert je Server, daher erst
|
||||
// per IMAP ermitteln. Existiert kein Junk-Ordner, wird Spam übersprungen
|
||||
// (kein Fehler). Wird explizit als dbFolder=SPAM abgelegt.
|
||||
let spamNew = 0;
|
||||
const credentials = await getImapCredentialsForAccount(stressfreiEmailId);
|
||||
if (credentials) {
|
||||
const junkPath = await findJunkFolderPath(credentials);
|
||||
if (junkPath) {
|
||||
const spamResult = await syncEmailsForAccount(stressfreiEmailId, {
|
||||
folder: junkPath,
|
||||
dbFolder: EmailFolder.SPAM,
|
||||
fullSync,
|
||||
});
|
||||
if (spamResult.success) spamNew = spamResult.newEmails;
|
||||
}
|
||||
}
|
||||
|
||||
// Ergebnisse kombinieren
|
||||
if (!inboxResult.success && !sentResult.success) {
|
||||
return {
|
||||
@@ -193,7 +238,7 @@ export async function syncAllFoldersForAccount(
|
||||
|
||||
return {
|
||||
success: true,
|
||||
newEmails: inboxResult.newEmails + sentResult.newEmails,
|
||||
newEmails: inboxResult.newEmails + sentResult.newEmails + spamNew,
|
||||
totalEmails: inboxResult.totalEmails,
|
||||
};
|
||||
}
|
||||
@@ -280,9 +325,11 @@ export async function getCachedEmails(
|
||||
where.contractId = options.contractId;
|
||||
}
|
||||
|
||||
// Folder-Filter (INBOX oder SENT)
|
||||
// Folder-Filter (INBOX, SENT oder SPAM)
|
||||
if (options.folder === 'SENT') {
|
||||
where.folder = EmailFolder.SENT;
|
||||
} else if (options.folder === 'SPAM') {
|
||||
where.folder = EmailFolder.SPAM;
|
||||
} else if (options.folder === 'INBOX' || !options.folder) {
|
||||
// Standard: Nur Posteingang
|
||||
where.folder = EmailFolder.INBOX;
|
||||
@@ -573,10 +620,12 @@ export async function getFolderCountsForAccount(stressfreiEmailId: number): Prom
|
||||
inboxUnread: number;
|
||||
sent: number;
|
||||
sentUnread: number;
|
||||
spam: number;
|
||||
spamUnread: number;
|
||||
trash: number;
|
||||
trashUnread: number;
|
||||
}> {
|
||||
const [inbox, inboxUnread, sent, sentUnread, trash, trashUnread] = await Promise.all([
|
||||
const [inbox, inboxUnread, sent, sentUnread, spam, spamUnread, trash, trashUnread] = await Promise.all([
|
||||
// INBOX total
|
||||
prisma.cachedEmail.count({
|
||||
where: { stressfreiEmailId, folder: EmailFolder.INBOX, isDeleted: false },
|
||||
@@ -593,6 +642,14 @@ export async function getFolderCountsForAccount(stressfreiEmailId: number): Prom
|
||||
prisma.cachedEmail.count({
|
||||
where: { stressfreiEmailId, folder: EmailFolder.SENT, isDeleted: false, isRead: false },
|
||||
}),
|
||||
// SPAM total
|
||||
prisma.cachedEmail.count({
|
||||
where: { stressfreiEmailId, folder: EmailFolder.SPAM, isDeleted: false },
|
||||
}),
|
||||
// SPAM unread
|
||||
prisma.cachedEmail.count({
|
||||
where: { stressfreiEmailId, folder: EmailFolder.SPAM, isDeleted: false, isRead: false },
|
||||
}),
|
||||
// TRASH total (isDeleted = true)
|
||||
prisma.cachedEmail.count({
|
||||
where: { stressfreiEmailId, isDeleted: true },
|
||||
@@ -603,7 +660,7 @@ export async function getFolderCountsForAccount(stressfreiEmailId: number): Prom
|
||||
}),
|
||||
]);
|
||||
|
||||
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
|
||||
return { inbox, inboxUnread, sent, sentUnread, spam, spamUnread, trash, trashUnread };
|
||||
}
|
||||
|
||||
// E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails).
|
||||
@@ -759,8 +816,17 @@ export async function moveEmailToTrash(id: number): Promise<TrashOperationResult
|
||||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||||
};
|
||||
|
||||
// Ordner bestimmen (INBOX oder Sent)
|
||||
const sourceFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
|
||||
// IMAP-Quellordner bestimmen. Bei SPAM den tatsächlichen Junk-Pfad
|
||||
// ermitteln (Name variiert je Server) – sonst würde moveToTrash im
|
||||
// falschen Ordner nach der UID suchen.
|
||||
let sourceFolder: string;
|
||||
if (email.folder === 'SENT') {
|
||||
sourceFolder = 'Sent';
|
||||
} else if (email.folder === 'SPAM') {
|
||||
sourceFolder = (await findJunkFolderPath(credentials)) || 'Junk';
|
||||
} else {
|
||||
sourceFolder = 'INBOX';
|
||||
}
|
||||
|
||||
// Auf IMAP-Server in Trash verschieben
|
||||
const imapResult = await moveToTrash(credentials, email.uid, sourceFolder);
|
||||
@@ -826,8 +892,16 @@ export async function restoreEmailFromTrash(id: number): Promise<TrashOperationR
|
||||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||||
};
|
||||
|
||||
// Ziel-Ordner bestimmen (basierend auf originalem Ordner)
|
||||
const targetFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
|
||||
// Ziel-Ordner bestimmen (basierend auf originalem Ordner). SPAM zurück
|
||||
// in den tatsächlichen Junk-Ordner (Name variiert je Server).
|
||||
let targetFolder: string;
|
||||
if (email.folder === 'SENT') {
|
||||
targetFolder = 'Sent';
|
||||
} else if (email.folder === 'SPAM') {
|
||||
targetFolder = (await findJunkFolderPath(credentials)) || 'Junk';
|
||||
} else {
|
||||
targetFolder = 'INBOX';
|
||||
}
|
||||
|
||||
// Auf IMAP-Server aus Trash wiederherstellen
|
||||
const imapResult = await restoreFromTrash(credentials, email.uid, targetFolder);
|
||||
|
||||
@@ -784,6 +784,9 @@ export async function fetchAttachmentList(
|
||||
// Typische Namen für Trash-Ordner (verschiedene Server/Sprachen)
|
||||
const TRASH_FOLDER_NAMES = ['Trash', 'INBOX.Trash', 'Deleted', 'Deleted Items', 'Deleted Messages', 'Papierkorb'];
|
||||
|
||||
// Übliche Namen für den Spam-/Junk-Ordner (Plesk/Dovecot: "Junk").
|
||||
const JUNK_FOLDER_NAMES = ['Junk', 'INBOX.Junk', 'Spam', 'INBOX.Spam', 'Junk E-mail', 'Junk Email', 'Bulk Mail'];
|
||||
|
||||
// Helper: Trash-Ordner finden
|
||||
async function findTrashFolder(client: ImapFlow): Promise<string | null> {
|
||||
const mailboxes = await client.list();
|
||||
@@ -803,6 +806,48 @@ async function findTrashFolder(client: ImapFlow): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt den tatsächlichen Pfad des Spam-/Junk-Ordners eines Postfachs.
|
||||
* Bevorzugt das IMAP-Special-Use-Flag \Junk, fällt sonst auf übliche
|
||||
* Ordnernamen zurück. Öffnet eine eigene Verbindung; gibt null zurück,
|
||||
* wenn kein Junk-Ordner existiert (dann wird Spam einfach nicht synct).
|
||||
*/
|
||||
export async function findJunkFolderPath(credentials: ImapCredentials): Promise<string | null> {
|
||||
const encryption = credentials.encryption ?? 'SSL';
|
||||
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') {
|
||||
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
||||
}
|
||||
|
||||
const client = new ImapFlow(clientOptions);
|
||||
try {
|
||||
await client.connect();
|
||||
const mailboxes = await client.list();
|
||||
// Zuerst per Special-Use-Flag \Junk
|
||||
const byFlag = mailboxes.find(m => m.specialUse === '\\Junk');
|
||||
if (byFlag) return byFlag.path;
|
||||
// Sonst per Namensliste
|
||||
for (const name of JUNK_FOLDER_NAMES) {
|
||||
const found = mailboxes.find(m =>
|
||||
m.path.toLowerCase() === name.toLowerCase() ||
|
||||
m.name.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
if (found) return found.path;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
try { await client.logout(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
export interface MoveToTrashResult {
|
||||
success: boolean;
|
||||
newUid?: number; // UID im Trash-Ordner
|
||||
|
||||
Reference in New Issue
Block a user