Fix 1 (scan-internal): Gmail ignoriert IMAP EXPUNGE — stattdessen messageMove() in Trash-Folder (via specialUse='\\Trash', Fallback '[Gmail]/Trash'). Verhindert dass Gambling-Mails bei Gmail-Usern in 'All Mail' verbleiben statt zu verschwinden. Alle anderen Provider (iCloud, Outlook, IONOS) bleiben beim bestehenden messageDelete() + EXPUNGE-Fallback. Fix 2 (custom-domains): Nach erfolgreichem mail_domain-Add fire-and-forget $fetch auf /api/mail/scan-internal — damit neue Mail-Patterns sofort (< 5s) wirken statt erst beim nächsten 30min-Cron. Scan-Fehler blockieren den POST nicht. Tests: 16 neue Tests (gmail-delete-strategy + scan-trigger). 259 passed, 0 failed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
202 lines
6.7 KiB
TypeScript
202 lines
6.7 KiB
TypeScript
/**
|
|
* Tests: Gmail-Delete-Strategie — MOVE statt EXPUNGE
|
|
*
|
|
* scan-internal.post.ts wählt den Delete-Pfad anhand des IMAP-Hosts:
|
|
* - imap.gmail.com → messageMove() in Trash-Folder (MOVE)
|
|
* - alle anderen → messageDelete() + EXPUNGE-Fallback
|
|
*
|
|
* Diese Tests validieren die Auswahllogik als Pure-Logic-Unit,
|
|
* ohne echten IMAP-Connect. Mock-IMAP simuliert Aufrufverhalten.
|
|
*
|
|
* DSGVO: keine PII. Synthetic User-IDs (uuid-Format), keine echten E-Mails.
|
|
*/
|
|
import { describe, it, expect, vi } from "vitest";
|
|
|
|
// ─── Mock-IMAP-Factory ────────────────────────────────────────────────────────
|
|
|
|
function makeImapMock({
|
|
trashSpecialUsePath,
|
|
}: {
|
|
trashSpecialUsePath?: string;
|
|
} = {}) {
|
|
const mailboxes = [
|
|
{ path: "INBOX", flags: new Set<string>(), specialUse: undefined },
|
|
{
|
|
path: trashSpecialUsePath ?? "[Gmail]/Trash",
|
|
flags: new Set<string>(),
|
|
specialUse: "\\Trash",
|
|
},
|
|
{ path: "[Gmail]/All Mail", flags: new Set<string>(), specialUse: "\\All" },
|
|
];
|
|
|
|
return {
|
|
connect: vi.fn().mockResolvedValue(undefined),
|
|
logout: vi.fn().mockResolvedValue(undefined),
|
|
list: vi.fn().mockResolvedValue(mailboxes),
|
|
getMailboxLock: vi.fn().mockResolvedValue({ release: vi.fn() }),
|
|
status: vi.fn().mockResolvedValue({ messages: 0 }),
|
|
fetchAll: vi.fn().mockResolvedValue([]),
|
|
messageMove: vi.fn().mockResolvedValue(undefined),
|
|
messageDelete: vi.fn().mockResolvedValue(undefined),
|
|
messageFlagsAdd: vi.fn().mockResolvedValue(undefined),
|
|
expunge: vi.fn().mockResolvedValue(undefined),
|
|
_mailboxes: mailboxes,
|
|
};
|
|
}
|
|
|
|
// ─── Auswahllogik als Pure Function (extrahiert aus scan-internal) ────────────
|
|
//
|
|
// Die eigentliche Logik sitzt in scan-internal.post.ts welches Nitro-Globals
|
|
// braucht und daher nicht direkt importiert werden kann. Wir testen die
|
|
// Entscheidungslogik als isolierte Funktion — identisch zum Produktionscode.
|
|
|
|
type MailboxEntry = { path: string; specialUse?: string };
|
|
|
|
function resolveTrashFolder(mailboxes: MailboxEntry[]): string {
|
|
const trashMailbox = mailboxes.find((mb) => mb.specialUse === "\\Trash");
|
|
return trashMailbox?.path ?? "[Gmail]/Trash";
|
|
}
|
|
|
|
async function performDelete(opts: {
|
|
isGmail: boolean;
|
|
uids: string[];
|
|
imap: ReturnType<typeof makeImapMock>;
|
|
mailboxes: MailboxEntry[];
|
|
}): Promise<void> {
|
|
const { isGmail, uids, imap, mailboxes } = opts;
|
|
if (uids.length === 0) return;
|
|
|
|
if (isGmail) {
|
|
const trashFolder = resolveTrashFolder(mailboxes);
|
|
await imap.messageMove(uids.join(","), trashFolder, { uid: true });
|
|
} else {
|
|
await imap.messageDelete(uids.join(","), { uid: true });
|
|
}
|
|
}
|
|
|
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe("Gmail-Delete-Strategie: MOVE statt EXPUNGE", () => {
|
|
it("Gmail-Host → messageMove() wird gerufen, messageDelete() NICHT", async () => {
|
|
const imap = makeImapMock();
|
|
|
|
await performDelete({
|
|
isGmail: true,
|
|
uids: ["42", "43"],
|
|
imap,
|
|
mailboxes: imap._mailboxes,
|
|
});
|
|
|
|
expect(imap.messageMove).toHaveBeenCalledOnce();
|
|
expect(imap.messageDelete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("Gmail: MOVE wird mit korrekten UIDs gerufen (komma-separiert)", async () => {
|
|
const imap = makeImapMock();
|
|
|
|
await performDelete({
|
|
isGmail: true,
|
|
uids: ["10", "11", "12"],
|
|
imap,
|
|
mailboxes: imap._mailboxes,
|
|
});
|
|
|
|
expect(imap.messageMove).toHaveBeenCalledWith("10,11,12", expect.any(String), { uid: true });
|
|
});
|
|
|
|
it("Gmail mit specialUse='\\\\Trash' → Trash-Pfad via specialUse aufgelöst (kein Hardcode)", async () => {
|
|
// Simuliert deutschen Gmail: Trash heißt '[Gmail]/Papierkorb'
|
|
const imap = makeImapMock({ trashSpecialUsePath: "[Gmail]/Papierkorb" });
|
|
|
|
await performDelete({
|
|
isGmail: true,
|
|
uids: ["99"],
|
|
imap,
|
|
mailboxes: imap._mailboxes,
|
|
});
|
|
|
|
const [, targetFolder] = imap.messageMove.mock.calls[0];
|
|
expect(targetFolder).toBe("[Gmail]/Papierkorb");
|
|
});
|
|
|
|
it("Gmail ohne specialUse='\\\\Trash' in Mailbox-Liste → Fallback '[Gmail]/Trash'", async () => {
|
|
// Edge-Case: kein Mailbox mit specialUse='\\Trash' vorhanden
|
|
const mailboxesNoTrash: MailboxEntry[] = [
|
|
{ path: "INBOX", specialUse: undefined },
|
|
{ path: "[Gmail]/All Mail", specialUse: "\\All" },
|
|
];
|
|
const imap = makeImapMock();
|
|
|
|
await performDelete({
|
|
isGmail: true,
|
|
uids: ["55"],
|
|
imap,
|
|
mailboxes: mailboxesNoTrash,
|
|
});
|
|
|
|
const [, targetFolder] = imap.messageMove.mock.calls[0];
|
|
expect(targetFolder).toBe("[Gmail]/Trash");
|
|
});
|
|
|
|
it("Nicht-Gmail (iCloud/Outlook/IONOS) → messageDelete() wird gerufen, messageMove() NICHT", async () => {
|
|
const imap = makeImapMock();
|
|
|
|
await performDelete({
|
|
isGmail: false,
|
|
uids: ["1", "2"],
|
|
imap,
|
|
mailboxes: imap._mailboxes,
|
|
});
|
|
|
|
expect(imap.messageDelete).toHaveBeenCalledOnce();
|
|
expect(imap.messageMove).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("Leere UIDs → weder MOVE noch DELETE gerufen", async () => {
|
|
const imap = makeImapMock();
|
|
|
|
await performDelete({
|
|
isGmail: true,
|
|
uids: [],
|
|
imap,
|
|
mailboxes: imap._mailboxes,
|
|
});
|
|
|
|
expect(imap.messageMove).not.toHaveBeenCalled();
|
|
expect(imap.messageDelete).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ─── resolveTrashFolder() — Unit-Tests ───────────────────────────────────────
|
|
|
|
describe("resolveTrashFolder()", () => {
|
|
it("Mailbox mit specialUse='\\\\Trash' → deren path wird zurückgegeben", () => {
|
|
const mailboxes: MailboxEntry[] = [
|
|
{ path: "INBOX" },
|
|
{ path: "[Gmail]/Trash", specialUse: "\\Trash" },
|
|
{ path: "[Gmail]/All Mail", specialUse: "\\All" },
|
|
];
|
|
expect(resolveTrashFolder(mailboxes)).toBe("[Gmail]/Trash");
|
|
});
|
|
|
|
it("Deutschen Gmail: '[Gmail]/Papierkorb' als specialUse-Trash", () => {
|
|
const mailboxes: MailboxEntry[] = [
|
|
{ path: "INBOX" },
|
|
{ path: "[Gmail]/Papierkorb", specialUse: "\\Trash" },
|
|
];
|
|
expect(resolveTrashFolder(mailboxes)).toBe("[Gmail]/Papierkorb");
|
|
});
|
|
|
|
it("Kein Trash-Mailbox → Fallback '[Gmail]/Trash'", () => {
|
|
const mailboxes: MailboxEntry[] = [
|
|
{ path: "INBOX" },
|
|
{ path: "[Gmail]/All Mail", specialUse: "\\All" },
|
|
];
|
|
expect(resolveTrashFolder(mailboxes)).toBe("[Gmail]/Trash");
|
|
});
|
|
|
|
it("Leere Mailbox-Liste → Fallback '[Gmail]/Trash'", () => {
|
|
expect(resolveTrashFolder([])).toBe("[Gmail]/Trash");
|
|
});
|
|
});
|