Completes the custom-mail-patterns feature (schema + migration shipped
in ba170af alongside the chat-tab-badge commit — apologies for the
mishap, agent staging collided with mine). This is the actual logic
that makes the new type column do work:
- mail-classifier.ts: new layer 2.6 between brand+random-token detect
and the score-based heuristic. Case-insensitive substring match of
the From-display-name against the user's customDisplayNames list.
Hard-block when matched, skip score entirely.
- db/domains.ts: getCustomMailDisplayNames(userId) reads the new
type=mail_display_name rows. countActiveCustomDomains stays a shared
total — matches the user's pick of a single 5/5/10 pool spanning
web + mail patterns rather than separate counts per type.
- scan-internal.post.ts and scan.post.ts both preload the display-name
list per user before the message loop and thread it into classifyMail.
- POST /api/custom-domains accepts { pattern, kind: 'web' | 'mail' }
with the server inferring the concrete type — 'mail' splits into
mail_domain when the input contains a TLD-like shape, otherwise
mail_display_name. Existing { domain } body shape stays accepted
for backwards compatibility with older clients.
- POST /api/custom-domains/:id/submit treats both mail types as
community-submittable. The user explicitly chose this; the admin
review pipeline is the backstop against display-name false positives.
- vitest cases cover: substring match, case insensitivity, no-match
fallthrough to score, mail_domain still flowing through the existing
domain-set path, and shared-pool slot counts (3 web + 2 mail_domain
+ 1 mail_display_name = 6 against the 10-slot legend cap).
Closes the bypass loophole where a Pro/Legend user could log out in a
craving moment, sign in with a fresh Free account on the same iPhone,
and watch the NEFilter blocklist shrink from 208k Casino domains to
the curated 30-domain stub. The user is the patient — the addiction
itself is the attacker.
When a Pro/Legend account signs in via x-device-id, the device is
bound to that user_id (UserDevice.boundToPlan = 'pro'|'legend' …).
A subsequent login attempt from a different account on the same
device returns 409 DEVICE_LOCKED. The original user gets a Resend
email naming the nickname only (no firstName / email leaked per
the anonymity rule) with a link to either confirm the foreign attempt
or release the device.
Release flow:
- POST /api/devices/:id/request-release schedules releaseAt = now + 24h
- POST /api/devices/:id/cancel-release reverts it
- a Nitro plugin cron sweeps both (24h-requested releases AND
30-day-idle auto-releases) hourly
Free -> Free swaps stay unrestricted so onboarding on a second-hand
iPhone keeps working. Free -> Pro upgrade binds going forward; a
Pro -> Free downgrade keeps the existing lock so the bypass vector
stays closed.
Lock check runs BEFORE Supabase auth in /api/auth/login to avoid
giving a timing oracle for account enumeration. The dummy-UUID filter
in findActiveDeviceLock is the trick: it queries "someone else's
lock" with a userId that can never match.
DSGVO: ON DELETE CASCADE on UserDevice means an Art-17 deletion of
the original user releases all their locks automatically (Hans-Mueller
hand-off noted in the migration SQL).
24 vitest cases cover bind / lock / request-release-24h /
cancel-release / 30-day-idle-release / email rate-limit (1 per 6h) /
DSGVO cascade / multi-device Legend.
Migration to deploy after push:
infisical run -- npx prisma migrate deploy --schema backend/prisma/schema.prisma
Frontend follow-up (separate task):
- Sign-In: handle 409 DEVICE_LOCKED with a dedicated error UI
- Settings/Devices page: "Release device" button + 24h countdown
- GET /api/devices to include boundToPlan + releaseRequestedAt
POST /api/devices/protected/handshake — server-to-server endpoint called by
the AdGuard log-watcher whenever a Mac with our DNS-profile makes a DoH query
with its dnsToken embedded in the path (/dns-query/<token>).
- Idempotent: pending → active on first hit, lastDnsQueryAt always updated
- Auth: shared secret via x-handshake-secret (Infisical: HANDSHAKE_SECRET,
must be set before enabling the watcher)
- Revoked tokens are silently ignored (no info leak to potential attackers)
- Realtime publication added so the native app auto-advances the AddMacSheet
flow when status flips (no "I've installed it" button needed anymore)
Bug: existing devices registered before Frontend started sending x-device-name/
x-device-model/x-device-os headers stayed with NULL fields forever — DeviceLimit
sheet shows only platform label ("iPhone" without iOS version, no name).
Fix:
- touchDevice() now accepts optional { name, model, osVersion } and updates
these fields when headers are provided (existing-row backfill).
- requireUser auth middleware reads URL-encoded x-device-* headers + passes
them to both touchDevice() (existing) and registerDevice() (auto-register).
After deploy: next authenticated request from updated client backfills the
device record automatically (throttled per TOUCH_THROTTLE_MS = 1×/min).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Schema: lyraVoiceId stays, new os_version column on user_devices (Migration 20260515)
- registerDevice() merge-heuristic: if existing record matches userId + same name +
same model + lastSeen < 30 days, update existing instead of inserting new.
Fixes iOS IDFV-reset creating phantom devices on Recovery-Restore.
- register.post.ts: accepts osVersion in body, maps isCurrent in error-path payload
- New util testUser.ts: isTestUser(email) — explicit allowlist for charioanouar@gmail.com
plus existing @rebreak.internal suffix
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MailClassificationSample hat keine userId-FK-Cascade im Schema (connectionId
ist nullable). Samples ohne connectionId blieben nach deleteAllMailConnections()
als Orphans stehen. Neuer Helper deleteUserMailClassificationSamples() löscht
explizit nach userId — wird in delete.delete.ts parallel zu anderen Lösch-Ops
ausgeführt.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
USP-Confirmed: Outlook-OAuth Casino-Bonus-Mail wurde end-to-end gefiltert
(User-verifiziert). Mit dieser Welle ist der Daemon plus alle Scan-Pfade
OAuth-aware.
Backend — Mail-Stack (mo):
- backend/server/utils/mail-auth.ts NEU: zentraler resolveImapAuth-Helper
kapselt OAuth-vs-AppPassword-Entscheidung. 5-min-Token-Expiry-Puffer,
race-condition-sicheres Refresh via refreshAndSaveTokens.
- scan.post.ts + scan-internal.post.ts nutzen jetzt resolveImapAuth statt
decrypt(passwordEncrypted). Vorher: Outlook-Connections wurden still
übersprungen weil passwordEncrypted='' → decrypt failed. Cron + manueller
Scan-Button funktionieren jetzt für OAuth-Connections.
- imap-idle: Initial-Sweep via triggerScan(conn) direkt nach Connect-Success.
Neue Outlook-Connections kriegen sofort einen Full-Folder-Scan statt bis
zu 30 Min Cron-Lag zu warten. scan-internal scannt ohnehin schon alle
Folders via imap.list() (Junk, Spam, Archive, Custom) — Multi-Folder-
Anforderung ist damit erfüllt.
Frontend — Mail-Page Polish v4 (rebreak-native-ui):
- MailDistributionChart: Donut zurück auf 200px (240 wuchs auch in der
Breite und quetschte die Legend), "Live"-Pill-Header komplett raus
(paddingTop von 16 auf 13 reduziert für tighteres Layout)
- mail.tsx Page-Hierarchie: "Mehr Infos"-Collapsible wandert von unter
der Postfach-Liste direkt unter den Hero-Donut. Sub-Beschreibung
"Blockiert — letzte 30 Tage" entfernt — Title reicht.
- Account-Card Expanded: adaptive Bar-Chart über Connection-Age
(too-new <24h zeigt Empty-State, 1-14d Day-Buckets via Backend
?connectionId=, 15-90d client-Week-Aggregation, >90d Month)
- Account-Card Expanded: Scan-Button "Jetzt scannen" mit Refresh-Icon
(Memory: kein Pen-Icon, refresh ok). Spinner während Scan, Feedback
mit Blocked-Count nach Success.
Eskalations-Hinweis (nicht in dieser Welle):
- POST /api/mail/scan akzeptiert noch keinen connectionId-Filter →
Scan-Button-Tap scannt aktuell alle Connections statt nur die
angeklickte. Kleiner Folge-Patch, nicht blocking.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UX-Welle nach User-Feedback aus dem ersten Live-Test der Mail-Page:
Page-Hierarchie neu (top → bottom):
1. HALF-DONUT als HERO-Karte — bisherige "BLOCKIERT XX über N Postfächer Live"-
Banner-Card weg, Inhalt ist jetzt Title-Zeile innerhalb der Donut-Karte
(rendert nur ab ≥2 Connections; Fallback-Stats-Row für 0-1 Connections)
2. Postfach-Liste (Account-Cards aus letztem Refactor — schlanker Header)
3. NEU: "Mehr Infos"-Collapsible — Bar-Chart "Blockiert letzte 30 Tage"
liegt jetzt versteckt drin (default collapsed)
4. Activity-Log "Kürzlich blockiert" (unverändert)
5. NEU: FAB unten rechts — 56pt brandOrange Kreis mit "+"-Icon,
öffnet ConnectMailSheet. Section-Header-Plus-Button entfällt.
Half-Donut Legend-Truncation:
- ≤3 Connections → alle anzeigen
- =4 Connections → alle anzeigen
- ≥5 Connections → Top-3 by blocked-count + "Sonstige"-Bucket
· Donut: 4 Segmente (Top-3 + OTHER_COLOR grau)
· Legend: 4 Zeilen (Top-3 fett, "weitere"-Zeile in regular grau)
Backend: GET /api/mail/stats/blocked-by-day?connectionId=<uuid> als
optionaler Filter (für per-Connection-Bar-Chart in expanded Account-Card,
in dieser Welle noch nicht im UI verdrahtet — Erweiterung kommt wenn
gewünscht).
FAB-Details (iOS-diskreter Shadow statt Material-Glow):
- position absolute, right 24, bottom = tabBarHeight + insets.bottom + 16
- 56pt, borderRadius 28, brandOrange BG, weißes Plus-Icon
- ScrollView paddingBottom angehoben damit kein Content unter dem FAB clipped
Edge-Cases:
- 0 Accounts → FAB sichtbar, Donut/Stats/Charts/Log versteckt + EmptyState
- 1 Account → Donut hidden (nur mit ≥2 Connections sinnvoll), Fallback-Stats-Row
- limitReached + FAB-Tap → bestehender Plan-Alert (FAB ist visuell nicht disabled)
Memory: Pull-to-refresh + bestehendes 30s-Status-Polling reichen für "wartet
auf erste verbindung"→"aktiv"-Übergang nach OAuth-Connect (Daemon-Heartbeat
braucht initial 2-9min, mo-Befund). UX-Polish-Option für später: in der
Initial-Phase einen freundlicheren "Verbinde gerade…"-Status anzeigen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Microsoft Personal-Accounts (outlook.com, hotmail.com, live.com) liefern mit
`openid` allein keinen `email`-Claim ins ID-Token. `preferred_username` ist
ein Display-Identifier, oft die Login-Email — aber nicht garantiert. Bei
manchen Account-Konfigurationen ist der Claim leer oder enthält einen MS-
internen Identifier.
Fix: `email` als zusätzlichen OIDC-Standard-Scope. `email` ist ein "identity
scope" (wie openid/profile/offline_access) und damit per OIDC-Spezifikation
mit jedem Resource-Scope (auch IMAP.AccessAsUser.All) kompatibel — verursacht
KEIN AADSTS70011 mehr.
Azure-App-Permissions müssen nicht angepasst werden — OIDC-Identity-Scopes
brauchen keine explizite Admin-Permission-Grant.
extractEmailFromIdToken bleibt unverändert — die Lookup-Order `email →
preferred_username → upn` greift jetzt erstmal auf den frischen `email`-Claim.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Microsoft V2.0 OAuth-Spezifikation: ein einzelner /token-Exchange darf nur
Scopes EINES Resource-Servers enthalten. Unsere bisherige Scope-Liste
mischte:
https://outlook.office.com/IMAP.AccessAsUser.All (outlook.office.com)
User.Read (graph.microsoft.com)
Im /authorize akzeptiert MS das (Multi-Consent-Screen), aber beim Token-
Exchange wirft MS AADSTS70011:
"The provided value for the input parameter 'scope' is not valid.
One or more scopes [...] are not compatible with each other."
Fix: User.Read raus. Display-Name in der App entfällt vorerst — Email
kommt sauber aus id_token.preferred_username (bei Consumer-MS-Accounts
typisch die Login-Email). Falls Display-Name künftig gebraucht wird →
separater Graph-Token-Exchange via On-Behalf-Of-Pattern.
Plus: ConnectMailSheet zeigt jetzt im roten Error-Banner den echten
Backend-Error (API-Status + Body) statt nur generischen Text — sonst
würden wir solche MS-Spezifika nie auf dem Device sehen.
Hans-Müller-Memo Section 3.1 (Datenkategorien) + Section 4.1
(Datenschutzerklärung) müssen entsprechend zurückgerollt werden — siehe
separater DSB-Update-Stream.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runtime-Error im /init und /callback weil Nitro auto-import nur server/utils/
scannt, nicht server/db/. Die Helper-Funktionen (createOauthPendingState,
consumeOauthPendingState, upsertOauthMicrosoftConnection, countMailConnections)
sowie ms-oauth-utility-Functions (exchangeCodeForTokens, extractEmailFromId
Token, generate*-Helpers, MS_*-Konstanten) wurden im Code implizit referenziert
aber nicht explizit importiert.
pnpm build:backend hat das nicht gefangen (Nitro Bundle-Step ist toleranter als
strict tsc). Erster Symptom auf staging:
ReferenceError: createOauthPendingState is not defined
at .../routes/api/mail/oauth/microsoft/init.post.mjs:36:3
Fix: explizite import-Statements in beiden Endpoints.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Microsoft hat App-Passwords für consumer-Outlook im September 2024 abgeschaltet.
Diese Welle bringt OAuth2/XOAUTH2-Support als zweiten AuthMethod-Pfad — Gmail/
iCloud/GMX/Yahoo bleiben unangetastet auf App-Password.
Backend (rebreak-backend):
- POST /api/mail/oauth/microsoft/init: PKCE-Flow-Start, generiert
code_verifier + Authorization-URL, persistiert pending state mit TTL
- POST /api/mail/oauth/microsoft/callback: Token-Exchange (PKCE, kein
client_secret weil Public Client), id_token-Decode für Email, MailConnection
upsert mit auth_method='oauth2_microsoft' + encrypted Tokens
- Token-Refresh-Util backend/server/utils/ms-oauth.ts + DB-Function
refreshAndSaveTokens(connectionId, clientId) mit optimistic-concurrency-
Race-Condition-Schutz (UPDATE WHERE oauth_token_expiry = <gelesener-wert>,
bei affected_rows=0 → frischen Wert lesen statt nochmal refreshen sonst
invalid_grant via Token-Rotation)
- Neue Tabelle oauth_pending_states (TTL via createdAt + Cleanup-Job-TODO)
- [id].delete.ts: echter OAuth-Disconnect — DB-Token-Löschung + Audit-Log
(MS hat keinen Drittanbieter-Revoke-Endpoint, daher User-Information-Pflicht
per Frontend-Modal, siehe DSB-Memo Section 5.1)
- Consent-Gate auch in scan.post.ts + scan-internal.post.ts (Cron-Trigger
war ohne Consent-Check = DSGVO-Lücke, jetzt geschlossen mit
skippedNoConsent-Field in Response)
IDLE-Daemon (backend/imap-idle/index.mjs, mo):
- XOAUTH2-Auth-Branch via getCredentialsForConnection() — wenn
auth_method='oauth2_microsoft', Token-Expiry-Check (<5min remaining →
proaktiver Refresh), sonst decrypted accessToken zu ImapFlow
- AUTHENTICATIONFAILED-Recovery: bis 3× reaktiv refresh + reconnect, danach
last_connect_error='auth_revoked' (kein Endlos-Loop)
- IDLE_RENEW_INTERVAL_MS = 10min — passt für MS 29min-Timeout (gleich wie
Gmail/iCloud)
- Consent-Pause: Connections mit consent_at=null laufen IDLE weiter (für
exists-Event-Wiederaufnahme), aber triggerScan() ist deaktiviert bis
consent erteilt
- start-idle-staging.sh: MS_OAUTH_CLIENT_ID explizit weiterleiten in den
inneren bash -c-Block (war Infisical-Var, ging aber durch strict-mode
verloren)
Frontend (rebreak-native-ui):
- Outlook-Tile re-aktiviert (war disabled mit "Kommt bald" seit Sept-2024-
Awareness), authMethod-Discriminator löst statt Email+Pw-Form den
OAuth-Flow aus
- ConnectMailSheet: neuer view-State 'oauth_warning' (Outing-Effekt-Hinweis
per Hans-Müller-Memo Section 6.1) + 'oauth_pending' (Browser-Step-Spinner)
- Deep-Link-Handler app/auth/mail-oauth-callback.tsx — auto-registriert
durch expo-router-File-Routing, kein Native-Rebuild (scheme 'rebreak'
schon im app.config.ts)
- mailConnectDraft-Store: pendingOAuthConnectionId für Title-Edit-Sheet
direkt nach Connect
- MailAccountCard: Password-Row hidden für OAuth-Connections, Post-Disconnect-
Modal mit MS-Account-Anleitung (DSB-konform — kompensiert fehlenden
Drittanbieter-Revoke-Endpoint mit User-Information)
Hans-Müller-DSB-Memo (mail-outlook-oauth-dsgvo-review.md):
- Section 4.1 Datenschutzerklärung-Textbaustein: "Wir widerrufen den Token
aktiv bei Microsoft"-Satz raus (war faktisch falsch — MS hat keinen
Drittanbieter-Revoke). Neuer Wortlaut: DB-Löschung + User-Anleitung
account.microsoft.com → Sicherheit → App-Berechtigungen
- Section 4.1: User.Read-Scope offen dokumentiert mit Datenminimierungs-
Klausel (Scope breiter, wir nutzen NUR Display-Name + Email-Claim)
- Section 5.1: ehrliche Doku dass MS keinen RFC-7009-Revoke hat
- Section 9 Anwalts-Themen: neue Frage 5 zur Art. 17-Erfüllung trotz
fehlendem MS-Revoke
Architektur-Eigenschaften:
- Generisches AuthMethod-Framework — Gmail/iCloud/Yahoo können später als
reine Config-Erweiterung OAuth bekommen, kein Refactor nötig
- Token-Encryption via bestehendes crypto.ts (AES-256-GCM, Key aus
Infisical)
- Consent-Gate konsistent: ConnectMailSheet-Consent-Step VOR Provider-
Auswahl (Frontend), backend-Endpoint 412 wenn consent fehlt, Daemon +
Scan-Endpoints pausieren bei consent_at=null
Open follow-ups:
- oauth_pending_states-Cleanup-Cron für abgelaufene Entries (TODO im
Backend-Code dokumentiert)
- Anwalts-Klärung Hans-Müller Section 9 (DPA-Anspruch ohne MS-Lizenz +
Art. 17 mit User-Information statt Revoke-Endpoint)
- TIA (Transfer Impact Assessment) für MS-Sub-AV — Hans-Müller-Draft-Aufgabe
- Outlook-Tile-Wieder-Aktivierung ist live, aber Phase-1-Production-Test
steht aus (User Test auf iPhone nach Pipeline-Deploy)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
deleteOldMailBlocked löscht weiter rohe Einträge nach 24h (Datenminimierung
für Mail-Inhalte, DSGVO Art. 5 Abs. 1 lit. c). Aber für Charts und
Pattern-Analysen werden vor dem Cleanup permanent aggregierte Daten in
einer separaten Tabelle geführt.
Architektur:
- Neue Tabelle mail_blocked_stats — UNIQUE (user_id, date, connection_id),
enthält ausschließlich counts + UTC-Datum + IMAP-Host. Kein Subject,
kein Sender, kein Mail-Inhalt. Datenminimierung jetzt auch im Audit-
Pfad sichtbar.
- Live-Aggregation: scan.post.ts + scan-internal.post.ts upserten direkt
nach jedem mail_blocked-INSERT in mail_blocked_stats (count += 1).
- 30-Tage-Backfill als SQL im Migration-File: bestehende mail_blocked-
Rows der letzten 30 Tage werden einmalig aggregiert, damit Charts
nicht 30 Tage lang leer aussehen.
- Stats-Endpoints (blocked-by-day, blocked-by-connection) lesen jetzt
aus mail_blocked_stats. Response-Shape unverändert → Frontend bleibt
unberührt.
ON DELETE CASCADE auf mail_connection_id (Hans-Müller-konservativ):
User-initiierter Disconnect = Art. 17-Signal → assoziierte Stats werden
mitgelöscht. SetNull wäre DSGVO-grenzwertig (orphan stats ohne klare
Lösch-Kontext-Zuordnung).
pnpm build:backend clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
POST /api/dev/set-plan { plan: 'free'|'pro'|'legend' } — requireUser, sets the
caller's own profile.plan via Prisma. Refuses on production URL (same guard as
the cooldown testMode: appUrl includes rebreak.org && !includes staging). Lets
the __DEV__ tier-toggle work without admin rights. Does NOT weaken updateProfile.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend:
- ProtectedDevice prisma model + migration add_protected_devices
- DB helpers: list/count/get/create/confirm/revoke
- mobileconfig.ts utility — XML-escape, unique UUIDs per request
- 5 endpoints under /api/devices/* (avoid /api/devices conflict with existing
Capacitor UserDevice route by using /api/devices/protected for list)
Phase 1: backend ready. DoH-server token-routing comes in phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wenn User App-Passwort aktualisiert via /api/mail/connect (upsert), waren bisher
lastConnectError + lastConnectErrorAt von der vorherigen Auth-Failure noch in
DB → /api/mail/status returned weiter Auth-Fehler-Status bis zum nächsten
IDLE-Heartbeat oder Cron-Scan diese überschrieb.
Jetzt: bei erfolgreichem Update räumt upsertMailConnection beide Felder, UI
zeigt sofort "Live" nach Passwort-Update.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AdGuard Home auf rebreak-mdm pullt diese Liste alle 1h für DoH-DNS-NXDOMAIN.
Single source of truth mit dem URL-Filter (NEFilter) — gleicher
getActiveBlocklistDomains() backend-call.
Public (no auth) — Casino-Domains sind keine PII, andere DNS-Blocklisten
(HaGeZi, OISD) sind genauso public.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds 3 fields to mail_connections so UI can distinguish between
"connection alive but no new mail" vs "connection dead" vs "auth-failed":
- last_connect_error — text of last IMAP error (auth-fail, connect-fail)
- last_connect_error_at — timestamp of error
- last_idle_heartbeat_at — updated every 2min by NOOP-success in daemon
Daemon (backend/imap-idle/index.mjs):
- updateConnectionError() / clearConnectionError() / updateIdleHeartbeat()
SQL helpers
- logError now uses err.responseText (shows "AUTHENTICATIONFAILED" instead
of generic "Command failed")
- clearError on connect() success
- updateError on connect() catch
- updateHeartbeat in NOOP-success-path (every 2min)
API (status.get.ts): returns the 3 new fields per account.
Migration: ALTER TABLE rebreak.mail_connections ADD COLUMN ... (idempotent).
UI-side (in flight, separate task): MailAccountCard renders auth-error
banner when lastConnectError != null + heartbeat-based "live" indicator.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds optional `gameName` column to community_posts so game-share posts
can render with the game-banner above the post-content (Snake/Tetris/
Memory/TTT visual indicator).
- prisma/schema.prisma: CommunityPost.gameName String? @map("game_name")
- migration: ALTER TABLE rebreak.community_posts ADD COLUMN game_name
- db/community.ts: createPost() accepts gameName param
- api/community/post.post.ts: extracts gameName from body
- api/community/posts.get.ts: returns gameName, prefers DB over content-parse
Frontend (already in flight on upgrade/sdk-54): PostCard.tsx renders
GameShareBanner when post.category === 'game_share' && post.gameName.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nitro auto-import doesn't reliably pick up named exports from
db/voiceQuota.ts at runtime — speak endpoint threw 500 with
"ReferenceError: getRemainingVoiceQuota is not defined".
Explicit imports for getRemainingVoiceQuota, consumeVoiceQuota,
estimateAudioSeconds + getPlanLimits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LLMs (especially Haiku) keep emitting markdown despite explicit "no markdown"
prompt rule. Mobile app has no markdown renderer — users see raw asterisks.
- New stripMarkdown() util handles **bold**, bullet-lists, headings,
code-fences, links, blockquotes
- /api/coach/message: applies stripMarkdown(text) post-LLM as safety-net
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per strategist-spec: Lyra-Coach-Mode klarer von SOS-Mode trennen.
- SOS-Mode (urge): crisis-intervention, focused, kurz
- Coach-Mode (lyra): casual, profile-building, philosophy, features
Backend (backend/server/api/coach/message.post.ts):
- COACH_CASUAL_SYSTEM_PROMPT komplett neu strukturiert (~620 tokens)
- Stärkerer Fokus: 3 explicit Aufträge (echtes Gespräch / Profile-Building /
Rebreak sprechen)
- Profile-building-mandate: "wenn du wenig weißt, sag's ehrlich; frag nach
Hobbies/Zielen/Menschen — eingewoben, NICHT als Checkliste"
- Cleanere Mission-Section: Bewegung, Anonymität, kein-pathologisieren,
community-getrieben, DiGA-Listung-Ziel
- Hard-rules klarer: NIE demographics extrahieren (User-Form ist tabu),
kein Sucht-Vokabular, kein medical-advice
- Existing PLAN_DETAILS-template-var bleibt
- Memory-system unverändert (lyra-memories table, extractAndStoreMemories
fire-and-forget — kein schema-change nötig)
Frontend Mode-Badges:
- app/lyra.tsx (Coach-Mode): Header-pill "Coach" in brandOrange-tint neben
Lyra-name
- app/urge.tsx (SOS-Mode): Header-pill "SOS" in error/red-tint neben
Lyra-name (alt: "Lyra · SOS [v2]" inline-text → cleaner badge-style)
i18n:
- coach.modeBadge.coach + coach.modeBadge.sos in DE + EN
Switch-Logic: route-based (lyra.tsx vs urge.tsx → separate persona via
backend endpoint). Kein User-Toggle — User soll nicht entscheiden müssen
"bin ich grade in Krise?".
Implementation Risk: LOW — schema-neutral, prompt-only + 2 small UI badges.
Erste Beta-Testing-Phase: ~1-2 Wochen iterieren bei Feedback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend-side admin-auth. Admin-App (apps/admin/) braucht das damit
useAdminAuth.verifyAdminRole() nach Login server-side prüfen kann ob User
in admin_users-tabelle steht.
New schema:
- model AdminUser → table rebreak.admin_users (user_id UUID PK FK Profile.id,
created_at, added_by). Migration 20260508_admin_users/migration.sql.
- ⚠️ SCHEMA-MIGRATION — NICHT autopushen. User entscheidet wann pipeline
triggert.
New backend code:
- backend/server/db/admin.ts: isAdminUser(userId) → boolean
- backend/server/utils/auth.ts: requireAdmin(event) wraps requireUser +
isAdminUser-check. Throws 403 wenn nicht admin.
- backend/server/api/admin/verify-admin.get.ts: GET endpoint. Returns
{ isAdmin: true, userId, email } bei success, 403 sonst, 401 if not auth'd.
Tests (5 cases in tests/admin/verify-admin.test.ts):
- isAdminUser DB-layer: row exists/null
- requireAdmin: admin → user, non-admin → 403, no token → 401
- Endpoint: admin → success, non-admin → 403
Pending User-Actions nach Push+Deploy:
1. Migration deploy auf staging:
ssh rebreak-server && cd /srv/rebreak && pnpm exec prisma migrate deploy
2. Seed-Admin eintragen:
INSERT INTO "rebreak"."admin_users" ("user_id", "created_at")
VALUES ('128df360-2008-4d6f-8aa1-bdb41ec1362f', NOW())
ON CONFLICT DO NOTHING;
3. Admin-App composables/useAdminAuth.ts kann dann verifyAdminRole()
gegen GET /api/admin/verify-admin aufrufen
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Endpoint /api/social/profile/[userId] returned (profile as any).postsCount ?? 0
und (profile as any).followingCount ?? 0 — Profile-schema hat aber weder
postsCount noch followingCount columns. Daher zeigte UI immer 0 obwohl User
Posts hatte.
Fix: 2 zusätzliche COUNT-queries in Promise.all:
- usePrisma().communityPost.count({ userId, isModerated: false }) → postsCount
- usePrisma().userFollow.count({ followerId: userId }) → followingCount
followersCount bleibt unverändert (wird via trigger denormalisiert in profile-row).
Tests: backend/tests/social/profile-counts.test.ts — 4 Cases
(posts>0, posts=0, following count, followers passthrough). 4/4 grün.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Coach-Page ist NICHT SOS — User ist nicht in Krise, will small-talk,
Reflexion, Feature-Wuensche, Philosophie. „Lockere Lyra".
Aenderungen:
- Neuer Prompt COACH_CASUAL_SYSTEM_PROMPT (exportiert): warm/neugierig/
manchmal humorvoll, bis 4-5 Saetze, darf eigene Empfehlungen + Mini-
Meinungen formulieren, lädt Feedback aktiv ein. Kein Crisis-Framing.
Sprachregeln (keine Pathologisierung, kein „Sucht") gelten unveraendert.
- Tier-LLM-Routing analog zu sos-stream:
Free/Pro = Groq llama-3.3-70b-versatile (Fallback llama-3.1-8b)
Legend = OpenRouter anthropic/claude-haiku-4.5 (Fallback claude-3.5-haiku)
- max_tokens 280→500 (Coach darf laenger antworten)
- Demographics-Injection (analog sos-stream): birthYear/gender/etc als
USER-DEMOGRAPHIE-Block in Prompt (read-only, kein Extract)
- sosMode-Branch deprecated — Frontend kann den Param noch senden, wird
ignoriert. Folge-TODO: UI-Agent entfernt sosMode aus Coach-Call.
NICHT geändert:
- TTS-Endpoints bleiben plan-agnostisch (Frontend routet by tier)
- sos-stream.get.ts/sos-stream.post.ts unberuehrt (importieren weiter
COACH_SYSTEM_PROMPT, kein Breaking Change)
Memory:
- project_llm_per_plan.md (tier-LLM-Default-Logic)
- feedback_anonymity_nickname.md
- feedback_demographics_user_initiated.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
let profile vor try-block hoisten, damit es im plan-routing-Fallback
(line 210, profile?.plan) sichtbar ist. Vorher: const profile innerhalb
try-block → block-scoped → ReferenceError außerhalb.
Demographics-Block-Injection added — gracefully no-op wenn neue Felder
(birthYear/gender/etc.) noch nicht im DB-Schema sind (optional chaining).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lyra halluzinierte Namen wie 'Max' im SOS-Stream weil systemPrompt
keinen Nicknamen-Anker hatte. message.post.ts hatte das Pattern schon,
aber beim Cutover ins backend/-Layout war's nur dort, nicht in
sos-stream.{get,post}.ts.
Fix: getProfile(user.id) + Inject 'NUTZER-NAME: ...' wie in message.post.ts.
Non-blocking (catch), Memory-Pattern preserved.
Im Cutover wurde 'const key = config.openrouterApiKey' rausgenommen,
aber line 311 referenzierte 'key' weiter für extractAndStoreMemories.
ReferenceError fiel in catch → 'stream failed' SSE event → App
fiel auf non-streaming fallback (4-5s wait) statt streaming.
Fix: explizit memoryExtractKey aus config.openrouterApiKey holen.