diff --git a/apps/rebreak-native/app/(app)/mail.tsx b/apps/rebreak-native/app/(app)/mail.tsx
index 15ec366..b1ce2fa 100644
--- a/apps/rebreak-native/app/(app)/mail.tsx
+++ b/apps/rebreak-native/app/(app)/mail.tsx
@@ -152,17 +152,6 @@ function MoreInfosSection({
>
{t('mail.more_infos_title')}
-
- {t('mail.more_infos_subtitle')}
-
)}
- {/* 2. ACCOUNT LIST */}
+ {/* 2. COLLAPSIBLE "MEHR INFOS" — Bar-Chart + nested Kürzlich blockiert */}
+ {hasAccounts && (
+
+ setMoreInfosExpanded((p) => !p)}
+ blockedByDay={blockedByDay}
+ providers={distinctProviders}
+ colors={colors}
+ />
+
+ )}
+
+ {/* 3. ACCOUNT LIST */}
{hasAccounts && (
@@ -511,24 +513,12 @@ export default function MailScreen() {
onEditSuccess={handleConnectSuccess}
disconnecting={disconnectingId === account.id && disconnecting}
blockedLast30d={connStat?.count}
+ onScanSuccess={refresh}
/>
);
})}
)}
-
- {/* 3. COLLAPSIBLE "MEHR INFOS" — Bar-Chart + nested Kürzlich blockiert */}
- {hasAccounts && (
-
- setMoreInfosExpanded((p) => !p)}
- blockedByDay={blockedByDay}
- providers={distinctProviders}
- colors={colors}
- />
-
- )}
void;
disconnecting?: boolean;
blockedLast30d?: number;
+ onScanSuccess?: () => void;
};
function OAuthDisconnectHintModal({
@@ -256,14 +261,17 @@ export function MailAccountCard({
onEditSuccess,
disconnecting,
blockedLast30d,
+ onScanSuccess,
}: Props) {
const { t } = useTranslation();
const [settingsVisible, setSettingsVisible] = useState(false);
const [confirmVisible, setConfirmVisible] = useState(false);
const [oauthDisconnectHintVisible, setOauthDisconnectHintVisible] = useState(false);
const [localTitle, setLocalTitle] = useState(account.title ?? null);
+ const [scanning, setScanning] = useState(false);
+ const [scanFeedback, setScanFeedback] = useState<{ blocked: number } | null>(null);
const { icon, color } = resolveProviderIcon(account.provider);
- const { data: connStats, granularity, loading: statsLoading } = useMailConnectionStats(
+ const { data: connStats, granularity, loading: statsLoading, refresh: refreshStats } = useMailConnectionStats(
account.id,
account.createdAt ?? null,
expanded,
@@ -281,6 +289,24 @@ export function MailAccountCard({
onToggle();
}
+ async function handleScan() {
+ setScanning(true);
+ setScanFeedback(null);
+ try {
+ const result = await apiFetch('/api/mail/scan', {
+ method: 'POST',
+ body: { connectionId: account.id },
+ });
+ setScanFeedback({ blocked: result.blocked });
+ refreshStats();
+ onScanSuccess?.();
+ } catch {
+ setScanFeedback({ blocked: -1 });
+ } finally {
+ setScanning(false);
+ }
+ }
+
function handleTitleSaved(newTitle: string | null) {
setLocalTitle(newTitle);
onEditSuccess();
@@ -404,32 +430,82 @@ export function MailAccountCard({
)}
- {/* Einstellungen tap-row */}
- setSettingsVisible(true)}
+ {/* Scan-Button + Einstellungen — horizontal nebeneinander */}
+
-
- {t('mail.settings_section_label')}
-
-
-
+ {scanning ? (
+
+ ) : (
+
+ )}
+
+ {scanning
+ ? t('mail.scan_running')
+ : scanFeedback?.blocked === -1
+ ? t('mail.scan_error')
+ : scanFeedback?.blocked !== undefined
+ ? t('mail.scan_done', { count: scanFeedback.blocked })
+ : t('mail.scan_now')}
+
+
+
+ setSettingsVisible(true)}
+ style={{
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 14,
+ paddingVertical: 13,
+ gap: 4,
+ }}
+ >
+
+ {t('mail.settings_section_label')}
+
+
+
+
)}
diff --git a/apps/rebreak-native/components/mail/MailDistributionChart.tsx b/apps/rebreak-native/components/mail/MailDistributionChart.tsx
index 093d19b..53ef497 100644
--- a/apps/rebreak-native/components/mail/MailDistributionChart.tsx
+++ b/apps/rebreak-native/components/mail/MailDistributionChart.tsx
@@ -18,7 +18,7 @@ const OTHER_COLOR = '#a3a3a3';
const MAX_LEGEND_ENTRIES = 3;
-const DONUT_WIDTH = 240;
+const DONUT_WIDTH = 200;
function formatCompact(n: number): string {
if (n < 1000) return n.toLocaleString();
@@ -94,49 +94,10 @@ export function MailDistributionChart({ data, hero, totalBlocked, isLegend }: Pr
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 16,
- paddingTop: 10,
+ paddingTop: 13,
paddingBottom: 12,
}}
>
-
-
-
-
- {isLegend ? t('mail.live') : t('mail.scheduled')}
-
-
-
-
90d → 'month' (client-aggregated into month buckets)
- */
export function useMailConnectionStats(
connectionId: string,
createdAt: string | null | undefined,
@@ -65,23 +57,36 @@ export function useMailConnectionStats(
if (!enabled || !connectionId) return;
setState((s) => ({ ...s, loading: true }));
try {
+ const ageDays = createdAt
+ ? Math.max(1, Math.ceil((Date.now() - new Date(createdAt).getTime()) / 86_400_000))
+ : 30;
+ const days = Math.min(30, ageDays);
+
const raw = await apiFetch(
- `/api/mail/stats/blocked-by-day?days=30&connectionId=${connectionId}`,
+ `/api/mail/stats/blocked-by-day?days=${days}&connectionId=${connectionId}`,
);
- let data = raw;
+ const nonEmpty = raw.filter((e) => e.count > 0);
+ let data: BlockedByDayEntry[];
if (granularity === 'week') {
data = aggregateToWeeks(raw);
} else if (granularity === 'month') {
data = aggregateToMonths(raw);
+ } else if (nonEmpty.length > 0 && days <= 7) {
+ // Short window: keep only days with data + days between first and last hit
+ const firstDate = nonEmpty[0].date;
+ const lastDate = nonEmpty[nonEmpty.length - 1].date;
+ data = raw.filter((e) => e.date >= firstDate && e.date <= lastDate);
+ } else {
+ data = raw;
}
setState({ data, granularity, loading: false });
} catch {
setState((s) => ({ ...s, loading: false }));
}
- }, [enabled, connectionId, granularity]);
+ }, [enabled, connectionId, granularity, createdAt]);
useEffect(() => {
if (!enabled) return;
diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json
index c3b0bc4..c5c56e0 100644
--- a/apps/rebreak-native/locales/de.json
+++ b/apps/rebreak-native/locales/de.json
@@ -485,6 +485,10 @@
},
"account_chart_collecting_title": "Daten werden gesammelt",
"account_chart_collecting_body": "Auswertung verfügbar nach 24h",
+ "scan_now": "Jetzt scannen",
+ "scan_running": "Scannt…",
+ "scan_done": "%{count} blockiert",
+ "scan_error": "Scan fehlgeschlagen",
"email_change_not_supported": "E-Mail-Änderung kommt bald"
},
"settings": {
diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json
index 89652e1..8d072d6 100644
--- a/apps/rebreak-native/locales/en.json
+++ b/apps/rebreak-native/locales/en.json
@@ -485,6 +485,10 @@
},
"account_chart_collecting_title": "Collecting data",
"account_chart_collecting_body": "Analysis available after 24h",
+ "scan_now": "Scan now",
+ "scan_running": "Scanning…",
+ "scan_done": "%{count} blocked",
+ "scan_error": "Scan failed",
"email_change_not_supported": "Email change coming soon"
},
"settings": {
diff --git a/backend/imap-idle/index.mjs b/backend/imap-idle/index.mjs
index 8c5bef5..eb1dbfb 100644
--- a/backend/imap-idle/index.mjs
+++ b/backend/imap-idle/index.mjs
@@ -485,6 +485,14 @@ async function runSession(conn) {
clearConnectionError(conn.id).catch(() => {}),
]);
+ // Initial-Sweep: einmalig nach erfolgreichem Connect scan-internal anstoßen.
+ // Damit werden bestehende Gambling-Mails in allen Folders sofort gelöscht,
+ // statt auf das erste exists-Event zu warten (das nur bei neuen Mails kommt).
+ // scan-internal baut eine eigene IMAP-Connection auf → kein Lock-Konflikt.
+ // Consent-Gate sitzt in scan-internal selbst → kein doppeltes Check hier.
+ // fire-and-forget: Fehler werden intern geloggt, Session läuft weiter.
+ triggerScan(conn).catch(() => {});
+
// Outlook/XOAUTH2 hat den Edge-Case dass getMailboxLock lautlos hängt
// wenn der Server in einen ungültigen Zustand kommt — die Session
// bleibt offen ohne Fortschritt bis der Renew-Timer (10min) ein
diff --git a/backend/server/api/mail/scan-internal.post.ts b/backend/server/api/mail/scan-internal.post.ts
index f1b61d7..afacc6e 100644
--- a/backend/server/api/mail/scan-internal.post.ts
+++ b/backend/server/api/mail/scan-internal.post.ts
@@ -11,6 +11,7 @@ import { getBlocklistedDomainsSet } from "../../db/domains";
import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features";
import { resolveProviderMeta } from "../../utils/imap-providers";
+import { resolveImapAuth } from "../../utils/mail-auth";
// Single-Source-of-Truth (Mo's Finding #4)
// @ts-expect-error — .mjs ohne types, GAMBLING_KEYWORDS ist string[]
import { GAMBLING_KEYWORDS } from "../../utils/gambling-keywords.mjs";
@@ -66,10 +67,20 @@ export default defineEventHandler(async (event) => {
let totalScanned = 0;
let totalBlocked = 0;
+ // scan-internal läuft im Cron-Context (kein User-Event). useRuntimeConfig(event)
+ // funktioniert hier weil event die Admin-Auth-Request-Referenz ist. Falls der
+ // Daemon triggerScan() direkt ohne echten HTTP-Request aufruft, fällt der
+ // process.env-Fallback ein — beide Quellen zeigen auf dieselbe Azure Client-ID.
+ const config = useRuntimeConfig(event);
+ const msClientId: string = config.msOauthClientId as string || process.env.MS_OAUTH_CLIENT_ID || "";
+
for (const connection of eligibleConnections) {
- let password: string;
+ // resolveImapAuth() wählt automatisch den richtigen Auth-Pfad:
+ // oauth2_microsoft → Access-Token (mit proaktivem Refresh falls abgelaufen)
+ // alle anderen → App-Password decrypt
+ let imapAuth: { user: string; accessToken: string } | { user: string; pass: string };
try {
- password = decrypt(connection.passwordEncrypted);
+ imapAuth = await resolveImapAuth(connection, msClientId);
} catch {
continue;
}
@@ -82,7 +93,7 @@ export default defineEventHandler(async (event) => {
port: connection.imapPort,
secure: useImplicitTls,
...(connection.useStarttls ? { requireTLS: true } : {}),
- auth: { user: connection.email, pass: password },
+ auth: imapAuth,
logger: false,
tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true },
});
diff --git a/backend/server/api/mail/scan.post.ts b/backend/server/api/mail/scan.post.ts
index d758123..b031f23 100644
--- a/backend/server/api/mail/scan.post.ts
+++ b/backend/server/api/mail/scan.post.ts
@@ -11,6 +11,7 @@ import { getBlocklistedDomainsSet } from "../../db/domains";
import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features";
import { resolveProviderMeta } from "../../utils/imap-providers";
+import { resolveImapAuth } from "../../utils/mail-auth";
// Single-Source-of-Truth (Mo's Finding #4)
// @ts-expect-error — .mjs ohne types, GAMBLING_KEYWORDS ist string[]
import { GAMBLING_KEYWORDS } from "../../utils/gambling-keywords.mjs";
@@ -56,10 +57,16 @@ export default defineEventHandler(async (event) => {
let totalScanned = 0;
let totalBlocked = 0;
+ const config = useRuntimeConfig(event);
+ const msClientId: string = config.msOauthClientId as string || process.env.MS_OAUTH_CLIENT_ID || "";
+
for (const connection of eligibleConnections) {
- let password: string;
+ // resolveImapAuth() wählt automatisch den richtigen Auth-Pfad:
+ // oauth2_microsoft → Access-Token (mit proaktivem Refresh falls abgelaufen)
+ // alle anderen → App-Password decrypt
+ let imapAuth: { user: string; accessToken: string } | { user: string; pass: string };
try {
- password = decrypt(connection.passwordEncrypted);
+ imapAuth = await resolveImapAuth(connection, msClientId);
} catch {
continue;
}
@@ -72,7 +79,7 @@ export default defineEventHandler(async (event) => {
port: connection.imapPort,
secure: useImplicitTls,
...(connection.useStarttls ? { requireTLS: true } : {}),
- auth: { user: connection.email, pass: password },
+ auth: imapAuth,
logger: false,
tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true },
});
diff --git a/backend/server/utils/mail-auth.ts b/backend/server/utils/mail-auth.ts
new file mode 100644
index 0000000..be6a3ef
--- /dev/null
+++ b/backend/server/utils/mail-auth.ts
@@ -0,0 +1,74 @@
+import { refreshAndSaveTokens } from "../db/mail";
+import { decrypt } from "./crypto";
+
+/**
+ * MailConnection-Shape: nur die Felder die für Auth-Resolution nötig sind.
+ * Beide Scan-Endpoints bekommen das volle Prisma-Objekt — dieses Interface
+ * dient als explizites Subset damit der Helper nicht vom vollen Typ abhängt.
+ */
+export interface MailConnectionAuthFields {
+ id: string;
+ email: string;
+ authMethod: string;
+ passwordEncrypted: string;
+ oauthAccessToken?: string | null;
+ oauthTokenExpiry?: Date | null;
+}
+
+export type ImapAuth =
+ | { user: string; accessToken: string }
+ | { user: string; pass: string };
+
+/**
+ * Gibt das korrekte `auth`-Objekt für ImapFlow zurück.
+ *
+ * - oauth2_microsoft: Access-Token decrypten, bei Ablauf via MS-Endpoint refreshen.
+ * Nutzt refreshAndSaveTokens() aus db/mail (Race-Condition-sicher, Prisma-basiert).
+ * - Alle anderen authMethods (app_password, default): passwordEncrypted decrypten.
+ *
+ * Wirft wenn:
+ * - App-Password leer oder decrypt fehlschlägt
+ * - OAuth-Token fehlt und kein Refresh möglich
+ * - refreshAndSaveTokens() wirft (revoked refresh_token, MS-Endpoint-Fehler)
+ *
+ * @param connection MailConnection-Felder (Subset)
+ * @param clientId MS Azure App Registration Client-ID (nur für OAuth-Pfad)
+ */
+export async function resolveImapAuth(
+ connection: MailConnectionAuthFields,
+ clientId: string,
+): Promise {
+ if (connection.authMethod === "oauth2_microsoft") {
+ // Token-Expiry-Check: 5-Minuten-Puffer damit der Scan nicht
+ // mitten in einem großen Mailbox-Durchlauf mit abgelaufenem Token stirbt.
+ const fiveMinFromNow = Date.now() + 5 * 60 * 1000;
+ const isExpiredOrMissing =
+ !connection.oauthTokenExpiry ||
+ connection.oauthTokenExpiry.getTime() < fiveMinFromNow;
+
+ let accessToken: string;
+ if (isExpiredOrMissing) {
+ // Wirft wenn Refresh-Token fehlt oder MS-Endpoint antwortet mit Fehler.
+ // Caller (scan.post / scan-internal.post) soll per try/catch continue-n.
+ accessToken = await refreshAndSaveTokens(connection.id, clientId);
+ } else {
+ if (!connection.oauthAccessToken) {
+ throw new Error(
+ `oauth2_microsoft connection ${connection.id} has no oauthAccessToken stored`,
+ );
+ }
+ accessToken = decrypt(connection.oauthAccessToken);
+ }
+
+ return { user: connection.email, accessToken };
+ }
+
+ // App-Password-Pfad (gmail, icloud, gmx, yahoo, custom)
+ if (!connection.passwordEncrypted) {
+ throw new Error(
+ `Connection ${connection.id} has no passwordEncrypted (authMethod=${connection.authMethod})`,
+ );
+ }
+ const pass = decrypt(connection.passwordEncrypted);
+ return { user: connection.email, pass };
+}