Aktueller Stand

This commit is contained in:
2026-01-22 23:12:48 +01:00
parent fa5f3808bb
commit 082dc5e110
5 changed files with 82 additions and 23 deletions

View File

@@ -88,7 +88,7 @@ When you click **“Bereinigung starten / Start cleanup”** a cleanup job is cr
### The three checkboxes explained ### The three checkboxes explained
**Dry run (keine Änderungen)** **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** **Unsubscribe aktiv**
Enables `ListUnsubscribe` handling. Enables `ListUnsubscribe` handling.

View File

@@ -4,7 +4,7 @@ import { createImapClient, fetchHeaders, listMailboxes } from "./imap.js";
import { detectNewsletter } from "./newsletter.js"; import { detectNewsletter } from "./newsletter.js";
import { matchRules } from "./rules.js"; import { matchRules } from "./rules.js";
import { unsubscribeFromHeader } from "./unsubscribe.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) => { export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) => {
const account = await prisma.mailboxAccount.findUnique({ where: { id: mailboxAccountId } }); 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}`); await logJobEvent(cleanupJobId, "info", `Connecting to ${account.email}`);
const client = createImapClient(account); const isGmail = account.provider === "GMAIL";
await client.connect(); const hasGmailOAuth = Boolean(account.oauthRefreshToken || account.oauthAccessToken);
let messages: {
uid: number;
subject?: string;
from?: string;
headers: Map<string, string>;
gmailMessageId?: string;
}[] = [];
let imapClient: ReturnType<typeof createImapClient> | 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<string, string>();
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 { try {
const mailboxes = await listMailboxes(client); const mailboxes = await listMailboxes(imapClient);
await logJobEvent(cleanupJobId, "info", `Found ${mailboxes.length} mailboxes`, 10); await logJobEvent(cleanupJobId, "info", `Found ${mailboxes.length} mailboxes`, 10);
const targetMailbox = mailboxes.find((box) => /inbox/i.test(box.path))?.path ?? "INBOX"; const targetMailbox = mailboxes.find((box) => /inbox/i.test(box.path))?.path ?? "INBOX";
await logJobEvent(cleanupJobId, "info", `Scanning ${targetMailbox}`, 20); await logJobEvent(cleanupJobId, "info", `Scanning ${targetMailbox}`, 20);
const messages = await fetchHeaders(client, targetMailbox, 300, job.dryRun); messages = await fetchHeaders(imapClient, targetMailbox, 300, job.dryRun);
} finally {
await imapClient.logout().catch(() => undefined);
}
}
try {
let newsletterCount = 0; let newsletterCount = 0;
let processed = 0; let processed = 0;
@@ -84,17 +137,21 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
continue; continue;
} }
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) { if ((action.type === "MOVE" || action.type === "ARCHIVE" || action.type === "LABEL") && action.target) {
await client.mailboxCreate(action.target).catch(() => undefined); await imapClient.mailboxCreate(action.target).catch(() => undefined);
await client.messageMove(msg.uid, action.target); await imapClient.messageMove(msg.uid, action.target);
await logJobEvent(cleanupJobId, "info", `Moved message ${msg.uid} to ${action.target}`); await logJobEvent(cleanupJobId, "info", `Moved message ${msg.uid} to ${action.target}`);
} }
if (action.type === "DELETE") { if (action.type === "DELETE") {
await client.messageDelete(msg.uid); await imapClient.messageDelete(msg.uid);
await logJobEvent(cleanupJobId, "info", `Deleted message ${msg.uid}`); await logJobEvent(cleanupJobId, "info", `Deleted message ${msg.uid}`);
} }
} }
} }
}
if (job.unsubscribeEnabled) { if (job.unsubscribeEnabled) {
const listUnsubscribe = msg.headers.get("list-unsubscribe") ?? null; const listUnsubscribe = msg.headers.get("list-unsubscribe") ?? null;
@@ -155,10 +212,7 @@ export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string)
} }
await logJobEvent(cleanupJobId, "info", `Detected ${newsletterCount} newsletter candidates`, 80); await logJobEvent(cleanupJobId, "info", `Detected ${newsletterCount} newsletter candidates`, 80);
if (!job.dryRun) {
await client.mailboxClose();
}
} finally { } finally {
await client.logout().catch(() => undefined); // no-op
} }
}; };

View File

@@ -678,6 +678,9 @@ export default function App() {
<input type="checkbox" checked={dryRun} onChange={(e) => setDryRun(e.target.checked)} /> <input type="checkbox" checked={dryRun} onChange={(e) => setDryRun(e.target.checked)} />
{t("cleanupDryRun")} {t("cleanupDryRun")}
</label> </label>
{dryRun && (
<p className="hint-text">{t("cleanupDryRunHint")}</p>
)}
<label className="toggle"> <label className="toggle">
<input <input
type="checkbox" type="checkbox"

View File

@@ -80,6 +80,7 @@
"cleanupDisabled": "Bereinigung ist noch nicht verfügbar.", "cleanupDisabled": "Bereinigung ist noch nicht verfügbar.",
"cleanupSelectMailbox": "Bitte ein Postfach auswählen.", "cleanupSelectMailbox": "Bitte ein Postfach auswählen.",
"cleanupOauthRequired": "Bitte Gmail OAuth verbinden, bevor die Bereinigung startet.", "cleanupOauthRequired": "Bitte Gmail OAuth verbinden, bevor die Bereinigung startet.",
"cleanupDryRunHint": "Dry run simuliert Routing und Unsubscribe. Es werden keine Änderungen durchgeführt und keine E-Mails gesendet.",
"rulesTitle": "Regeln", "rulesTitle": "Regeln",
"rulesName": "Rule Name", "rulesName": "Rule Name",
"rulesEnabled": "Rule aktiv", "rulesEnabled": "Rule aktiv",

View File

@@ -80,6 +80,7 @@
"cleanupDisabled": "Cleanup is not available yet.", "cleanupDisabled": "Cleanup is not available yet.",
"cleanupSelectMailbox": "Select a mailbox to start cleanup.", "cleanupSelectMailbox": "Select a mailbox to start cleanup.",
"cleanupOauthRequired": "Connect Gmail OAuth before starting cleanup.", "cleanupOauthRequired": "Connect Gmail OAuth before starting cleanup.",
"cleanupDryRunHint": "Dry run simulates routing and unsubscribe actions. No changes or emails are sent.",
"rulesTitle": "Rules", "rulesTitle": "Rules",
"rulesName": "Rule name", "rulesName": "Rule name",
"rulesEnabled": "Rule enabled", "rulesEnabled": "Rule enabled",