fix(mail): Gmail-Delete als Trash-Move + Scan-Trigger nach Custom-Domain-Add
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>
This commit is contained in:
parent
d97e3aa496
commit
bf6affb3eb
@ -215,6 +215,26 @@ export default defineEventHandler(async (event) => {
|
||||
() => {},
|
||||
);
|
||||
|
||||
// Fire-and-forget: Scan sofort triggern damit neue Mail-Domain-Pattern innerhalb
|
||||
// von Sekunden wirkt — ohne auf den Cron-Intervall (30min) zu warten.
|
||||
// Casts on event: useRuntimeConfig braucht den event-Context für Nitro.
|
||||
if (type === "mail_domain" || type === "mail_display_name") {
|
||||
const config = useRuntimeConfig(event);
|
||||
const adminSecret = (config.adminSecret as string) || process.env.ADMIN_SECRET || "";
|
||||
$fetch("/api/mail/scan-internal", {
|
||||
method: "POST",
|
||||
headers: { "x-admin-secret": adminSecret },
|
||||
body: { userId: user.id },
|
||||
}).catch((err: unknown) => {
|
||||
// Fire-and-forget: Fehler loggen, aber POST-Response nicht blockieren.
|
||||
// Der Scan ist best-effort — nächster Cron holt nach.
|
||||
console.warn(
|
||||
`[custom-domains] post-add scan-trigger failed for user ${user.id}:`,
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
const msg =
|
||||
|
||||
@ -193,6 +193,33 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
if (uidsToDelete.length > 0) {
|
||||
// Gmail-Detection: imap.messageDelete() auf Gmail erzeugt kein echtes DELETE —
|
||||
// Gmail bewegt die Mail in "[Gmail]/All Mail" statt sie zu entfernen. Für Gmail
|
||||
// müssen wir per messageMove() in den Trash verschieben, der dann nach 30 Tagen
|
||||
// automatisch geleert wird.
|
||||
const isGmail = connection.imapHost === "imap.gmail.com";
|
||||
if (isGmail) {
|
||||
// Trash-Folder via specialUse='\\Trash' discovern, Fallback: '[Gmail]/Trash'
|
||||
const trashMailbox = mailboxes.find(
|
||||
(mb2: any) => mb2.specialUse === "\\Trash",
|
||||
);
|
||||
const trashFolder = trashMailbox?.path ?? "[Gmail]/Trash";
|
||||
try {
|
||||
await imap.messageMove(uidsToDelete.join(","), trashFolder, { uid: true });
|
||||
console.log(
|
||||
`[scan-internal] ${connection.email} | ${mb.path} | moved ${uidsToDelete.length} gambling mails to ${trashFolder} (Gmail)`,
|
||||
);
|
||||
} catch (moveErr) {
|
||||
// Move fehlgeschlagen — eskalieren statt stumm ignorieren. Der Scan-Run
|
||||
// schreibt trotzdem den DB-Insert (mail_blocked), aber loggt den Fehler
|
||||
// damit Operations/Alerting reagieren kann.
|
||||
console.error(
|
||||
`[scan-internal] Gmail MOVE to ${trashFolder} failed for ${connection.email} | ${mb.path}:`,
|
||||
moveErr,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Non-Gmail (iCloud, Outlook, IONOS, etc.): EXPUNGE funktioniert korrekt
|
||||
try {
|
||||
await imap.messageDelete(uidsToDelete.join(","), { uid: true });
|
||||
} catch {
|
||||
@ -211,6 +238,7 @@ export default defineEventHandler(async (event) => {
|
||||
`[scan-internal] ${connection.email} | ${mb.path} | deleted ${uidsToDelete.length} gambling mails`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await insertMailBlocked(toInsert);
|
||||
|
||||
|
||||
172
backend/tests/custom-domains/scan-trigger.test.ts
Normal file
172
backend/tests/custom-domains/scan-trigger.test.ts
Normal file
@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Tests: Custom-Domain POST — fire-and-forget Scan-Trigger
|
||||
*
|
||||
* Nach erfolgreichem addUserCustomDomain (type=mail_domain oder mail_display_name)
|
||||
* muss ein fire-and-forget $fetch auf /api/mail/scan-internal abgefeuert werden:
|
||||
* - userId korrekt im Body
|
||||
* - x-admin-secret im Header (aus runtimeConfig.adminSecret)
|
||||
* - POST-Response wartet NICHT auf Scan-Completion (fire-and-forget)
|
||||
* - Scan-Fehler blockieren die POST-Response nicht
|
||||
* - type='web' triggert KEINEN Scan
|
||||
*
|
||||
* Getestete Funktion: resolveTypeAndValue() + Trigger-Logik (isoliert aus index.post.ts).
|
||||
*
|
||||
* DSGVO: keine PII. Synthetic User-IDs (uuid-Format), synthetic Domains.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// ─── $fetch Mock ──────────────────────────────────────────────────────────────
|
||||
// Nitro's $fetch wird als global injiziert. Wir mocken es im globalThis
|
||||
// bevor die Logik läuft.
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function setupFetchMock(resolveWith: unknown = { ok: true }) {
|
||||
fetchMock = vi.fn().mockResolvedValue(resolveWith);
|
||||
(globalThis as Record<string, unknown>).$fetch = fetchMock;
|
||||
}
|
||||
|
||||
function teardownFetchMock() {
|
||||
delete (globalThis as Record<string, unknown>).$fetch;
|
||||
}
|
||||
|
||||
// ─── Trigger-Logik (isoliert aus index.post.ts) ───────────────────────────────
|
||||
// Produktionscode ist in index.post.ts — wir testen die Entscheidungslogik
|
||||
// als Pure Function um Nitro-Globals-Abhängigkeiten zu vermeiden.
|
||||
|
||||
type CustomDomainType = "web" | "mail_domain" | "mail_display_name";
|
||||
|
||||
async function triggerScanIfMailDomain(opts: {
|
||||
type: CustomDomainType;
|
||||
userId: string;
|
||||
adminSecret: string;
|
||||
}): Promise<void> {
|
||||
const { type, userId, adminSecret } = opts;
|
||||
|
||||
if (type === "mail_domain" || type === "mail_display_name") {
|
||||
const globalFetch = (globalThis as Record<string, unknown>).$fetch as (
|
||||
url: string,
|
||||
opts: unknown,
|
||||
) => Promise<unknown>;
|
||||
|
||||
globalFetch("/api/mail/scan-internal", {
|
||||
method: "POST",
|
||||
headers: { "x-admin-secret": adminSecret },
|
||||
body: { userId },
|
||||
}).catch((err: unknown) => {
|
||||
console.warn(`[custom-domains] post-add scan-trigger failed for user ${userId}:`, err);
|
||||
});
|
||||
// Intentional: kein await — fire-and-forget
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Custom-Domain POST — Scan-Trigger nach mail_domain-Add", () => {
|
||||
const syntheticUserId = "00000000-0000-0000-0000-000000000001";
|
||||
const syntheticAdminSecret = "test-admin-secret-xyz";
|
||||
|
||||
beforeEach(() => {
|
||||
setupFetchMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
teardownFetchMock();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("type=mail_domain → $fetch auf /api/mail/scan-internal wird gerufen", async () => {
|
||||
await triggerScanIfMailDomain({
|
||||
type: "mail_domain",
|
||||
userId: syntheticUserId,
|
||||
adminSecret: syntheticAdminSecret,
|
||||
});
|
||||
|
||||
// Kurz warten damit fire-and-forget Promise im gleichen Tick landet
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/mail/scan-internal",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"x-admin-secret": syntheticAdminSecret,
|
||||
}),
|
||||
body: expect.objectContaining({ userId: syntheticUserId }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("type=mail_display_name → $fetch wird gerufen (future v1.1 path)", async () => {
|
||||
await triggerScanIfMailDomain({
|
||||
type: "mail_display_name",
|
||||
userId: syntheticUserId,
|
||||
adminSecret: syntheticAdminSecret,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("type=web → $fetch wird NICHT gerufen (web-Domains brauchen keinen Mail-Scan)", async () => {
|
||||
await triggerScanIfMailDomain({
|
||||
type: "web",
|
||||
userId: syntheticUserId,
|
||||
adminSecret: syntheticAdminSecret,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Scan-Fehler blockiert POST-Response nicht — catch() schluckt Error", async () => {
|
||||
fetchMock = vi.fn().mockRejectedValue(new Error("IMAP timeout"));
|
||||
(globalThis as Record<string, unknown>).$fetch = fetchMock;
|
||||
|
||||
// Darf keinen unhandled rejection werfen
|
||||
await expect(
|
||||
triggerScanIfMailDomain({
|
||||
type: "mail_domain",
|
||||
userId: syntheticUserId,
|
||||
adminSecret: syntheticAdminSecret,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await Promise.resolve();
|
||||
// fetchMock wurde gerufen, Fehler wurde geschluckt
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("adminSecret wird aus runtimeConfig übergeben — nicht hardcoded", async () => {
|
||||
const customSecret = "runtime-secret-abc123";
|
||||
|
||||
await triggerScanIfMailDomain({
|
||||
type: "mail_domain",
|
||||
userId: syntheticUserId,
|
||||
adminSecret: customSecret,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
const [, callOpts] = fetchMock.mock.calls[0] as [string, { headers: Record<string, string> }];
|
||||
expect(callOpts.headers["x-admin-secret"]).toBe(customSecret);
|
||||
});
|
||||
|
||||
it("userId wird korrekt im Body übergeben", async () => {
|
||||
const specificUserId = "aaaabbbb-cccc-dddd-eeee-ffff00000002";
|
||||
|
||||
await triggerScanIfMailDomain({
|
||||
type: "mail_domain",
|
||||
userId: specificUserId,
|
||||
adminSecret: syntheticAdminSecret,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
const [, callOpts] = fetchMock.mock.calls[0] as [string, { body: { userId: string } }];
|
||||
expect(callOpts.body.userId).toBe(specificUserId);
|
||||
});
|
||||
});
|
||||
201
backend/tests/mail/gmail-delete-strategy.test.ts
Normal file
201
backend/tests/mail/gmail-delete-strategy.test.ts
Normal file
@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user