chahinebrini 0ab635c74a feat: art-9 consent flow + outlook-oauth schema + cooldown patterns + mail draft persist
DSGVO Art. 9 — Compliance-Gap im Mail-Connect-Flow geschlossen (Hans-Müller-DSB
hat den Gap für Gmail/iCloud/GMX identifiziert, schon vor Outlook-OAuth-Pflicht):

- Schema: mail_connections.consent_at + consent_version + consent_ip_address;
  neue consent_logs-Tabelle für Audit (grant + revoke append-only)
- Endpoints:
  - POST /api/mail-connections/consent (Bulk-Array für Re-Consent, partial-fail
    wirft sofort = DSGVO-sicher gegen silent-skip fremder IDs)
  - POST /api/mail-connections/:id mit consent-gate (412 wenn consentVersion fehlt)
  - DELETE /api/mail-connections/:id mit Widerruf-Log (OAuth-Token-Revoke als
    TODO für mo Phase 2)
  - GET /api/mail-connections/pending-consent — listet Bestands-Connections
    mit consent_at=NULL für Re-Consent-Modal
- Account-Lösch-Bug fix: deleteAllMailConnections() war in user/delete nicht
  eingebunden — Verbindungen blieben als Waisen
- Frontend:
  - ConnectMailSheet: neuer Consent-Step VOR Provider-Grid (view-Machine
    consent → grid → form), exakter Hans-Müller-Wortlaut für Art. 9 Abs. 2
    lit. a Einwilligung
  - MailConsentReminderSheet: Re-Consent-Modal beim App-Open für Bestands-User
  - Stores mailConsent + mailConnectDraft (letzterer fixt Bug: Email/Provider
    ging verloren wenn User Browser für App-Pw-Generierung öffnete)
  - 12 neue i18n-Keys mail.consent.* in DE + EN
- Versionierter Consent-Text: art9-mail-v1-2026-05-13 (Bump bei Text-Änderung
  triggert Re-Consent für alle)

Outlook-OAuth Schema (Phase 0 — additiv, Endpoints kommen später):

- mail_connections: auth_method (default 'app_password' → keine Bestands-
  Connection bricht), oauth_access_token, oauth_refresh_token,
  oauth_token_expiry, oauth_scope
- Encryption via bestehendes server/utils/crypto.ts (AES-256-GCM, Key aus
  Infisical)
- Plan-Doc backend/docs/mail-outlook-oauth-plan.md (mo)
- DSB-Review backend/docs/mail-outlook-oauth-dsgvo-review.md (Hans-Müller):
  MS als Sub-AV via DPA Sep 2025, EU Data Boundary seit Feb 2025; 5 Pflicht-
  Aufgaben + Anwalts-Klärung zu DPA-Anspruch ohne MS-Lizenz

Profile — Cooldown-Pattern-Analysis als Collapsible:

- CooldownPatternAnalysis: 24h-Uhrzeit-Heatmap, Mo–So-Wochentag-Histogramm,
  Top-5-Reason-Wortcloud mit Stop-Words-Filter, Cancel-Rate-Anzeige
- DiGA-relevant: NLP läuft client-side, reason-Texte verlassen das Device
  nicht (gut für DSB-Akte)
- useProfileData: useCooldownHistoryFull (limit=100) für Pattern-Analyse
- Neutral formuliert, kein Stigma, alle Headings als Frage

Plan-Docs (kein Code):

- backend/docs/mail-custom-keywords-plan.md — Pro/Legend Custom-Keyword-Filter
  (3.25 PT MVP, user-scoped, Body-Match in Phase 2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:35:18 +02:00

247 lines
6.6 KiB
TypeScript

import { usePrisma } from "../utils/prisma";
export async function getMailConnections(userId: string) {
const db = usePrisma();
// isActive=true UND nicht pausiert (pausedAt=null) — pausierte werden vom Cron ausgelassen
return db.mailConnection.findMany({
where: { userId, isActive: true, pausedAt: null },
orderBy: { createdAt: "asc" },
});
}
/** Alle Verbindungen eines Users inkl. pausierten — für Status-Anzeige im Frontend. */
export async function getAllMailConnections(userId: string) {
const db = usePrisma();
return db.mailConnection.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: {
id: true,
email: true,
provider: true,
providerName: true,
isActive: true,
pausedAt: true,
pausedReason: true,
scanInterval: true,
lastScannedAt: true,
nextScanAt: true,
emailsBlocked: true,
emailsScanned: true,
lastConnectError: true,
createdAt: true,
},
});
}
export async function getAllActiveMailUserIds() {
const db = usePrisma();
const rows = await db.mailConnection.findMany({
where: { isActive: true, nextScanAt: { lte: new Date() } },
select: { userId: true },
distinct: ["userId"],
});
return rows.map((r) => r.userId);
}
export async function countMailConnections(userId: string) {
const db = usePrisma();
// Nur aktive + nicht-pausierte Verbindungen zählen gegen das Limit
return db.mailConnection.count({ where: { userId, isActive: true, pausedAt: null } });
}
export async function upsertMailConnection(data: {
userId: string;
email: string;
provider: string;
providerName: string;
imapHost: string;
imapPort: number;
passwordEncrypted: string;
rejectUnauthorized?: boolean;
useStarttls?: boolean;
}) {
const db = usePrisma();
return db.mailConnection.upsert({
where: { userId_email: { userId: data.userId, email: data.email } },
create: {
...data,
isActive: true,
rejectUnauthorized: data.rejectUnauthorized ?? true,
useStarttls: data.useStarttls ?? false,
},
update: {
providerName: data.providerName,
imapHost: data.imapHost,
imapPort: data.imapPort,
passwordEncrypted: data.passwordEncrypted,
rejectUnauthorized: data.rejectUnauthorized ?? true,
useStarttls: data.useStarttls ?? false,
isActive: true,
// Bei Re-Connect (z.B. neues App-Passwort): alte Error-Spuren clearen,
// damit UI sofort wieder "Live" zeigt — IDLE-daemon übernimmt.
lastConnectError: null,
lastConnectErrorAt: null,
},
});
}
export async function deleteMailConnection(
userId: string,
connectionId: string,
) {
const db = usePrisma();
return db.mailConnection.deleteMany({
where: { id: connectionId, userId },
});
}
export async function deleteAllMailConnections(userId: string) {
const db = usePrisma();
return db.mailConnection.deleteMany({ where: { userId } });
}
export async function updateMailConnectionInterval(
userId: string,
connectionId: string,
interval: number,
) {
const db = usePrisma();
return db.mailConnection.updateMany({
where: { id: connectionId, userId },
data: { scanInterval: interval },
});
}
export async function updateMailConnectionScanStats(
connectionId: string,
scanned: number,
blocked: number,
currentBlocked: number,
currentScanned: number,
scanIntervalHours: number,
) {
const db = usePrisma();
return db.mailConnection.update({
where: { id: connectionId },
data: {
lastScannedAt: new Date(),
emailsBlocked: currentBlocked + blocked,
emailsScanned: currentScanned + scanned,
nextScanAt: new Date(Date.now() + scanIntervalHours * 3_600_000),
},
});
}
export async function getMailBlockedStats(userId: string) {
const db = usePrisma();
const since7d = new Date(Date.now() - 7 * 86_400_000);
return db.mailBlocked.findMany({
where: { userId, createdAt: { gte: since7d } },
select: { createdAt: true },
});
}
export async function isMailAlreadyBlocked(
gmailMessageId: string,
userId: string,
) {
const db = usePrisma();
const existing = await db.mailBlocked.findFirst({
where: { gmailMessageId, userId },
select: { id: true },
});
return !!existing;
}
export async function getAlreadyBlockedUidSet(
uids: string[],
userId: string,
): Promise<Set<string>> {
if (uids.length === 0) return new Set();
const db = usePrisma();
const existing = await db.mailBlocked.findMany({
where: { gmailMessageId: { in: uids }, userId },
select: { gmailMessageId: true },
});
return new Set(existing.map((e) => e.gmailMessageId));
}
export async function insertMailBlocked(
entries: {
userId: string;
connectionId: string;
gmailMessageId: string;
senderEmail: string;
senderName: string | null;
subject: string;
receivedAt: Date;
action: string;
}[],
) {
if (entries.length === 0) return;
const db = usePrisma();
await db.mailBlocked.createMany({ data: entries, skipDuplicates: true });
}
/**
* Gibt alle MailConnections eines Users zurück bei denen consent_at noch NULL ist.
* Wird vom pending-consent.get.ts Endpoint für den Re-Consent-Modal-Trigger genutzt.
*/
export async function getPendingConsentConnections(
userId: string,
): Promise<{ id: string; email: string }[]> {
const db = usePrisma();
return db.mailConnection.findMany({
where: { userId, consentAt: null },
select: { id: true, email: true },
orderBy: { createdAt: "asc" },
});
}
export async function getImapProxyAccounts(userId: string) {
const db = usePrisma();
return db.imapProxyAccount.findMany({ where: { userId } });
}
export async function upsertImapProxyAccount(data: {
userId: string;
proxyUsername: string;
proxyPassword: string;
connectionId: string;
}) {
const db = usePrisma();
return db.imapProxyAccount.upsert({
where: { connectionId: data.connectionId },
create: data,
update: { proxyPassword: data.proxyPassword },
});
}
export async function deleteOldMailBlocked(userId: string) {
const db = usePrisma();
const cutoff = new Date(Date.now() - 24 * 3_600_000);
return db.mailBlocked.deleteMany({
where: { userId, createdAt: { lt: cutoff } },
});
}
export async function getMailBlockedPaginated(
userId: string,
page: number,
limit = 20,
) {
const db = usePrisma();
const offset = (page - 1) * limit;
const [results, total] = await Promise.all([
db.mailBlocked.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
skip: offset,
take: limit,
}),
db.mailBlocked.count({ where: { userId } }),
]);
return { results, total, page, pages: Math.ceil(total / limit) };
}