diff --git a/README.md b/README.md index 9d9c2114..fea5e510 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ When you click **“Bereinigung starten / Start cleanup”** a cleanup job is cr ### The three checkboxes explained **Dry run (keine Änderungen)** -Runs the full scan and logs what *would* happen, but **does not move/delete/unsubscribe** any mail. Useful for testing rules safely. +Runs the full scan and logs what *would* happen, but **does not move/delete/unsubscribe** any mail and **does not send unsubscribe emails**. Useful for testing rules safely. **Unsubscribe aktiv** Enables `List‑Unsubscribe` handling. diff --git a/backend/src/mail/cleanup.ts b/backend/src/mail/cleanup.ts index 551f3c7f..a70adec4 100644 --- a/backend/src/mail/cleanup.ts +++ b/backend/src/mail/cleanup.ts @@ -4,7 +4,7 @@ import { createImapClient, fetchHeaders, listMailboxes } from "./imap.js"; import { detectNewsletter } from "./newsletter.js"; import { matchRules } from "./rules.js"; import { unsubscribeFromHeader } from "./unsubscribe.js"; -import { applyGmailAction } from "./gmail.js"; +import { applyGmailAction, gmailClientForAccount } from "./gmail.js"; export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) => { const account = await prisma.mailboxAccount.findUnique({ where: { id: mailboxAccountId } }); @@ -31,17 +31,70 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) await logJobEvent(cleanupJobId, "info", `Connecting to ${account.email}`); - const client = createImapClient(account); - await client.connect(); + const isGmail = account.provider === "GMAIL"; + const hasGmailOAuth = Boolean(account.oauthRefreshToken || account.oauthAccessToken); + + let messages: { + uid: number; + subject?: string; + from?: string; + headers: Map; + gmailMessageId?: string; + }[] = []; + + let imapClient: ReturnType | null = null; + + if (isGmail && hasGmailOAuth) { + const { gmail } = await gmailClientForAccount(account); + const list = await gmail.users.messages.list({ + userId: "me", + labelIds: ["INBOX"], + maxResults: 300 + }); + const ids = list.data.messages?.map((msg) => msg.id).filter(Boolean) as string[] | undefined; + await logJobEvent(cleanupJobId, "info", `Found ${ids?.length ?? 0} Gmail messages`, 10); + + if (ids?.length) { + const headersWanted = ["Subject", "From", "List-Id", "List-Unsubscribe", "List-Unsubscribe-Post", "Message-Id"]; + for (const id of ids) { + const meta = await gmail.users.messages.get({ + userId: "me", + id, + format: "metadata", + metadataHeaders: headersWanted + }); + const headers = new Map(); + const payloadHeaders = meta.data.payload?.headers ?? []; + for (const header of payloadHeaders) { + if (!header.name || !header.value) continue; + headers.set(header.name.toLowerCase(), header.value); + } + messages.push({ + uid: 0, + subject: headers.get("subject"), + from: headers.get("from"), + headers, + gmailMessageId: id + }); + } + } + } else { + imapClient = createImapClient(account); + await imapClient.connect(); + try { + const mailboxes = await listMailboxes(imapClient); + await logJobEvent(cleanupJobId, "info", `Found ${mailboxes.length} mailboxes`, 10); + + const targetMailbox = mailboxes.find((box) => /inbox/i.test(box.path))?.path ?? "INBOX"; + await logJobEvent(cleanupJobId, "info", `Scanning ${targetMailbox}`, 20); + + messages = await fetchHeaders(imapClient, targetMailbox, 300, job.dryRun); + } finally { + await imapClient.logout().catch(() => undefined); + } + } try { - const mailboxes = await listMailboxes(client); - await logJobEvent(cleanupJobId, "info", `Found ${mailboxes.length} mailboxes`, 10); - - const targetMailbox = mailboxes.find((box) => /inbox/i.test(box.path))?.path ?? "INBOX"; - await logJobEvent(cleanupJobId, "info", `Scanning ${targetMailbox}`, 20); - - const messages = await fetchHeaders(client, targetMailbox, 300, job.dryRun); let newsletterCount = 0; let processed = 0; @@ -84,14 +137,18 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) continue; } - if ((action.type === "MOVE" || action.type === "ARCHIVE" || action.type === "LABEL") && action.target) { - await client.mailboxCreate(action.target).catch(() => undefined); - await client.messageMove(msg.uid, action.target); - await logJobEvent(cleanupJobId, "info", `Moved message ${msg.uid} to ${action.target}`); - } - if (action.type === "DELETE") { - await client.messageDelete(msg.uid); - await logJobEvent(cleanupJobId, "info", `Deleted message ${msg.uid}`); + if (!imapClient) { + await logJobEvent(cleanupJobId, "info", "Skipping IMAP action: no IMAP client"); + } else { + if ((action.type === "MOVE" || action.type === "ARCHIVE" || action.type === "LABEL") && action.target) { + await imapClient.mailboxCreate(action.target).catch(() => undefined); + await imapClient.messageMove(msg.uid, action.target); + await logJobEvent(cleanupJobId, "info", `Moved message ${msg.uid} to ${action.target}`); + } + if (action.type === "DELETE") { + await imapClient.messageDelete(msg.uid); + await logJobEvent(cleanupJobId, "info", `Deleted message ${msg.uid}`); + } } } } @@ -155,10 +212,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) } await logJobEvent(cleanupJobId, "info", `Detected ${newsletterCount} newsletter candidates`, 80); - if (!job.dryRun) { - await client.mailboxClose(); - } } finally { - await client.logout().catch(() => undefined); + // no-op } }; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 182d8f84..f7650ed1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -678,6 +678,9 @@ export default function App() { setDryRun(e.target.checked)} /> {t("cleanupDryRun")} + {dryRun && ( +

{t("cleanupDryRunHint")}

+ )}