chahinebrini b7909d77e4 feat(mail): custom title + settings collapsible + stats charts + provider filter
Mail-Page-Refactor — Privacy-friendly + DiGA-tauglich:

- Custom title pro mail-connection (z.B. "Privat-Gmail" statt voller E-Mail).
  Memory-Pattern: Anonymität via Nickname jetzt auch für Mail-Adressen
  sichtbar, Datenminimierung. Title nullable, Fallback auf Email-Domain.
- Schema-Migration mail_connection_title (additiv, NULL default für Bestand)
- Endpoint PATCH /api/mail-connections/:id mit title-Validation (max 60,
  trim, leerer String → NULL)
- "Passwort ändern"-Collapsible → vollwertige "Einstellungen"-Sektion:
  Title editieren · Email read-only · Passwort neu setzen · Verbindung
  trennen (mit Confirm-Dialog)
- EditMailTitleSheet als FormSheet-Pattern für Title-Edit
- mailConnectDraft-Store kriegt Title-Feld für Pre-Fill bei Re-Open

Zwei neue Stats-Charts auf der Mail-Page:

- MailBlockedByDayChart — 30-Tage-Bar-Chart, Plain-View-Bars (Pattern wie
  Sparkline-Profile), Empty-State bei 0 Cooldowns
  · Backend: GET /api/mail/stats/blocked-by-day?days=30
- MailDistributionChart — Half-Donut via react-native-svg, Top-5 Connections
  + "Sonstige", rendert nicht bei ≤1 Connection
  · Backend: GET /api/mail/stats/blocked-by-connection

Activity-Log mit Provider-Filter:

- Filter-Chips Mo Gmail/Outlook/iCloud/etc. über bestehendem Activity-Log
- GET /api/mail/results?provider=X (war vorher hardcoded all)
- Endpoint-Naming-Fix in useMailResults (war /api/mail/blocked, jetzt
  korrekt /api/mail/results — UI-Agent hatte falschen Path geraten)

Backend-Side-Effects:

- imap-providers util resolveProviderMeta(host) — gibt {provider, label,
  isCustomDomain} zurück, von 3 Endpoints konsumiert
- /api/mail/status erweitert: title, provider, providerLabel,
  isCustomDomain im Account-Shape
- /api/mail/results erweitert: connection-Sub-Objekt pro Entry +
  provider-Filter-Query

Open follow-ups (TODOs):

- deleteOldMailBlocked-Cron löscht <24h → Bar-Chart-Daten weg. Retention
  auf 90 Tage hochsetzen oder Cron stoppen.
- POST /api/mail/connect könnte die neue connection.id im Response
  mitliefern → Title-PATCH direkt ohne Extra-GET (UI-Agent-Empfehlung).
- /api/mail/status zeigt nur active Connections — paused mit Title wären
  unsichtbar. Entscheiden.

18 neue i18n-Keys (mail.title_*, mail.settings_*, mail.row_*,
mail.disconnect_confirm_*, mail.stats.*, mail.filter.all) in DE + EN.

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

351 lines
9.8 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,
title: true,
provider: true,
providerName: true,
imapHost: true,
authMethod: true,
consentAt: 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,
providerFilter?: string[],
) {
const db = usePrisma();
const offset = (page - 1) * limit;
// Bei Provider-Filter: JOINen via connectionId → imapHost für Vergleich
const whereBase = providerFilter && providerFilter.length > 0
? { userId, connection: { imapHost: { in: providerFilter } } }
: { userId };
const [results, total] = await Promise.all([
db.mailBlocked.findMany({
where: whereBase,
orderBy: { createdAt: "desc" },
skip: offset,
take: limit,
include: {
connection: {
select: { id: true, email: true, title: true, providerName: true, imapHost: true },
},
},
}),
db.mailBlocked.count({ where: whereBase }),
]);
return { results, total, page, pages: Math.ceil(total / limit) };
}
/** Title einer MailConnection setzen (nullable — reset auf NULL möglich). */
export async function updateMailConnectionTitle(
userId: string,
connectionId: string,
title: string | null,
) {
const db = usePrisma();
const updated = await db.mailConnection.updateMany({
where: { id: connectionId, userId },
data: { title },
});
if (updated.count === 0) return null;
return db.mailConnection.findFirst({
where: { id: connectionId, userId },
select: { id: true, email: true, title: true },
});
}
/**
* Geblockte Mails pro Tag (UTC) für die letzten N Tage — für Bar-Chart.
* Fehlende Tage werden mit count=0 aufgefüllt.
*/
export async function getBlockedMailsByDay(
userId: string,
days: number,
): Promise<{ date: string; count: number }[]> {
const db = usePrisma();
const since = new Date(Date.now() - days * 86_400_000);
// Prisma hat kein groupBy auf DATE-Funktionen → raw query
const rows = await db.$queryRaw<{ date: string; count: bigint }[]>`
SELECT TO_CHAR(DATE("created_at"), 'YYYY-MM-DD') AS date, COUNT(*) AS count
FROM "rebreak"."mail_blocked"
WHERE "user_id" = ${userId}::uuid
AND "created_at" >= ${since}
GROUP BY DATE("created_at")
ORDER BY DATE("created_at") ASC
`;
const map: Record<string, number> = {};
for (const row of rows) {
map[row.date] = Number(row.count);
}
// Alle N Tage auffüllen (neueste zuletzt)
return Array.from({ length: days }, (_, i) => {
const d = new Date(Date.now() - (days - 1 - i) * 86_400_000);
const key = d.toISOString().slice(0, 10);
return { date: key, count: map[key] ?? 0 };
});
}
/**
* Anzahl blockierter Mails pro MailConnection — für Half-Donut-Chart.
* Connections ohne blocked emails werden NICHT included.
*/
export async function getBlockedMailsByConnection(userId: string) {
const db = usePrisma();
const rows = await db.mailBlocked.groupBy({
by: ["connectionId"],
where: { userId },
_count: { id: true },
orderBy: { _count: { id: "desc" } },
});
if (rows.length === 0) return [];
const connectionIds = rows.map((r) => r.connectionId);
const connections = await db.mailConnection.findMany({
where: { id: { in: connectionIds } },
select: { id: true, email: true, title: true, providerName: true, imapHost: true },
});
const connMap = new Map(connections.map((c) => [c.id, c]));
return rows.map((r) => {
const conn = connMap.get(r.connectionId);
return {
connectionId: r.connectionId,
title: conn?.title ?? null,
email: conn?.email ?? "",
providerName: conn?.providerName ?? null,
imapHost: conn?.imapHost ?? "",
count: r._count.id,
};
});
}