Aktueller Stand

This commit is contained in:
2026-01-23 14:01:49 +01:00
parent 2766dd12c5
commit e16f6d50fb
46 changed files with 5482 additions and 311 deletions

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<title>Simple Mail Cleaner</title>
</head>
<body>

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 B

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

File diff suppressed because it is too large Load Diff

View File

@@ -85,7 +85,17 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
googleClientId: "",
googleClientSecret: "",
googleRedirectUri: "",
cleanupScanLimit: ""
cleanupScanLimit: "",
newsletterThreshold: "",
newsletterSubjectTokens: "",
newsletterFromTokens: "",
newsletterHeaderKeys: "",
newsletterWeightHeader: "",
newsletterWeightPrecedence: "",
newsletterWeightSubject: "",
newsletterWeightFrom: "",
unsubscribeHistoryTtlDays: "",
unsubscribeMethodPreference: "auto"
});
const [settingsStatus, setSettingsStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [showGoogleSecret, setShowGoogleSecret] = useState(false);
@@ -162,7 +172,17 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
googleClientId: next["google.client_id"]?.value ?? "",
googleClientSecret: next["google.client_secret"]?.value ?? "",
googleRedirectUri: next["google.redirect_uri"]?.value ?? "",
cleanupScanLimit: next["cleanup.scan_limit"]?.value ?? ""
cleanupScanLimit: next["cleanup.scan_limit"]?.value ?? "",
newsletterThreshold: next["newsletter.threshold"]?.value ?? "",
newsletterSubjectTokens: next["newsletter.subject_tokens"]?.value ?? "",
newsletterFromTokens: next["newsletter.from_tokens"]?.value ?? "",
newsletterHeaderKeys: next["newsletter.header_keys"]?.value ?? "",
newsletterWeightHeader: next["newsletter.weight_header"]?.value ?? "",
newsletterWeightPrecedence: next["newsletter.weight_precedence"]?.value ?? "",
newsletterWeightSubject: next["newsletter.weight_subject"]?.value ?? "",
newsletterWeightFrom: next["newsletter.weight_from"]?.value ?? "",
unsubscribeHistoryTtlDays: next["unsubscribe.history_ttl_days"]?.value ?? "",
unsubscribeMethodPreference: next["unsubscribe.method_preference"]?.value ?? "auto"
});
};
@@ -452,7 +472,17 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
"google.client_id": settingsDraft.googleClientId,
"google.client_secret": settingsDraft.googleClientSecret,
"google.redirect_uri": settingsDraft.googleRedirectUri,
"cleanup.scan_limit": settingsDraft.cleanupScanLimit
"cleanup.scan_limit": settingsDraft.cleanupScanLimit,
"newsletter.threshold": settingsDraft.newsletterThreshold,
"newsletter.subject_tokens": settingsDraft.newsletterSubjectTokens,
"newsletter.from_tokens": settingsDraft.newsletterFromTokens,
"newsletter.header_keys": settingsDraft.newsletterHeaderKeys,
"newsletter.weight_header": settingsDraft.newsletterWeightHeader,
"newsletter.weight_precedence": settingsDraft.newsletterWeightPrecedence,
"newsletter.weight_subject": settingsDraft.newsletterWeightSubject,
"newsletter.weight_from": settingsDraft.newsletterWeightFrom,
"unsubscribe.history_ttl_days": settingsDraft.unsubscribeHistoryTtlDays,
"unsubscribe.method_preference": settingsDraft.unsubscribeMethodPreference
}
})
},
@@ -532,12 +562,31 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
{t("countJobs", { count: tenant._count?.jobs ?? 0 })}
</p>
</div>
<div className="inline-actions">
<button className="ghost" onClick={() => exportTenant(tenant)}>{t("adminExportStart")}</button>
<button className="ghost" onClick={() => toggleTenant(tenant)}>
{tenant.isActive ? t("adminDisable") : t("adminEnable")}
<div className="inline-actions icon-actions">
<button
className="ghost icon-only"
onClick={() => exportTenant(tenant)}
title={t("adminExportStart")}
aria-label={t("adminExportStart")}
>
</button>
<button
className="ghost icon-only"
onClick={() => toggleTenant(tenant)}
title={tenant.isActive ? t("adminDisable") : t("adminEnable")}
aria-label={tenant.isActive ? t("adminDisable") : t("adminEnable")}
>
</button>
<button
className="ghost icon-only"
onClick={() => deleteTenant(tenant)}
title={t("adminDelete")}
aria-label={t("adminDelete")}
>
🗑
</button>
<button className="ghost" onClick={() => deleteTenant(tenant)}>{t("adminDelete")}</button>
</div>
</div>
))}
@@ -567,7 +616,7 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
</select>
</label>
<button
className="ghost"
className="ghost icon-only"
onClick={async () => {
try {
await apiFetch("/admin/exports/purge", { method: "POST" }, token);
@@ -577,8 +626,10 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
pushToast(getErrorMessage(err), "error");
}
}}
title={t("adminExportPurge")}
aria-label={t("adminExportPurge")}
>
{t("adminExportPurge")}
🗑
</button>
</div>
{exportsFiltered.map((item) => (
@@ -600,13 +651,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
<p>{t("exportProgress", { progress: item.progress })}</p>
)}
</div>
<div className="inline-actions">
<div className="inline-actions icon-actions">
<span>
{item.createdAt ? new Date(item.createdAt).toLocaleString() : "-"} ·{" "}
{t("exportExpires")}: {item.expiresAt ? new Date(item.expiresAt).toLocaleString() : "-"}
</span>
<button
className="ghost"
className="ghost icon-only"
disabled={item.status !== "DONE" || (item.expiresAt ? new Date(item.expiresAt) < new Date() : false)}
onClick={async () => {
const response = await downloadExport(token, item.id);
@@ -615,11 +666,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
downloadFile(blob, `export-${item.id}.zip`);
}
}}
title={t("exportDownload")}
aria-label={t("exportDownload")}
>
{t("exportDownload")}
</button>
<button
className="ghost"
className="ghost icon-only"
onClick={async () => {
try {
await apiFetch(`/admin/exports/${item.id}`, { method: "DELETE" }, token);
@@ -629,8 +682,10 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
pushToast(getErrorMessage(err), "error");
}
}}
title={t("delete")}
aria-label={t("delete")}
>
{t("delete")}
🗑
</button>
</div>
</div>
@@ -664,18 +719,38 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
</div>
<p>{user.role} · {user.tenant?.name ?? "-"}</p>
</div>
<div className="inline-actions">
<button className="ghost" onClick={() => setRole(user, user.role === "ADMIN" ? "USER" : "ADMIN")}>
{user.role === "ADMIN" ? t("adminMakeUser") : t("adminMakeAdmin")}
<div className="inline-actions icon-actions">
<button
className="ghost icon-only"
onClick={() => setRole(user, user.role === "ADMIN" ? "USER" : "ADMIN")}
title={user.role === "ADMIN" ? t("adminMakeUser") : t("adminMakeAdmin")}
aria-label={user.role === "ADMIN" ? t("adminMakeUser") : t("adminMakeAdmin")}
>
</button>
<button className="ghost" onClick={() => toggleUser(user)}>
{user.isActive ? t("adminDisable") : t("adminEnable")}
<button
className="ghost icon-only"
onClick={() => toggleUser(user)}
title={user.isActive ? t("adminDisable") : t("adminEnable")}
aria-label={user.isActive ? t("adminDisable") : t("adminEnable")}
>
</button>
<button className="ghost" onClick={() => impersonate(user)}>
{t("adminImpersonate")}
<button
className="ghost icon-only"
onClick={() => impersonate(user)}
title={t("adminImpersonate")}
aria-label={t("adminImpersonate")}
>
👤
</button>
<button className="ghost" onClick={() => setResetUserId(user.id)}>
{t("adminResetPassword")}
<button
className="ghost icon-only"
onClick={() => setResetUserId(user.id)}
title={t("adminResetPassword")}
aria-label={t("adminResetPassword")}
>
🔑
</button>
</div>
</div>
@@ -740,8 +815,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
</p>
)}
</div>
<button className="ghost" onClick={() => toggleAccount(account)}>
{account.isActive ? t("adminDisable") : t("adminEnable")}
<button
className="ghost icon-only"
onClick={() => toggleAccount(account)}
title={account.isActive ? t("adminDisable") : t("adminEnable")}
aria-label={account.isActive ? t("adminDisable") : t("adminEnable")}
>
</button>
</div>
))}
@@ -770,12 +850,26 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
/>
{t("selectAll")}
</label>
<div className="inline-actions">
<button className="ghost" type="button" onClick={cancelSelectedJobs} disabled={selectedJobIds.length === 0}>
{t("adminCancelSelected")}
<div className="inline-actions icon-actions">
<button
className="ghost icon-only"
type="button"
onClick={cancelSelectedJobs}
disabled={selectedJobIds.length === 0}
title={t("adminCancelSelected")}
aria-label={t("adminCancelSelected")}
>
</button>
<button className="ghost" type="button" onClick={deleteSelectedJobs} disabled={selectedJobIds.length === 0}>
{t("adminDeleteSelected")}
<button
className="ghost icon-only"
type="button"
onClick={deleteSelectedJobs}
disabled={selectedJobIds.length === 0}
title={t("adminDeleteSelected")}
aria-label={t("adminDeleteSelected")}
>
🗑
</button>
</div>
</div>
@@ -785,16 +879,37 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
<strong>{mapJobStatus(job.status)}</strong>
<p>{job.tenant?.name ?? "-"} · {job.mailboxAccount?.email ?? "-"}</p>
</div>
<div className="inline-actions">
<div className="inline-actions icon-actions">
<input
className="checkbox-input"
type="checkbox"
checked={selectedJobIds.includes(job.id)}
onChange={() => toggleJobSelection(job.id)}
/>
<button className="ghost" onClick={() => retryJob(job)}>{t("adminRetry")}</button>
<button className="ghost" onClick={() => cancelJob(job)}>{t("adminCancelJob")}</button>
<button className="ghost" onClick={() => deleteJob(job)}>{t("adminDelete")}</button>
<button
className="ghost icon-only"
onClick={() => retryJob(job)}
title={t("adminRetry")}
aria-label={t("adminRetry")}
>
</button>
<button
className="ghost icon-only"
onClick={() => cancelJob(job)}
title={t("adminCancelJob")}
aria-label={t("adminCancelJob")}
>
</button>
<button
className="ghost icon-only"
onClick={() => deleteJob(job)}
title={t("adminDelete")}
aria-label={t("adminDelete")}
>
🗑
</button>
<span>{new Date(job.createdAt).toLocaleString()}</span>
</div>
</div>
@@ -842,9 +957,109 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
/>
<small className="hint-text">{t("adminCleanupScanLimitHint")}</small>
</label>
<div className="inline-actions">
<button className="ghost" onClick={() => setShowGoogleSecret((prev) => !prev)}>
{showGoogleSecret ? t("adminHideSecret") : t("adminShowSecret")}
<div className="panel-divider" />
<h4>{t("adminNewsletterSettings")}</h4>
<p className="hint-text">{t("adminNewsletterSettingsHint")}</p>
<label className="field-row">
<span>{t("adminNewsletterThreshold")}</span>
<input
type="number"
min="1"
step="1"
value={settingsDraft.newsletterThreshold}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterThreshold: event.target.value }))}
/>
</label>
<label className="field-row">
<span>{t("adminNewsletterHeaderKeys")}</span>
<input
value={settingsDraft.newsletterHeaderKeys}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterHeaderKeys: event.target.value }))}
/>
<small className="hint-text">{t("adminNewsletterHeaderKeysHint")}</small>
</label>
<label className="field-row">
<span>{t("adminNewsletterWeightHeader")}</span>
<input
type="number"
step="1"
value={settingsDraft.newsletterWeightHeader}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterWeightHeader: event.target.value }))}
/>
</label>
<label className="field-row">
<span>{t("adminNewsletterWeightPrecedence")}</span>
<input
type="number"
step="1"
value={settingsDraft.newsletterWeightPrecedence}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterWeightPrecedence: event.target.value }))}
/>
</label>
<label className="field-row">
<span>{t("adminNewsletterSubjectTokens")}</span>
<input
value={settingsDraft.newsletterSubjectTokens}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterSubjectTokens: event.target.value }))}
/>
<small className="hint-text">{t("adminNewsletterSubjectTokensHint")}</small>
</label>
<label className="field-row">
<span>{t("adminNewsletterWeightSubject")}</span>
<input
type="number"
step="1"
value={settingsDraft.newsletterWeightSubject}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterWeightSubject: event.target.value }))}
/>
</label>
<label className="field-row">
<span>{t("adminNewsletterFromTokens")}</span>
<input
value={settingsDraft.newsletterFromTokens}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterFromTokens: event.target.value }))}
/>
<small className="hint-text">{t("adminNewsletterFromTokensHint")}</small>
</label>
<label className="field-row">
<span>{t("adminNewsletterWeightFrom")}</span>
<input
type="number"
step="1"
value={settingsDraft.newsletterWeightFrom}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterWeightFrom: event.target.value }))}
/>
</label>
<label className="field-row">
<span>{t("adminUnsubscribeHistoryTtl")}</span>
<input
type="number"
step="1"
value={settingsDraft.unsubscribeHistoryTtlDays}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, unsubscribeHistoryTtlDays: event.target.value }))}
/>
<small className="hint-text">{t("adminUnsubscribeHistoryTtlHint")}</small>
</label>
<label className="field-row">
<span>{t("adminUnsubscribeMethod")}</span>
<select
value={settingsDraft.unsubscribeMethodPreference}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, unsubscribeMethodPreference: event.target.value }))}
>
<option value="auto">{t("adminUnsubscribeMethodAuto")}</option>
<option value="http">{t("adminUnsubscribeMethodHttp")}</option>
<option value="mailto">{t("adminUnsubscribeMethodMailto")}</option>
</select>
<small className="hint-text">{t("adminUnsubscribeMethodHint")}</small>
</label>
<div className="inline-actions icon-actions">
<button
className="ghost icon-only"
onClick={() => setShowGoogleSecret((prev) => !prev)}
title={showGoogleSecret ? t("adminHideSecret") : t("adminShowSecret")}
aria-label={showGoogleSecret ? t("adminHideSecret") : t("adminShowSecret")}
>
👁
</button>
<button className="primary" onClick={saveSettings} disabled={settingsStatus === "saving"}>
{settingsStatus === "saving" ? t("adminSaving") : t("adminSaveSettings")}
@@ -859,6 +1074,20 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
redirect: settings["google.redirect_uri"]?.source ?? "unset"
})}
</div>
<div className="status-note">
{t("adminSettingsSourceNewsletter", {
threshold: settings["newsletter.threshold"]?.source ?? "unset",
headers: settings["newsletter.header_keys"]?.source ?? "unset",
subject: settings["newsletter.subject_tokens"]?.source ?? "unset",
from: settings["newsletter.from_tokens"]?.source ?? "unset",
weightHeader: settings["newsletter.weight_header"]?.source ?? "unset",
weightPrecedence: settings["newsletter.weight_precedence"]?.source ?? "unset",
weightSubject: settings["newsletter.weight_subject"]?.source ?? "unset",
weightFrom: settings["newsletter.weight_from"]?.source ?? "unset",
history: settings["unsubscribe.history_ttl_days"]?.source ?? "unset",
method: settings["unsubscribe.method_preference"]?.source ?? "unset"
})}
</div>
</div>
</div>
)}
@@ -867,8 +1096,14 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
<div className="modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h3>{t("confirmTitle")}</h3>
<button className="ghost" type="button" onClick={closeConfirmDialog}>
{t("close")}
<button
className="ghost icon-only"
type="button"
onClick={closeConfirmDialog}
title={t("close")}
aria-label={t("close")}
>
</button>
</div>
<div className="modal-body">

View File

@@ -67,6 +67,26 @@
"adminGoogleRedirectUri": "Redirect-URL",
"adminCleanupScanLimit": "Max. Mails pro Bereinigung",
"adminCleanupScanLimitHint": "0 = unbegrenzt. Praktisch für Tests.",
"adminNewsletterSettings": "Newsletter-Erkennung",
"adminNewsletterSettingsHint": "Signale und Schwellen für die Newsletter-Erkennung. Kommagetrennte Listen. Gewichte bestimmen, wie stark ein Signal den Score erhöht.",
"adminNewsletterThreshold": "Mindest-Signale",
"adminNewsletterHeaderKeys": "Header-Keys",
"adminNewsletterHeaderKeysHint": "Kommagetrennte List-Header (z.B. list-unsubscribe,list-id,...).",
"adminNewsletterWeightHeader": "Gewicht: Header-Treffer",
"adminNewsletterWeightPrecedence": "Gewicht: Bulk/List-Precedence",
"adminNewsletterSubjectTokens": "Betreff-Keywords",
"adminNewsletterSubjectTokensHint": "Kommagetrennte Tokens, die im Betreff gesucht werden.",
"adminNewsletterWeightSubject": "Gewicht: Betreff-Treffer",
"adminNewsletterFromTokens": "Absender-Keywords",
"adminNewsletterFromTokensHint": "Kommagetrennte Tokens, die im Absender gesucht werden.",
"adminNewsletterWeightFrom": "Gewicht: Absender-Treffer",
"adminUnsubscribeHistoryTtl": "Abmelde-Dedupe-Zeitfenster (Tage)",
"adminUnsubscribeHistoryTtlHint": "Verhindert erneutes Abmelden innerhalb dieses Zeitfensters. 0 deaktiviert.",
"adminUnsubscribeMethod": "Abmelde-Methode bevorzugen",
"adminUnsubscribeMethodHint": "Auto nutzt HTTP und fällt bei Fehler auf mailto zurück.",
"adminUnsubscribeMethodAuto": "Auto (HTTP → mailto Fallback)",
"adminUnsubscribeMethodHttp": "HTTP bevorzugen",
"adminUnsubscribeMethodMailto": "mailto bevorzugen",
"adminSaveSettings": "Einstellungen speichern",
"adminSaving": "Speichert...",
"adminSettingsSaved": "Gespeichert",
@@ -74,6 +94,7 @@
"adminShowSecret": "Secret anzeigen",
"adminHideSecret": "Secret verbergen",
"adminSettingsSource": "Quellen - Client-ID: {{id}}, Secret: {{secret}}, Redirect: {{redirect}}",
"adminSettingsSourceNewsletter": "Quellen - Schwelle: {{threshold}}, Header: {{headers}}, Betreff-Tokens: {{subject}}, Absender-Tokens: {{from}}, Gewichte (Header/Precedence/Betreff/Absender): {{weightHeader}}/{{weightPrecedence}}/{{weightSubject}}/{{weightFrom}}, Abmelde-History: {{history}}, Methode: {{method}}",
"selectAll": "Alle auswählen",
"adminCancelSelected": "Auswahl abbrechen",
"adminDeleteSelected": "Auswahl löschen",
@@ -94,30 +115,175 @@
"mailboxCancelEdit": "Abbrechen",
"mailboxEmpty": "Noch keine Mailbox. Füge eine hinzu, um zu starten.",
"cleanupStart": "Bereinigung starten",
"cleanupDryRun": "Dry run (keine Änderungen)",
"cleanupDryRun": "Nur simulieren (keine Änderungen)",
"cleanupUnsubscribe": "Unsubscribe aktiv",
"cleanupRouting": "Routing aktiv",
"cleanupDisabled": "Bereinigung ist noch nicht verfügbar.",
"cleanupSelectMailbox": "Bitte ein Postfach auswählen.",
"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.",
"cleanupDryRunHint": "Nur simulieren: Routing und Abmelden werden simuliert. Es werden keine Änderungen durchgeführt und keine E-Mails gesendet.",
"cleanupUnsubscribeHint": "Versucht Newsletter per List-Unsubscribe abzumelden. Im Nur-simulieren-Modus werden nur die Checks geloggt.",
"cleanupRoutingHint": "Wendet deine Regeln an (Verschieben/Löschen/Label). Im Nur-simulieren-Modus nur Simulation.",
"rulesTitle": "Regeln",
"rulesAdd": "Regel hinzufügen",
"rulesReorder": "Ziehen zum Sortieren",
"rulesAddTitle": "Regel erstellen",
"rulesEditTitle": "Regel bearbeiten",
"rulesName": "Rule Name",
"rulesEnabled": "Rule aktiv",
"rulesMatchMode": "Regel-Logik",
"rulesMatchAll": "Alle Bedingungen (UND)",
"rulesMatchAny": "Mindestens eine (ODER)",
"rulesMatchAnyLabel": "ODER",
"rulesStopOnMatch": "Nach Treffer stoppen (erste Regel gewinnt)",
"rulesStopOnMatchBadge": "ERSTE",
"rulesConditions": "Bedingungen",
"rulesActions": "Aktionen",
"rulesAddCondition": "+ Bedingung",
"rulesAddAction": "+ Aktion",
"rulesSave": "Regel speichern",
"rulesExampleHint": "Beispiel: Abmelde-Status = OK + List-Unsubscribe = * → Aktion: In \"Newsletter-Abgemeldet\" verschieben.",
"remove": "Entfernen",
"ruleConditionUnsubscribeStatus": "Abmelde-Status",
"ruleConditionScore": "Newsletter-Score",
"ruleConditionScorePlaceholder": "z.B. >=2",
"ruleConditionHeaderMissing": "Header fehlt",
"ruleConditionHeaderMissingPlaceholder": "Header-Name (z.B. List-Unsubscribe)",
"ruleUnsubStatusAny": "Beliebig",
"ruleUnsubStatusOk": "OK",
"ruleUnsubStatusDryRun": "Nur simuliert",
"ruleUnsubStatusFailed": "Fehlgeschlagen",
"ruleUnsubStatusSkipped": "Übersprungen",
"ruleUnsubStatusDuplicate": "Übersprungen (Duplikat)",
"ruleUnsubStatusDisabled": "Deaktiviert",
"ruleActionMarkRead": "Als gelesen markieren",
"ruleActionMarkUnread": "Als ungelesen markieren",
"rulesOverview": "Regeln Übersicht",
"rulesEmpty": "Noch keine Regeln.",
"jobsTitle": "Jobs",
"jobsEmpty": "Noch keine Jobs.",
"jobCandidatesTitle": "Ergebnisdetails",
"jobCandidatesHint": "Zeigt jeden Newsletter-Kandidaten inkl. Aktionen, Abmelde-Status und Metadaten.",
"jobCandidatesGroupBy": "Gruppieren nach",
"jobCandidatesGroupDomain": "Absender-Domain",
"jobCandidatesGroupFrom": "Absender",
"jobCandidatesGroupListId": "List-ID",
"jobCandidatesGroupNone": "Keine Gruppierung",
"jobCandidatesGroupsEmpty": "Noch keine Newsletter-Kandidaten.",
"jobCandidatesShowSignals": "Details anzeigen",
"jobCandidatesCount": "{{count}} Kandidaten",
"jobCandidatesLoadMore": "Mehr laden",
"jobCandidatesBack": "Zurück zu Gruppen",
"jobCandidatesRefresh": "Aktualisieren",
"jobCandidatesUnknown": "Unbekannt",
"jobCandidatesSubject": "Betreff",
"jobCandidatesFrom": "Von",
"jobCandidatesListId": "List-ID",
"jobCandidatesDate": "Datum",
"jobCandidatesActions": "Aktionen",
"jobCandidatesUnsubscribe": "Abmelden",
"jobCandidatesUnsubscribeNone": "Kein List-Unsubscribe Header",
"jobCandidatesUnsubscribeDisabled": "Deaktiviert",
"jobCandidatesUnsubscribeDryRun": "Nur simuliert",
"jobCandidatesUnsubscribeOk": "OK",
"jobCandidatesUnsubscribeFailed": "Fehlgeschlagen",
"jobCandidatesUnsubscribeDuplicate": "Übersprungen (Duplikat)",
"resultsTitle": "Ergebnisdetails",
"resultsHint": "Öffnet eine detaillierte Liste aller erkannten E-Mails mit Vorschau.",
"resultsOpen": "Ergebnisse öffnen",
"resultsGroups": "Gruppen",
"resultsGroupsDisabled": "Gruppierung deaktiviert. Bitte eine Gruppierung wählen.",
"resultsItems": "Nachrichten",
"resultsPreview": "Vorschau",
"resultsSelectGroup": "Bitte eine Gruppe auswählen, um Nachrichten zu laden.",
"resultsSelectItem": "Bitte eine Nachricht auswählen, um sie anzuzeigen.",
"resultsPreviewLoading": "Vorschau wird geladen...",
"resultsPreviewEmpty": "Keine Vorschau verfügbar.",
"resultsSearch": "Suche Betreff/Absender/List-ID",
"resultsFilterAll": "Alle Status",
"resultsReviewedAll": "Alle",
"resultsReviewed": "Geprüft",
"resultsUnreviewed": "Offen",
"resultsExportCsv": "CSV",
"resultsExportGroupCsv": "Gruppen-CSV",
"resultsSelectAll": "Alle",
"resultsMarkSelectedReviewed": "Ausgewählt geprüft",
"resultsMarkSelectedUnreviewed": "Ausgewählt offen",
"resultsBulkSelect": "Nur offen",
"resultsBulkMarkReviewed": "Alle geprüft",
"resultsDeleteSelected": "Ausgewählte löschen",
"resultsAttachments": "Anhänge",
"resultsDownloadAttachment": "Download",
"resultsHistory": "Abmelde-Historie",
"resultsUnsubscribeDetails": "Abmelde-Details",
"resultsUnsubscribeStatus": "Status",
"resultsUnsubscribeMethod": "Methode",
"resultsUnsubscribeTarget": "Ziel",
"resultsUnsubscribeMessage": "Ergebnis",
"resultsListId": "List-ID",
"resultsListUnsubscribe": "List-Unsubscribe",
"resultsListUnsubscribePost": "List-Unsubscribe-Post",
"resultsMailtoSubject": "Mailto-Betreff",
"resultsMailtoBody": "Mailto-Text",
"resultsRequestMethod": "Request-Methode",
"resultsRequestUrl": "Request-URL",
"resultsResponseStatus": "Response-Status",
"resultsResponseRedirect": "Redirect",
"resultsMailtoVia": "Versendet via",
"resultsMailtoTo": "Mailto",
"resultsMailtoReplyTo": "Reply-To",
"resultsMailtoListUnsubscribe": "List-Unsubscribe-Header",
"resultsUnsubscribeReason": "Grund",
"resultsUnsubscribeError": "Fehler",
"resultsLive": "Live",
"resultsStatic": "Statisch",
"resultsBackToJob": "Zurück zum Job",
"jobCandidatesActionApplied": "Ausgeführt",
"jobCandidatesActionDryRun": "Nur simuliert",
"jobCandidatesActionFailed": "Fehlgeschlagen",
"jobCandidatesActionSkipped": "Übersprungen",
"jobCandidatesSignals": "Signale",
"jobCandidatesScore": "Score",
"jobCandidatesScoreHint": "Der Score basiert u. a. auf Betreff, Absender, Header-Signalen und dem Newsletter-Erkennungsmodell.",
"jobCandidatesHeaderSignals": "Header",
"jobCandidatesSubjectSignals": "Betreff-Treffer",
"jobCandidatesFromSignals": "Absender-Treffer",
"jobCandidatesPrecedenceSignal": "Bulk/List-Precedence",
"jobEventCleanupStarted": "Bereinigung gestartet",
"jobEventCleanupFinished": "Bereinigung abgeschlossen",
"jobEventConnecting": "Verbinde mit {{email}}",
"jobEventListingGmail": "Gmail-Nachrichten werden aufgelistet",
"jobEventListedGmail": "{{count}} Gmail-Nachrichten bisher gefunden",
"jobEventPreparedGmail": "{{count}} Gmail-Nachrichten vorbereitet",
"jobEventResumeGmail": "Gmail-Bereinigung fortgesetzt bei {{current}}/{{total}}",
"jobEventProcessingGmail": "{{count}} Gmail-Nachrichten werden verarbeitet",
"jobEventNoGmail": "Keine Gmail-Nachrichten zu verarbeiten",
"jobEventFoundMailboxes": "{{count}} Postfächer gefunden",
"jobEventScanningMailbox": "Scanne {{mailbox}}",
"jobEventPreparedImap": "{{count}} IMAP-Nachrichten vorbereitet",
"jobEventResumeImap": "IMAP-Bereinigung fortgesetzt bei {{current}}/{{total}}",
"jobEventProcessingImap": "{{count}} IMAP-Nachrichten werden verarbeitet",
"jobEventNoImap": "Keine IMAP-Nachrichten zu verarbeiten",
"jobEventDetectedCandidates": "{{count}} Newsletter-Kandidaten erkannt",
"jobEventProcessed": "Verarbeitet {{current}}/{{total}}",
"jobEventProcessedCount": "Verarbeitet {{current}}",
"jobEventGmailActionAppliedList": "GmailAktion angewendet: {{actions}}",
"jobEventGmailActionApplied": "GmailAktion angewendet: {{action}}",
"jobEventGmailActionSkippedNoChanges": "GmailAktion übersprungen: keine LabelÄnderungen",
"jobEventGmailActionFailedSimple": "GmailAktion fehlgeschlagen: {{error}}",
"jobEventGmailActionFailed": "GmailAktion fehlgeschlagen ({{action}}): {{error}}",
"jobEventImapActionFailed": "IMAPAktion fehlgeschlagen ({{action}}): {{error}}",
"jobEventDryRunAction": "Nur simulieren: {{action}}",
"jobEventCanceledByAdmin": "Job vom Admin abgebrochen",
"jobEventCanceledBeforeStart": "Job vor Start abgebrochen",
"jobEventFailed": "Job fehlgeschlagen: {{error}}",
"loadingCandidates": "Kandidaten werden geladen...",
"jobsProgress": "Fortschritt",
"jobsEta": "Restzeit",
"jobsEtaDone": "Fertig",
"jobsEtaQueued": "Wartet",
"jobsEtaRecalculating": "Restzeit wird neu berechnet…",
"jobsEtaCalculating": "Berechne…",
"jobDetailsTitle": "Job-Details",
"jobNoEvents": "Noch keine Events.",
"jobEvents": "Job Events",
@@ -128,7 +294,13 @@
"phaseProcessingPending": "Warte auf Verarbeitung.",
"phaseUnsubscribePending": "Warte auf Abmeldung.",
"phaseUnsubscribeDisabled": "Abmeldung ist für diesen Job deaktiviert.",
"phaseUnsubscribeSummary": "Abgemeldet {{ok}} · Fehlgeschlagen {{failed}} · Dry-Run {{dryRun}} ({{total}} gesamt).",
"phaseUnsubscribeSummary": "Abgemeldet {{ok}} · Fehlgeschlagen {{failed}} · Nur simuliert {{dryRun}} ({{total}} gesamt).",
"phaseUnsubscribeSummaryWithProcessed": "Abgemeldet {{ok}} · Fehlgeschlagen {{failed}} · Nur simuliert {{dryRun}} ({{total}} Kandidaten, {{processed}}/{{overall}} verarbeitet).",
"phaseUnsubscribeSummaryNoDryRun": "Abgemeldet {{ok}} · Fehlgeschlagen {{failed}} ({{total}} gesamt).",
"phaseUnsubscribeSummaryNoDryRunWithProcessed": "Abgemeldet {{ok}} · Fehlgeschlagen {{failed}} ({{total}} Kandidaten, {{processed}}/{{overall}} verarbeitet).",
"phaseUnsubscribeDryRunPending": "Nur simulieren: warte auf Abmelde-Checks.",
"phaseUnsubscribeDryRunSummary": "Nur simulieren: {{dryRun}} Abmelde-Checks ({{total}} gesamt).",
"phaseUnsubscribeDryRunSummaryWithProcessed": "Nur simulieren: {{dryRun}} Abmelde-Checks ({{total}} Kandidaten, {{processed}}/{{overall}} verarbeitet).",
"phaseStatusActive": "Aktiv",
"phaseStatusDone": "Fertig",
"phaseStatusPending": "Ausstehend",
@@ -173,6 +345,7 @@
"statusRunning": "Laufend",
"statusQueued": "In Warteschlange",
"statusSucceeded": "Erfolgreich",
"statusFinished": "Bereinigung abgeschlossen",
"statusFailed": "Fehlgeschlagen",
"statusCanceled": "Abgebrochen",
"oauthStatusLabel": "OAuth Status"
@@ -204,10 +377,10 @@
"ruleConditionFrom": "From",
"ruleConditionListUnsub": "List-Unsubscribe",
"ruleConditionListId": "List-Id",
"ruleActionMove": "Move",
"ruleActionDelete": "Delete",
"ruleActionArchive": "Archive",
"ruleActionLabel": "Label",
"ruleActionMove": "Verschieben",
"ruleActionDelete": "Löschen",
"ruleActionArchive": "Archivieren",
"ruleActionLabel": "Label setzen",
"adminExportFormat": "Format",
"exportFormatJson": "JSON",
"exportFormatCsv": "CSV",
@@ -250,11 +423,14 @@
"toastMailboxDeleted": "Mailbox gelöscht.",
"toastRuleSaved": "Regel gespeichert.",
"toastRuleDeleted": "Regel gelöscht.",
"toastRuleOrderSaved": "Reihenfolge aktualisiert.",
"toastDeleteSelected": "{{deleted}} gelöscht · {{missing}} fehlend · {{failed}} fehlgeschlagen",
"toastCleanupStarted": "Bereinigung gestartet.",
"toastLoggedOut": "Ausgeloggt.",
"toastExportQueued": "Export in Warteschlange.",
"toastExportReady": "Export bereit.",
"toastExportFailed": "Export fehlgeschlagen.",
"toastDownloadFailed": "Download fehlgeschlagen.",
"toastExportPurged": "Abgelaufene Exporte entfernt.",
"toastExportDeleted": "Export gelöscht.",
"toastTenantUpdated": "Tenant aktualisiert.",
@@ -270,5 +446,6 @@
"toastSettingsSaved": "Einstellungen gespeichert.",
"toastSettingsFailed": "Einstellungen konnten nicht gespeichert werden.",
"confirmMailboxDelete": "Mailbox {{email}} löschen? Dabei werden alle zugehörigen Daten entfernt.",
"confirmDeleteSelected": "{{count}} ausgewählte Nachrichten löschen? Nachrichten können bereits gelöscht sein.",
"adminDeleteJobConfirm": "Diesen Job samt Events löschen?"
}

View File

@@ -67,6 +67,26 @@
"adminGoogleRedirectUri": "Redirect URL",
"adminCleanupScanLimit": "Max emails per cleanup",
"adminCleanupScanLimitHint": "0 = unlimited. Useful for testing.",
"adminNewsletterSettings": "Newsletter detection",
"adminNewsletterSettingsHint": "Signals and thresholds used to detect newsletters. Comma-separated lists. Weights define how much each signal adds to the score.",
"adminNewsletterThreshold": "Minimum signals",
"adminNewsletterHeaderKeys": "Header keys",
"adminNewsletterHeaderKeysHint": "Comma-separated list headers (e.g. list-unsubscribe,list-id,...).",
"adminNewsletterWeightHeader": "Weight: header matches",
"adminNewsletterWeightPrecedence": "Weight: bulk/list precedence",
"adminNewsletterSubjectTokens": "Subject keywords",
"adminNewsletterSubjectTokensHint": "Comma-separated tokens matched against the subject.",
"adminNewsletterWeightSubject": "Weight: subject match",
"adminNewsletterFromTokens": "From keywords",
"adminNewsletterFromTokensHint": "Comma-separated tokens matched against the sender.",
"adminNewsletterWeightFrom": "Weight: from match",
"adminUnsubscribeHistoryTtl": "Unsubscribe dedupe window (days)",
"adminUnsubscribeHistoryTtlHint": "Prevents running unsubscribe again within this time window. Set to 0 to disable.",
"adminUnsubscribeMethod": "Unsubscribe method preference",
"adminUnsubscribeMethodHint": "Auto uses HTTP when available and falls back to mailto if HTTP fails.",
"adminUnsubscribeMethodAuto": "Auto (HTTP → mailto fallback)",
"adminUnsubscribeMethodHttp": "Prefer HTTP",
"adminUnsubscribeMethodMailto": "Prefer mailto",
"adminSaveSettings": "Save settings",
"adminSaving": "Saving...",
"adminSettingsSaved": "Saved",
@@ -74,6 +94,7 @@
"adminShowSecret": "Show secret",
"adminHideSecret": "Hide secret",
"adminSettingsSource": "Sources - Client ID: {{id}}, Secret: {{secret}}, Redirect: {{redirect}}",
"adminSettingsSourceNewsletter": "Sources - Threshold: {{threshold}}, Headers: {{headers}}, Subject tokens: {{subject}}, From tokens: {{from}}, Weights (header/precedence/subject/from): {{weightHeader}}/{{weightPrecedence}}/{{weightSubject}}/{{weightFrom}}, Unsubscribe history: {{history}}, Method: {{method}}",
"selectAll": "Select all",
"adminCancelSelected": "Cancel selected",
"adminDeleteSelected": "Delete selected",
@@ -94,30 +115,175 @@
"mailboxCancelEdit": "Cancel",
"mailboxEmpty": "No mailboxes yet. Add one to start cleaning.",
"cleanupStart": "Start cleanup",
"cleanupDryRun": "Dry run (no changes)",
"cleanupDryRun": "Simulate only (no changes)",
"cleanupUnsubscribe": "Unsubscribe enabled",
"cleanupRouting": "Routing enabled",
"cleanupDisabled": "Cleanup is not available yet.",
"cleanupSelectMailbox": "Select a mailbox to start cleanup.",
"cleanupOauthRequired": "Connect Gmail OAuth before starting cleanup.",
"cleanupDryRunHint": "Dry run simulates routing and unsubscribe actions. No changes or emails are sent.",
"cleanupDryRunHint": "Simulation only: routing and unsubscribe are simulated. No changes or emails are sent.",
"cleanupUnsubscribeHint": "Tries to unsubscribe newsletters via List-Unsubscribe. In simulation mode, it only logs the checks.",
"cleanupRoutingHint": "Applies your rules (move/delete/label). In simulation mode, it only simulates the actions.",
"rulesTitle": "Rules",
"rulesAdd": "Add rule",
"rulesReorder": "Drag to reorder",
"rulesAddTitle": "Create rule",
"rulesEditTitle": "Edit rule",
"rulesName": "Rule name",
"rulesEnabled": "Rule enabled",
"rulesMatchMode": "Match mode",
"rulesMatchAll": "All conditions (AND)",
"rulesMatchAny": "Any condition (OR)",
"rulesMatchAnyLabel": "OR",
"rulesStopOnMatch": "Stop after match (first match wins)",
"rulesStopOnMatchBadge": "FIRST",
"rulesConditions": "Conditions",
"rulesActions": "Actions",
"rulesAddCondition": "+ Add condition",
"rulesAddAction": "+ Add action",
"rulesSave": "Save rule",
"rulesExampleHint": "Example: Unsubscribe status = OK + List-Unsubscribe = * → Action: Move to \"Newsletter-Abgemeldet\".",
"remove": "Remove",
"ruleConditionUnsubscribeStatus": "Unsubscribe status",
"ruleConditionScore": "Newsletter score",
"ruleConditionScorePlaceholder": "e.g. >=2",
"ruleConditionHeaderMissing": "Header missing",
"ruleConditionHeaderMissingPlaceholder": "Header name (e.g. List-Unsubscribe)",
"ruleUnsubStatusAny": "Any",
"ruleUnsubStatusOk": "OK",
"ruleUnsubStatusDryRun": "Simulated",
"ruleUnsubStatusFailed": "Failed",
"ruleUnsubStatusSkipped": "Skipped",
"ruleUnsubStatusDuplicate": "Skipped (duplicate)",
"ruleUnsubStatusDisabled": "Disabled",
"ruleActionMarkRead": "Mark as read",
"ruleActionMarkUnread": "Mark as unread",
"rulesOverview": "Rules overview",
"rulesEmpty": "No rules yet.",
"jobsTitle": "Jobs",
"jobsEmpty": "No jobs yet.",
"jobCandidatesTitle": "Result details",
"jobCandidatesHint": "Shows each newsletter candidate with actions, unsubscribe outcome, and metadata.",
"jobCandidatesGroupBy": "Group by",
"jobCandidatesGroupDomain": "Sender domain",
"jobCandidatesGroupFrom": "From address",
"jobCandidatesGroupListId": "List-ID",
"jobCandidatesGroupNone": "No grouping",
"jobCandidatesGroupsEmpty": "No newsletter candidates yet.",
"jobCandidatesShowSignals": "Show details",
"jobCandidatesCount": "{{count}} candidates",
"jobCandidatesLoadMore": "Load more",
"jobCandidatesBack": "Back to groups",
"jobCandidatesRefresh": "Refresh",
"jobCandidatesUnknown": "Unknown",
"jobCandidatesSubject": "Subject",
"jobCandidatesFrom": "From",
"jobCandidatesListId": "List-ID",
"jobCandidatesDate": "Date",
"jobCandidatesActions": "Actions",
"jobCandidatesUnsubscribe": "Unsubscribe",
"jobCandidatesUnsubscribeNone": "No List-Unsubscribe header",
"jobCandidatesUnsubscribeDisabled": "Disabled",
"jobCandidatesUnsubscribeDryRun": "Simulated",
"jobCandidatesUnsubscribeOk": "OK",
"jobCandidatesUnsubscribeFailed": "Failed",
"jobCandidatesUnsubscribeDuplicate": "Skipped (duplicate)",
"resultsTitle": "Result details",
"resultsHint": "Open a detailed list of all detected emails with a preview of each message.",
"resultsOpen": "Open results",
"resultsGroups": "Groups",
"resultsGroupsDisabled": "Grouping disabled. Switch to a grouping option to see categories.",
"resultsItems": "Messages",
"resultsPreview": "Preview",
"resultsSelectGroup": "Select a group to load its messages.",
"resultsSelectItem": "Select a message to preview it.",
"resultsPreviewLoading": "Loading preview...",
"resultsPreviewEmpty": "No preview text available.",
"resultsSearch": "Search subject/from/list",
"resultsFilterAll": "All statuses",
"resultsReviewedAll": "All",
"resultsReviewed": "Reviewed",
"resultsUnreviewed": "Unreviewed",
"resultsExportCsv": "CSV",
"resultsExportGroupCsv": "Group CSV",
"resultsSelectAll": "All",
"resultsMarkSelectedReviewed": "Selected reviewed",
"resultsMarkSelectedUnreviewed": "Selected unreviewed",
"resultsBulkSelect": "Unreviewed",
"resultsBulkMarkReviewed": "All reviewed",
"resultsDeleteSelected": "Delete selected",
"resultsAttachments": "Attachments",
"resultsDownloadAttachment": "Download",
"resultsHistory": "Unsubscribe history",
"resultsUnsubscribeDetails": "Unsubscribe details",
"resultsUnsubscribeStatus": "Status",
"resultsUnsubscribeMethod": "Method",
"resultsUnsubscribeTarget": "Target",
"resultsUnsubscribeMessage": "Result",
"resultsListId": "List-ID",
"resultsListUnsubscribe": "List-Unsubscribe",
"resultsListUnsubscribePost": "List-Unsubscribe-Post",
"resultsMailtoSubject": "Mailto subject",
"resultsMailtoBody": "Mailto body",
"resultsRequestMethod": "Request method",
"resultsRequestUrl": "Request URL",
"resultsResponseStatus": "Response status",
"resultsResponseRedirect": "Redirect",
"resultsMailtoVia": "Mail sent via",
"resultsMailtoTo": "Mailto",
"resultsMailtoReplyTo": "Reply-To",
"resultsMailtoListUnsubscribe": "List-Unsubscribe header",
"resultsUnsubscribeReason": "Reason",
"resultsUnsubscribeError": "Error",
"resultsLive": "Live",
"resultsStatic": "Static",
"resultsBackToJob": "Back to job",
"jobCandidatesActionApplied": "Applied",
"jobCandidatesActionDryRun": "Simulated",
"jobCandidatesActionFailed": "Failed",
"jobCandidatesActionSkipped": "Skipped",
"jobCandidatesSignals": "Signals",
"jobCandidatesScore": "Score",
"jobCandidatesScoreHint": "Score is derived from subject, sender, header signals, and the newsletter detection model.",
"jobCandidatesHeaderSignals": "Headers",
"jobCandidatesSubjectSignals": "Subject matches",
"jobCandidatesFromSignals": "From matches",
"jobCandidatesPrecedenceSignal": "Bulk/List precedence",
"jobEventCleanupStarted": "Cleanup started",
"jobEventCleanupFinished": "Cleanup finished",
"jobEventConnecting": "Connecting to {{email}}",
"jobEventListingGmail": "Listing Gmail messages",
"jobEventListedGmail": "Listed {{count}} Gmail messages so far",
"jobEventPreparedGmail": "Prepared {{count}} Gmail messages",
"jobEventResumeGmail": "Resuming Gmail cleanup at {{current}}/{{total}}",
"jobEventProcessingGmail": "Processing {{count}} Gmail messages",
"jobEventNoGmail": "No Gmail messages to process",
"jobEventFoundMailboxes": "Found {{count}} mailboxes",
"jobEventScanningMailbox": "Scanning {{mailbox}}",
"jobEventPreparedImap": "Prepared {{count}} IMAP messages",
"jobEventResumeImap": "Resuming IMAP cleanup at {{current}}/{{total}}",
"jobEventProcessingImap": "Processing {{count}} IMAP messages",
"jobEventNoImap": "No IMAP messages to process",
"jobEventDetectedCandidates": "Detected {{count}} newsletter candidates",
"jobEventProcessed": "Processed {{current}}/{{total}}",
"jobEventProcessedCount": "Processed {{current}}",
"jobEventGmailActionAppliedList": "Gmail action applied: {{actions}}",
"jobEventGmailActionApplied": "Gmail action applied: {{action}}",
"jobEventGmailActionSkippedNoChanges": "Gmail action skipped: no label changes",
"jobEventGmailActionFailedSimple": "Gmail action failed: {{error}}",
"jobEventGmailActionFailed": "Gmail action failed ({{action}}): {{error}}",
"jobEventImapActionFailed": "IMAP action failed ({{action}}): {{error}}",
"jobEventDryRunAction": "Simulate only: {{action}}",
"jobEventCanceledByAdmin": "Job canceled by admin",
"jobEventCanceledBeforeStart": "Job canceled before start",
"jobEventFailed": "Job failed: {{error}}",
"loadingCandidates": "Loading candidates...",
"jobsProgress": "Progress",
"jobsEta": "ETA",
"jobsEtaDone": "Done",
"jobsEtaQueued": "Queued",
"jobsEtaRecalculating": "Recalculating ETA…",
"jobsEtaCalculating": "Calculating…",
"jobDetailsTitle": "Job details",
"jobNoEvents": "No events yet.",
"jobEvents": "Job events",
@@ -128,7 +294,13 @@
"phaseProcessingPending": "Waiting for processing.",
"phaseUnsubscribePending": "Waiting for unsubscribe.",
"phaseUnsubscribeDisabled": "Unsubscribe disabled for this job.",
"phaseUnsubscribeSummary": "Unsubscribed {{ok}} · Failed {{failed}} · Dry run {{dryRun}} ({{total}} total).",
"phaseUnsubscribeSummary": "Unsubscribed {{ok}} · Failed {{failed}} · Simulated {{dryRun}} ({{total}} total).",
"phaseUnsubscribeSummaryWithProcessed": "Unsubscribed {{ok}} · Failed {{failed}} · Simulated {{dryRun}} ({{total}} candidates, {{processed}}/{{overall}} processed).",
"phaseUnsubscribeSummaryNoDryRun": "Unsubscribed {{ok}} · Failed {{failed}} ({{total}} total).",
"phaseUnsubscribeSummaryNoDryRunWithProcessed": "Unsubscribed {{ok}} · Failed {{failed}} ({{total}} candidates, {{processed}}/{{overall}} processed).",
"phaseUnsubscribeDryRunPending": "Simulation: waiting for unsubscribe checks.",
"phaseUnsubscribeDryRunSummary": "Simulation: {{dryRun}} unsubscribe checks ({{total}} total).",
"phaseUnsubscribeDryRunSummaryWithProcessed": "Simulation: {{dryRun}} unsubscribe checks ({{total}} candidates, {{processed}}/{{overall}} processed).",
"phaseStatusActive": "Active",
"phaseStatusDone": "Done",
"phaseStatusPending": "Pending",
@@ -173,6 +345,7 @@
"statusRunning": "Running",
"statusQueued": "Queued",
"statusSucceeded": "Succeeded",
"statusFinished": "Cleanup finished",
"statusFailed": "Failed",
"statusCanceled": "Canceled",
"oauthStatusLabel": "OAuth status"
@@ -250,11 +423,14 @@
"toastMailboxDeleted": "Mailbox deleted.",
"toastRuleSaved": "Rule saved.",
"toastRuleDeleted": "Rule deleted.",
"toastRuleOrderSaved": "Rule order updated.",
"toastDeleteSelected": "{{deleted}} deleted · {{missing}} missing · {{failed}} failed",
"toastCleanupStarted": "Cleanup job started.",
"toastLoggedOut": "Logged out.",
"toastExportQueued": "Export queued.",
"toastExportReady": "Export ready.",
"toastExportFailed": "Export failed.",
"toastDownloadFailed": "Download failed.",
"toastExportPurged": "Expired exports purged.",
"toastExportDeleted": "Export deleted.",
"toastTenantUpdated": "Tenant updated.",
@@ -270,5 +446,6 @@
"toastSettingsSaved": "Settings saved.",
"toastSettingsFailed": "Settings save failed.",
"confirmMailboxDelete": "Delete mailbox {{email}}? This will remove all related data.",
"confirmDeleteSelected": "Delete {{count}} selected messages? Messages might already be deleted.",
"adminDeleteJobConfirm": "Delete this job and all its events?"
}

View File

@@ -27,6 +27,10 @@ body {
min-height: 100vh;
}
body.modal-open {
overflow: hidden;
}
.toast-container {
position: fixed;
top: 20px;
@@ -370,6 +374,13 @@ button.ghost {
gap: 12px;
}
.panel-divider {
height: 1px;
background: rgba(148, 163, 184, 0.35);
border-radius: 999px;
margin: 8px 0 4px;
}
.section-block {
margin: 16px 0 18px;
padding: 12px 14px;
@@ -490,6 +501,48 @@ select {
gap: 8px;
}
.row-with-action {
grid-template-columns: 1fr 2fr auto;
align-items: center;
}
.icon-button {
border: 1px solid rgba(148, 163, 184, 0.4);
background: #fff;
color: var(--muted);
border-radius: 10px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
height: 36px;
}
.icon-button:hover {
border-color: rgba(37, 99, 235, 0.4);
color: var(--primary-strong);
}
.icon-only {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
font-size: 14px;
}
.icon-button.icon-only {
width: 32px;
height: 32px;
padding: 0;
}
.icon-actions {
gap: 6px;
}
.rule-block {
margin-top: 8px;
display: grid;
@@ -521,19 +574,197 @@ select {
}
.list-item {
display: flex;
justify-content: space-between;
align-items: center;
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: 12px;
padding: 10px 0;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.list-actions {
display: flex;
gap: 8px;
.rule-item {
grid-template-columns: auto 1fr auto;
align-items: center;
flex-wrap: wrap;
padding: 10px 8px;
border-radius: 14px;
transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.rule-item .rule-details {
align-self: center;
}
.rule-item.drag-over {
background: rgba(37, 99, 235, 0.08);
border-color: rgba(37, 99, 235, 0.35);
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.16);
}
.rule-item.dragging {
opacity: 0.5;
transform: scale(0.985);
}
.rule-order {
display: inline-flex;
align-items: center;
gap: 8px;
}
.rule-order .order-badge {
min-width: 26px;
height: 26px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.12);
color: var(--primary-strong);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
}
.rule-item .drag-handle {
cursor: grab;
}
.rule-item .drag-handle:active {
cursor: grabbing;
}
.rule-item .rule-details {
display: grid;
gap: 2px;
}
.rule-tail {
display: inline-flex;
align-items: center;
gap: 10px;
}
.rule-flags {
display: inline-flex;
align-items: center;
gap: 6px;
}
.rule-badge.rule-badge-strong {
background: rgba(37, 99, 235, 0.18);
color: var(--primary-strong);
}
.drag-ghost {
opacity: 0.85;
border-radius: 14px;
background: #fff;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.18);
}
.list-item > div {
min-width: 0;
}
.list-item p {
margin: 2px 0 0;
font-size: 12px;
color: var(--muted);
}
.list-item strong {
font-size: 13px;
font-weight: 600;
}
.list-item .badge {
letter-spacing: 0;
text-transform: none;
font-size: 12px;
color: var(--ink);
margin-bottom: 6px;
}
.list-item .badge strong {
letter-spacing: 0;
text-transform: none;
font-size: 12px;
}
.list-actions {
display: grid;
gap: 6px;
align-items: start;
justify-items: end;
min-width: 140px;
grid-column: 2;
}
.list-actions .ghost {
padding: 6px 10px;
font-size: 12px;
border-radius: 10px;
}
.list-actions .ghost {
min-width: 96px;
}
.list-actions.icon-actions {
min-width: auto;
justify-items: end;
grid-auto-flow: column;
grid-auto-columns: min-content;
}
.list-actions.icon-actions .ghost {
min-width: 0;
}
.job-item {
align-items: center;
}
.job-item .list-actions {
min-width: auto;
}
.job-item .job-action {
min-width: 32px;
border-radius: 10px;
}
.job-row {
width: 100%;
background: transparent;
border: none;
text-align: left;
cursor: pointer;
border-radius: 14px;
padding: 8px 10px;
}
.job-row:hover {
background: rgba(37, 99, 235, 0.06);
}
.job-meta {
display: grid;
gap: 2px;
}
.job-meta span {
font-size: 11px;
color: var(--muted);
}
.list-item .rule-badge {
grid-column: 2;
justify-self: end;
align-self: start;
}
.rule-item .list-actions {
min-width: 0;
}
.events {
@@ -690,6 +921,578 @@ select {
height: min(92vh, 1000px);
display: grid;
grid-template-rows: auto auto 1fr;
overflow: auto;
}
.results-modal {
width: min(1400px, 98vw);
height: min(92vh, 1100px);
grid-template-rows: auto auto 1fr;
}
.results-header {
align-items: center;
gap: 16px;
}
.results-header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.results-toolbar {
display: grid;
gap: 10px;
padding: 10px 12px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.02);
border-radius: 14px;
}
.results-toolbar-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.results-toolbar-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.results-toolbar input,
.results-toolbar select {
width: auto;
min-width: 140px;
height: 32px;
font-size: 12px;
padding: 6px 10px;
}
.results-toolbar .search-input {
min-width: 220px;
}
.results-toolbar .field-row {
grid-template-columns: auto auto;
width: auto;
}
.results-toolbar .field-row span {
font-size: 12px;
}
.results-toolbar .toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.results-toolbar .toggle input {
width: 14px;
height: 14px;
margin: 0;
padding: 0;
transform: none;
appearance: auto;
}
.results-toolbar .toggle input[type="checkbox"] {
width: 14px;
height: 14px;
flex: 0 0 14px;
min-width: 14px;
min-height: 14px;
max-width: 14px;
max-height: 14px;
line-height: 14px;
appearance: checkbox;
accent-color: #0f172a;
}
/* fallback for global checkbox scaling */
.results-toolbar input[type="checkbox"] {
transform: scale(1);
}
.results-toolbar-actions button {
font-size: 12px;
padding: 6px 12px;
border-radius: 999px;
}
.is-hidden {
display: none !important;
}
.results-toolbar-actions {
gap: 8px;
}
.results-group-list li button {
padding: 6px 10px;
border-radius: 999px;
}
.results-group-list li.group-new {
animation: groupIn 0.35s ease-out;
}
.results-group-list li.group-updated button {
animation: groupPulse 0.55s ease-out;
}
.results-group-list li.group-moved button {
animation: groupMove 0.35s ease-out;
}
@keyframes groupIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes groupPulse {
0% {
background: rgba(37, 99, 235, 0.22);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.35);
}
60% {
background: rgba(37, 99, 235, 0.12);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
}
100% {
background: #fff;
box-shadow: none;
}
}
@keyframes groupMove {
0% {
background: rgba(59, 130, 246, 0.14);
}
100% {
background: #fff;
}
}
.results-layout {
display: grid;
grid-template-columns: minmax(200px, 0.7fr) minmax(420px, 1.6fr) minmax(360px, 1.7fr);
gap: 16px;
height: 100%;
overflow: hidden;
}
@media (max-width: 1400px) {
.results-layout {
grid-template-columns: minmax(190px, 0.7fr) minmax(380px, 1.4fr) minmax(320px, 1.3fr);
}
}
@media (max-width: 1200px) {
.results-layout {
grid-template-columns: minmax(180px, 0.65fr) minmax(320px, 1.2fr) minmax(280px, 1.1fr);
}
}
@media (max-width: 1024px) {
.results-layout {
grid-template-columns: 1fr;
}
}
.results-panel {
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px;
background: rgba(255, 255, 255, 0.9);
display: grid;
gap: 8px;
min-height: 0;
overflow: hidden;
align-content: start;
}
.results-panel h4 {
margin: 0;
}
.results-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.results-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
overflow-y: auto;
align-content: start;
}
.results-list li {
display: grid;
gap: 6px;
}
.results-group-list li {
grid-template-columns: 1fr;
will-change: transform;
}
.results-message-row {
grid-template-columns: 18px 1fr auto;
align-items: center;
}
.results-list li button {
width: 100%;
border: 1px solid rgba(148, 163, 184, 0.3);
background: #fff;
border-radius: 12px;
padding: 6px 10px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
text-align: left;
font-size: 12px;
cursor: pointer;
}
.results-message-list li button {
padding: 6px 10px;
border-radius: 12px;
box-shadow: none;
}
.results-message-list li button > div {
display: grid;
gap: 2px;
}
.results-message-content {
display: grid;
gap: 2px;
}
.results-message-meta {
font-size: 11px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.results-message-history {
font-size: 10px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.results-message-status {
font-size: 11px;
color: var(--muted);
white-space: nowrap;
border: 1px solid rgba(148, 163, 184, 0.3);
padding: 2px 8px;
border-radius: 999px;
}
.results-row-actions {
display: flex;
justify-content: flex-end;
padding: 0;
}
.results-row-select {
display: flex;
align-items: center;
padding: 0 6px 0 2px;
}
.results-row-history {
grid-column: 2 / span 2;
margin: 0;
font-size: 10px;
}
.results-row-select input {
width: 14px;
height: 14px;
}
.results-message-list .toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
}
.toggle.compact {
gap: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.search-input {
border: 1px solid var(--border);
border-radius: 999px;
padding: 8px 12px;
font-size: 12px;
min-width: 180px;
}
.live-indicator {
padding: 6px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
border: 1px solid rgba(148, 163, 184, 0.3);
color: var(--muted);
background: rgba(148, 163, 184, 0.08);
}
.live-indicator.live {
border-color: rgba(34, 197, 94, 0.4);
color: #15803d;
background: rgba(34, 197, 94, 0.12);
}
.rule-badge {
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: var(--primary-strong);
border: 1px solid rgba(37, 99, 235, 0.3);
background: rgba(37, 99, 235, 0.08);
}
.results-list li button span {
font-size: 11px;
color: var(--muted);
}
.results-list li button strong {
display: block;
font-size: 12px;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.results-list li button div span {
display: block;
font-size: 11px;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.results-list li button.active {
border-color: rgba(37, 99, 235, 0.5);
background: rgba(37, 99, 235, 0.08);
color: var(--text);
}
.results-preview {
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
min-height: 0;
}
.preview-card {
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
min-height: 0;
overflow: hidden;
}
.preview-meta {
display: grid;
gap: 6px;
font-size: 12px;
color: var(--text);
}
.preview-subject strong {
font-size: 14px;
}
.preview-line {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
font-size: 12px;
}
.preview-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.preview-badge {
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(148, 163, 184, 0.08);
color: var(--muted);
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
}
.preview-badge-action {
cursor: pointer;
border-color: rgba(37, 99, 235, 0.35);
background: rgba(37, 99, 235, 0.08);
color: var(--primary-strong);
}
.preview-details {
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 12px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.8);
font-size: 12px;
}
.preview-details summary {
cursor: pointer;
font-weight: 600;
color: var(--ink);
}
.preview-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
margin-top: 8px;
}
.preview-detail-grid span {
display: block;
font-size: 11px;
color: var(--muted);
}
.preview-detail-grid strong {
font-size: 12px;
font-weight: 600;
color: var(--ink);
word-break: break-word;
}
.preview-frame {
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 12px;
width: 100%;
flex: 1;
min-height: 0;
background: #fff;
}
.preview-text {
white-space: pre-wrap;
background: #fff;
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.2);
padding: 12px;
flex: 1;
min-height: 0;
overflow: auto;
font-size: 12px;
}
.preview-attachments {
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 12px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.8);
font-size: 12px;
}
.preview-attachments ul {
margin: 6px 0 0;
padding-left: 0;
list-style: none;
display: grid;
gap: 6px;
}
.preview-attachments li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.preview-attachments .att-meta {
color: #1f2937;
}
.preview-attachments .ghost.small {
padding: 4px 10px;
font-size: 11px;
border-radius: 999px;
}
.results-cta {
margin-top: 16px;
padding: 14px;
border-radius: 16px;
border: 1px solid rgba(37, 99, 235, 0.2);
background: rgba(37, 99, 235, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
@media (max-width: 900px) {
.results-layout {
grid-template-columns: 1fr;
}
.results-modal {
height: auto;
}
.preview-frame,
.preview-text {
min-height: 320px;
}
}
.job-hero {
@@ -880,6 +1683,102 @@ select {
opacity: 0.6;
}
.job-candidates {
display: grid;
gap: 12px;
margin-top: 16px;
}
.candidate-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.candidate-toolbar .inline-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.candidate-groups {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.candidate-group {
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.3);
background: rgba(255, 255, 255, 0.6);
padding: 12px 14px;
text-align: left;
display: grid;
gap: 6px;
transition: all 0.2s ease;
}
.candidate-group:hover {
border-color: rgba(37, 99, 235, 0.35);
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.12);
}
.candidate-group span {
color: var(--muted);
font-size: 12px;
}
.candidate-list {
display: grid;
gap: 10px;
}
.candidate-item {
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.2);
padding: 12px 14px;
background: rgba(255, 255, 255, 0.75);
display: grid;
gap: 6px;
}
.candidate-item h5 {
margin: 0;
font-size: 14px;
}
.candidate-meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
color: var(--muted);
}
.candidate-signal-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.candidate-action-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.candidate-chip {
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
border: 1px solid rgba(37, 99, 235, 0.2);
background: rgba(37, 99, 235, 0.08);
color: var(--primary-strong);
}
.modal-header {
display: flex;
align-items: center;