207 Commits

Author SHA1 Message Date
chahinebrini
e8ea00568e feat(native): devices page — 2-line entries, single UIMenu CTA, dynamic counter, slimmer buttons
- MobileDeviceRow: collapse to 2 lines (name+badge / lastSeen · seit date)
- ProtectedDeviceRow: collapse to 2 lines (name+badge / seit date or degraded hint)
- Both rows now use alignItems:center for visual parity
- Replace dual Mac/Windows buttons with single UIMenu "+ neues Gerät hinzufügen"
- MenuView disabled (no-op TouchableOpacity) when at device limit
- Dynamic counter below subtitle: "X von 3 Geräten · noch Y frei" / "Maximum erreicht"
- paddingVertical 16→12 on all primary CTAs in devices.tsx, AddMacSheet, AddWindowsSheet
- New i18n keys: devices.add_device, devices.counter_some, devices.counter_limit (DE/EN/FR)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:10:09 +02:00
chahinebrini
8851f36f65 fix(native): protectedDevices store — unwrap response shape
Backend GET /api/devices/protected returns:
  { success, data: { devices, plan, max, isLegend } }

apiFetch already unwraps `data`, leaving us with the object
{ devices, plan, max, isLegend } — not an array.

Old code did `Array.isArray(res) ? res : []` on that object, which
silently fell through to an empty list. Effect: enrolled protected
devices (Mac/Windows) never appeared in the Geräte screen even though
the DB row existed and the API responded correctly.

Fix: read res.devices instead of assuming the response is the array.
2026-05-15 23:02:26 +02:00
chahinebrini
db7875fb34 feat(ops/mdm): AdGuard ClientID handshake — nginx + watcher
End-to-end DoH-to-backend wiring for Mac auto-activation:

  Mac → dns.rebreak.org/dns-query/<token> → nginx → AdGuard
  → querylog.json (CP field) → watcher.py → POST /handshake → backend

- ops/nginx/dns.rebreak.org.conf: vhost with `location ^~ /dns-query`
  prefix-match (not exact). proxy_pass without trailing slash preserves
  the full path so AdGuard parses the ClientID natively.
- watcher.py: NDJSON tail with inode-based rotation safety, per-token
  60s in-memory cooldown, urllib (no external deps), graceful 401/404/5xx
- rebreak-handshake-watcher.service: systemd unit, EnvironmentFile with
  chmod 600 (HANDSHAKE_SECRET never in git), NoNewPrivileges + PrivateTmp
- DOH_CLIENTID_HANDSHAKE.md: architecture + flow diagram + risk table
- RUNBOOK.md: status/logs/restart commands + deploy ordering

Not yet deployed. Verify-checklist before `nginx -s reload`:
  1. confirm AdGuard DoH port (config assumes 127.0.0.1:3000)
  2. confirm TLS cert exists for dns.rebreak.org
  3. snapshot current nginx config
  4. `nginx -t` dry-run
  5. functional curl + grep CP in querylog before starting watcher
2026-05-15 22:41:38 +02:00
chahinebrini
42a8223bfc feat(native): auto-detect Mac activation via Supabase Realtime
Replaces the manual "I've installed it" button in AddMacSheet with an
auto-advancing waiting-pill. As soon as the backend flips status from
pending → active (triggered by the DoH handshake from the AdGuard
watcher), the sheet jumps to the success step automatically.

- useProtectedDevicesRealtime hook subscribes to rebreak.protected_devices
  UPDATE events for the current user, with auto-reconnect on CHANNEL_ERROR
- AddMacSheet listens only while in step 2 (download/install)
- devices.tsx keeps a list-level subscription so the table refreshes even
  if the user dismissed the sheet before activation
- i18n: waiting_install / waiting_hint / activated_toast (DE + EN)
2026-05-15 22:41:25 +02:00
chahinebrini
0e4c3787c2 feat(backend): DoH handshake endpoint for protected-device auto-activation
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)
2026-05-15 22:41:17 +02:00
chahinebrini
db377da7ce fix(native): realtime disconnect bug — accessToken callback + AppState handler
Bug (diagnosed by backyard, see project_session_2026-05-15_push.md):
- Manual `supabase.realtime.setAuth()` calls in subscribe-hooks set
  `_manuallySetToken=true` internally, blocking the automatic token-refresh
  on heartbeat. After ~1h the cached access_token expires → Postgres-Changes
  silently stop arriving (channel still shows "joined" but no events).
- Plus: no AppState handler → no Foreground-Reconnect trigger after
  Background-kill of WebSocket.

Fix A — lib/supabase.ts: createClient now passes a `realtime.accessToken`
async callback that returns the current session token. Heartbeat picks
fresh tokens automatically, no manual setAuth needed.

Fix A — all 5 manual `supabase.realtime.setAuth()` calls removed from
useChatRealtime, useCommunityRealtime, useDomainSubmissionRealtime,
stores/notifications. Token is handled by the callback now.

Fix B — _layout.tsx: AppState listener calls
supabase.auth.startAutoRefresh()/stopAutoRefresh() — official Supabase RN
pattern. On Foreground-Return, onAuthStateChange fires TOKEN_REFRESHED →
realtime.setAuth gets called internally.

Required for upcoming Auto-Detect protected-device handshake (Realtime
channel listens on protected_devices status transitions pending→active).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:48:54 +02:00
chahinebrini
cd5efab6e1 feat(native): use expo-device for reliable device-info headers
Constants.platform.ios.model returns only generic "iPhone" instead of
"iPhone 15 Pro" + osVersion was unreliable. Switched lib/deviceId.ts to
expo-device which exposes Device.deviceName ("iPhone von Chahine"),
Device.modelName ("iPhone 15 Pro") and Device.osVersion ("26.4.2") on real
devices. Constants stays as fallback for Simulator/Web.

Backend touchDevice + auto-register already backfill these fields from the
x-device-* headers (commit 60f608d) — but only with proper Frontend values
which Constants couldn't provide.

Requires new native build (versionCode 8) since expo-device is a native
module — current TestFlight build (7) still ships with old Constants logic.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:45:33 +02:00
chahinebrini
60f608d891 fix(backend): backfill device name/model/osVersion on touch + auto-register
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>
2026-05-15 21:27:58 +02:00
chahinebrini
e1ba0ebeaf chore(native): bump versionCode/buildNumber to 7 for device-info + i18n release
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:17:43 +02:00
chahinebrini
d55cbc11b2 fix(native): mail-sheet modal-conflict + google-oauth picker + feed-bg contrast
- mail/MailAccountSettingsSheet: handleSaveTitle + handleSavePassword now
  dismiss sheet FIRST, then trigger parent SuccessAlert via setTimeout(350ms).
  Fixes iOS "already presenting" crash + page-freeze when editing mailbox name.
  Also fixes double-click-needed UX bug.
- stores/auth: signOut adds WebBrowser.coolDownAsync() to clear OAuth cookies.
  signInWithOAuth for Google adds prompt=select_account — forces account-picker
  on every sign-in attempt instead of auto-reusing previous account.
- app/(app)/index: feed page uses colors.groupedBg instead of colors.bg —
  matches iOS Mail/Messages list-style, post-cards stand out clearer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:16:34 +02:00
chahinebrini
804d4a5861 feat(native): device-info api headers + DeviceLimitSheet UI + profile i18n sweep
- lib/api.ts: sends x-device-name + x-device-model + x-device-os headers
  (cached per session, URL-encoded). Backend persists into user_devices for
  visual differentiation in DeviceLimitSheet.
- DeviceLimitReachedSheet: renders name (primary) + model · OS-version
  (secondary), "Dieses Gerät"-Pill on isCurrent. Stale phantoms become
  distinguishable.
- Profile i18n sweep: 8 keys × 3 languages = 24 fixes — all {{var}} placeholders
  switched to %{var} matching i18next config (Vue-i18n leftover from Nuxt-port).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:16:22 +02:00
chahinebrini
5b1f89e749 feat(backend): device-info schema + merge heuristic + test-user detection
- 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>
2026-05-15 21:16:05 +02:00
chahinebrini
0452007daf chore(native): bump iOS buildNumber 4 → 6 for fr-locale + lyra-voice release
Sync mit Android versionCode 6 (buildNumber 5 wurde nie als iOS-EAS-Build
genutzt, daher direkt auf 6 für konsistente Plattform-Nummerierung).

Changelog für buildNumber 6:
- French app-language for test customers
- Lyra voice picker (legend tier)
- Realtime debug page (DEV-only)
- Mail-filter refactor: Groq removed, deterministic pipeline only
- Bugfix: protectedDevices array guard

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 16:41:13 +02:00
chahinebrini
fb29c061c3 chore(native): bump android versionCode to 6 for fr-locale + lyra-voice release
Changelog für versionCode 6:
- French app-language for test customers
- Lyra voice picker (legend tier)
- Realtime debug page (DEV-only)
- Mail-filter refactor: Groq removed, deterministic pipeline only
- Bugfix: protectedDevices array guard

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 11:03:43 +02:00
chahinebrini
10219e5f68 feat(i18n): add french as 3rd app language
Für Test-Kunden: vollständige fr-Locale mit Tonalität für Recovery-Kontext
(„addiction", „Série", „Période de blocage"). Eigenname „Lyra" und Brand
„Rebreak" bleiben unübersetzt.

- locales/fr.json: 1:1 key parity zu de.json/en.json (UI-Agent-Output)
- lib/i18n.ts: fr in resources + initialLng-Detection (Device-Locale fr → fr)
- stores/language.ts: AppLanguage union ergänzt um 'fr', init-Logic + persistence
- app/settings.tsx: Sprach-Picker mit dritter Option Français
- de.json/en.json: language_fr-Label + language_desc trilingual

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 11:03:37 +02:00
chahinebrini
740589db5b chore(native): bump android versionCode to 5 for mail-page-ui release
Covers 10 commits: reactive mail page, donut/legend layout overhaul,
multi-layer classifier, DSGVO Art.17 sample cleanup. CHANGELOG.md added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 22:16:02 +02:00
chahinebrini
a0d67f33a8 feat(native): realtime debug page + protected-devices array guard
- stores/realtimeDebug.ts: neuer DEV-only Zustand-Store mit connection-state,
  reconnect-counter, token-expiry-countdown, channel-liste, rolling log-buffer
  (last 100 events). Hookt Phoenix-Socket open/close/reconnect + Channel-subscribe.
- _layout.tsx: initRealtimeDebug() im __DEV__-Block beim App-Start.
- debug.tsx: zwei neue Cards (RealtimeStatusCard + RealtimeLogCard) mit
  1s-Tick-Refresh, Copy + Clear Buttons. Settings-Entry 'Realtime connection (DEV)'.
- protectedDevices.ts: Array.isArray-Guard für apiFetch-Response — verhindert
  TypeError 'devices.filter is not a function' wenn Backend Non-Array zurückgibt.

Diagnostik-Tool für Realtime-Disconnect-Bug bei lange eingeloggten Usern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:15:57 +02:00
chahinebrini
d9bb7ef91a feat(native): lyra voice picker UI + me-hydration
- settings.tsx: neuer Abschnitt 'Lyra (Legend)' — nur sichtbar wenn plan==='legend',
  UIMenu mit 3 Optionen (Standard / Stimme 1 / Stimme 2), chevron-forward Anchor.
  Optimistic Update via PATCH /api/profile/me/lyra-voice, Revert bei Error.
- useMe.ts: lyraVoiceId im Me-Type — Hydration aus /api/auth/me beim App-Start.
- de.json + en.json: settings.lyra_voice + lyra_voice_default/_1/_2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:15:49 +02:00
chahinebrini
76f8595a4f feat(backend): lyra voice picker for legend (mvp)
- profile.ts: Whitelist (null | iFSsEDGbm0FiEd2IVH4w | Gt7OshJCH7MuzX96wFHi) + setLyraVoiceId()
- profile/me/lyra-voice.patch.ts: neuer Endpoint, Legend-Gate (403 legend_only),
  Validation gegen Whitelist (400 invalid_voice_id). DB-Wert bleibt bei Plan-Downgrade.
- coach/speak.post.ts: ElevenLabs-Voice-Prioritätskette nimmt userLyraVoiceId
  zuerst (nur wenn plan === legend), sonst voiceCfg / config / env / FALLBACK.
- auth/me.get.ts: lyraVoiceId in der Profile-Response damit Frontend hydriert.

Schema-Feld lyraVoiceId existiert bereits aus migration
20260507_profile_demographics_and_trial — keine neue Migration nötig.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:15:42 +02:00
chahinebrini
f2e3c00943 refactor(mail): remove groq llm layer — deterministic pipeline only
User-Direktive: Mail-Filter bleibt auf dem deterministischen Score+Layer-2.5-Stack.
Groq-LLM Borderline-Call (Layer 4) entfernt. Layer 2.5 Brand+Random fängt den
Apple Hide-My-Email Fall (icloud.com-Adressen mit kryptischen Local-Parts +
Brand-DisplayName) weiterhin sauber via Hard-Block.

Score-Mid-Range 25-79 entscheidet jetzt deterministisch: ≥50 → BLOCK, sonst PASS.

Damit auch DSGVO-P0-Items aus dem Hans-Müller-Review obsolet
(AVV-Annex Groq, Drittland-USA-Consent-Toggle, Datenschutzerklärung-Absatz).

- mail-classifier.ts: callGroqClassifier + redactLocalPartForLLM + groq-Feld raus
- scan.post.ts + scan-internal.post.ts: groqApiKey-Param raus, groq*-Sample-Felder raus
- mail-classifier.test.ts: Groq-Tests + redactLocalPart-Tests entfernt, 46 Tests grün

DB-Spalten in mail_classification_samples (groq_*) bleiben als legacy nullable —
Cleanup-Migration optional in späterem Sprint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:15:32 +02:00
chahinebrini
343f9ab567 fix(mail): DSGVO Art. 17 — manuelles Sample-Cleanup bei Account-Delete
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>
2026-05-14 22:05:35 +02:00
chahinebrini
bdd93668ae feat(mail): multi-layer classifier — Brand+Random, Relay-Decoder, Score, Groq + ML-Sampling
Layer 0–4 Klassifikations-Pipeline in mail-classifier.ts:
- Layer 2: Domain-Hard-Block + Relay-Decoder (=domain.tld aus SendGrid/Mailchimp-Bounces)
- Layer 2.5: Brand+Random-Token-Hard-Block (Gambling-Brand-Normalisierung + Random-Token-Detection)
  verhindert LLM-Call für bekannte Gambling-Relayer (Gamblezen, BetandPlay etc.)
- Layer 3: Score 0–100 (TS-Gewichte: Domain-Keywords, Subject-Keywords, Name-Match,
  Geld-Pattern, Urgency, All-Caps, Short-Random-Domain, Brand/Random-Ergänzungen)
- Layer 4: Groq Llama 3.3 70B Borderline-Klassifikation (Score 25–75)
  mit Local-Part-Redaction (DSGVO: nur behalten wenn local-part selbst Keyword enthält)
- Layer 5: MailClassificationSample-Insert nach jeder Klassifikation (ML-Phase 3)

Migrations:
- 20260514_add_mail_blocked_trigger_source: ADD COLUMN trigger_source auf mail_blocked
- 20260514_add_mail_classification_sample: CREATE TABLE mail_classification_samples

50 neue Tests (mail-classifier.test.ts): alle Layer, beide Screenshot-Beispiele (Gamblezen +
BetandPlay) bestätigt als Layer-2.5-Hard-Block ohne LLM-Call, Whitelist, Score, Redaction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 22:05:35 +02:00
chahinebrini
c218287c5e fix(mail): legend bottom-aligned mit donut-baseline für visuelle zentrierung
Donut-Bounding-Box ist asymmetrisch (Bogen oben, Center-Number bei ~70%
der Box-Höhe unten). alignItems:center zentrierte Legend gegen die
Box-Mitte → visuell zu hoch. alignItems:flex-end aligned Legend an
Donut-Baseline → Legend-Mitte landet auf Donut-Center-Number-Höhe.
Plus paddingBottom:12 damit Legend nicht direkt am Card-Border klebt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:08:53 +02:00
chahinebrini
1d93ada275 fix(mail): revert marginBottom hack — layout was breaking out of card
Mein letzter marginBottom:-28 Versuch hat den Donut-Wrapper Layout-Width
durcheinandergebracht — Donut ragte links aus der Card. Zurück zum
clean Layout ohne negative Margin. Kleine vertikale Asymmetrie zwischen
Donut-Center-Number und Legend-Mitte bleibt akzeptiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:02:56 +02:00
chahinebrini
778d3b6746 fix(mail): legend vertikal zentral gegenüber donut-center-number
Donut-Box ist asymmetrisch: SVG-Höhe 118px, aber Center-Number sitzt bei
y≈81 (Bogen oben, Number unten-mitte). alignItems:center zentriert die
Legend gegen die SVG-Box-Mitte (y=59) — visuell zu hoch, weil die echte
Donut-Mitte unten liegt.

Fix: marginBottom:-28 am Donut-Wrapper. Reduziert die effektive Box-Höhe
von 118 auf 90px → alignItems:center positioniert Legend dann gegen die
visuelle Donut-Mitte statt der Bounding-Box-Mitte. Donut-Bogen overflows
sichtbar nach unten (kein Clipping).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:57:18 +02:00
chahinebrini
55cba9a3fe fix(mail): legend takes natural width inside card + bar-chart always trims to hit-range
1. Legend-Wrapper: feste 180px-Width raus, stattdessen flex:1 + minWidth:0.
   Mit Donut 200px + gap 20 + Card-paddingHorizontal 16+16 wäre 200+20+180+32=432
   zu breit — kleine iPhones haben effektive Card-Width <380px. Legend ragte
   raus. Jetzt: Legend nimmt verfügbaren Rest-Platz, Texte trunken bei Bedarf.

2. useMailConnectionStats: zoom IMMER wenn nonEmpty.length > 0, nicht nur
   bei sparse-data-Bedingung. Bei 30-Tage-Range mit 1 Hit wurde das vorher
   trotzdem als 30 leere Bars + 1 Bar gerendert (Logik nonEmpty*3<raw greift
   zwar mathematisch, aber nicht aggressiv genug für wirklichen Visual-Fix).
   Jetzt: trim ALWAYS auf [firstHit..lastHit] — bei 1 Hit = 1 Bar, bei 5 Hits
   über 10 Tage = 10 Bars (5 mit Daten, 5 dazwischen). Konsistent visuell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:53:08 +02:00
chahinebrini
b47ac2427e fix(mail): legend rows justify-between + per-connection chart sparse-data zoom
1. Donut-Legend-Rows als space-between: Name links + dot, Count rechts.
   Vorher: alle Elemente eng aneinander (gap:6), Count direkt nach Name.
   Jetzt: feste Legend-Width 180px, jede Row hat Name+Dot links (flex:1)
   und Count rechts mit Whitespace dazwischen.

2. Per-Connection-Bar-Chart in Account-Card: sparse-data-zoom.
   Vorher: bei nonEmpty.length > 0 && days <= 7 wurde gezoomt — bei 30-Tage-
   Range mit nur 1-2 Hits passierte das aber NICHT → 30 leere Bars + 1 Bar
   ganz rechts (Screenshot bei GMX-expanded).
   Jetzt: zoom IMMER wenn nonEmpty.length * 3 < raw.length (= mehr als
   2/3 der Range sind leer). Trim auf die echte Hit-Range. User sieht
   damit nur die Tage mit Daten + die paar dazwischen, statt 30 leere
   Slots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:48:51 +02:00
chahinebrini
aac6c00720 fix(mail): donut card layout — justify-start statt center
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:36:12 +02:00
chahinebrini
2ea0cfec96 fix(mail): donut card layout from scratch — center, breathing room, no truncation
User-Feedback nach mehreren Iterationen: vorheriges Layout war kaputt
(Donut zu klein, Total links statt im Center, Legend mit "G.." truncated).
Frischer Ansatz:

- DONUT_WIDTH 180 → 200 (Center-Number-Math passt, sitzt sauber im Bogen-Hohlraum)
- Container: flex-row, alignItems center, justifyContent center, gap 20
- KEIN flexShrink/maxWidth am Legend-Wrapper mehr (war Ursache des Quetschens)
- Truncation nur am einzelnen Text-Element via maxWidth: 160 + numberOfLines: 1
  (statt am ganzen Wrapper) — schützt nur extrem lange Domains
- Donut + Legend nehmen ihre natural-width, Container zentriert beides

Plus i18n: "Blockiert — letzte 30 Tage" → "Blockiert" (DE+EN).
Das hardcoded 30 war falsch wenn die Connection nur 2 Tage Daten hat.
Echte Range-Info kommt schon aus dem Sublabel "N Mails blockiert · M letzte
Woche".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:33:39 +02:00
chahinebrini
4580a197dd fix(mail): reactive page (refresh stats + status on scan/connect) + center donut+legend
Two small fixes blocking real "feierabend":

1. Stats-Counter veraltet nach Scan/Connect/Disconnect:
   - mail.tsx hatte zwei separate Data-Sources: useMailStatus (accounts +
     errors + heartbeat) und useMailStats (blockedByDay + blockedByConnection)
   - onScanSuccess + onIntervalChanged + OAuth-onSuccess + disconnect-handler
     refreshten nur useMailStatus → der Account-Collapsible-Counter (kommt
     aus useMailStats.blockedByConnection) blieb veraltet
   - Beobachtet: GMX-Scan-Button meldet "90 blockiert" als Feedback, aber
     Card-Header zeigt weiter 60
   - Fix: refreshAll() = refresh() + refreshStats() parallel. Alle reactive
     callsites (4 Stellen) auf refreshAll umgestellt
   - useMailStats hatte refresh schon exportiert (Z. 153), nur nicht
     verdrahtet

2. Donut + Legend horizontal zentriert:
   - vorher: alignItems center (vertikal), Legend flex:1 → linksbündig mit
     Legend bis Card-Rand gestreckt
   - jetzt: justifyContent center + Legend ohne flex:1 → Block in der Mitte
     mit Whitespace links/rechts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:16:53 +02:00
chahinebrini
30ed4b90b4 chore(native): bump build number to 4 for staging release
iOS buildNumber: 3 → 4
Android versionCode: 3 → 4
version stays 0.1.0 (no breaking changes, MVP phase)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:02:49 +02:00
chahinebrini
8075c8e79c feat(mail): outlook-OAuth scan + daemon initial-sweep + page polish v4
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>
2026-05-13 23:55:18 +02:00
chahinebrini
fd1cb912f7 fix(idle-daemon): sync MS_OAUTH_SCOPES with backend — drop User.Read, add email
mo-Befund: Daemon hat sein eigenes MS_OAUTH_SCOPES-Array, das durch den
Backend-Fix von gestern Abend nicht erreicht wurde. Token-Refresh-Call
im Daemon nutzte noch User.Read → Microsoft wirft AADSTS70011
"scopes not compatible" → setMailConnectionAuthBroken → Frontend zeigt
"Auth-Fehler" (vorher: stiller Hang via getMailboxLock-Timeout).

Daemon-Scopes jetzt synchron zu backend/server/utils/ms-oauth.ts:
- IMAP.AccessAsUser.All (Outlook-Resource)
- offline_access (cross-resource)
- openid (OIDC, cross-resource)
- email (OIDC, cross-resource — liefert email-Claim)

Folge-Aktion für User: bestehende Outlook-Connection muss neu verbunden
werden, weil der gespeicherte refresh_token von Microsoft mit den alten
inkompatiblen Scopes ausgestellt wurde. Disconnect + re-OAuth in der App.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:33:39 +02:00
chahinebrini
2e285beefd chore(mail): bump distribution donut to 240 + trim card padding
User-Feedback: viel Top-Padding ungenutzt nachdem der Title raus ist.
DONUT_WIDTH 200 → 240. paddingTop 16 → 10, paddingBottom 16 → 12,
marginBottom der Live-Pill-Row 14 → 4. Visuell mehr Donut, weniger
leere Fläche.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:31:06 +02:00
chahinebrini
c8a18baf75 chore(mail): drop distribution chart title + bump donut width 168→200
User-Feedback: "verteilung nach postfach"-Title ist redundant
(Donut + Legend sind selbsterklärend). Plus: Donut soll größer sein.

- Title-Text entfernt in beiden Render-Pfaden (hero + non-hero)
- Live-Pill rechts oben bleibt (justifyContent: 'flex-end')
- DONUT_WIDTH 168 → 200 (Höhe skaliert proportional via HalfDonut-Aspect)
- Animation läuft bereits über die shared HalfDonut-Komponente
  (1100ms Easing.out.cubic beim Mount/Value-Change)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:28:37 +02:00
chahinebrini
1dfb0c647c feat(mail-page): polish v3 + shared HalfDonut + status-dot heartbeat-aware
User-Feedback nach Live-Test:

Frontend (mail page):
- HalfDonut als shared component in components/common/HalfDonut.tsx
  extrahiert (vorher local in ProtectionDetailsSheet). Mail-Page nutzt
  jetzt dieselbe SVG-Math, Animation und Stroke-Style wie der
  Blocker-Schutz-Details-Sheet — visuelle Konsistenz auf einen Blick.
  Mail-Donut: width=168 (kompakter als die 220 in Blocker, weil Legend
  rechts daneben sitzt).
- Donut zeigt Total in der Mitte mit kompaktem Format:
  < 1000 → "999", >=1000 → "1.2k+" / "12k+" / "27k+"
  Headline-Zahl oben links entfällt — Total ist im Donut-Center.
- "Mehr Infos" + "Kürzlich blockiert" zu EINER Top-Level-Collapsible
  zusammengefasst. Beim Aufklappen: Bar-Chart direkt sichtbar, nested
  Collapsible "Kürzlich blockiert" darunter (default zu).
- Account-Card Expanded: per-Connection-Bar-Chart mit adaptive
  Granularität nach Connection-Age:
  · <24h → Empty-State "Daten werden gesammelt, Auswertung nach 24h"
  · 1-14d → Day-Buckets (echte Daten via /api/mail/stats/blocked-by-day
    ?connectionId=)
  · 15-90d → Week-Buckets (client-aggregiert)
  · >90d → Month-Buckets (client-aggregiert)
- Settings-Sheet komplett refactored: State-Machine `mode: 'list' |
  'edit-title' | 'edit-email' | 'edit-password'` mit Back-Pfeil. Inline-
  Edit im selben Sheet statt Sub-Sheet öffnen (FormSheet-Pattern).
  Email-Edit-Row vorbereitet (Backend-PATCH-Endpoint kommt separat).
- Pen-Icons app-weit entfernt: SheetFieldStack-Row, alle Settings-Rows
  auf chevron-forward (Memory-Konvention).

Frontend (MailAccountCard status fix):
- resolveStatusDot nutzt jetzt heartbeat-as-fallback. Vorher: "waiting"
  wenn lastScannedAt=null, egal ob Daemon längst connected war. Jetzt:
  "waiting" nur wenn weder lebendiger Heartbeat noch vergangener Scan
  existiert → frisch verbundene Connections (z.B. OAuth-Outlook 5s nach
  Connect) zeigen direkt "live".
- Behebt User-Beobachtung: "wartet auf erste verbindung" bei Outlook
  obwohl Daemon-Log "connected, auth=xoauth2" zeigt.

Backend (imap-idle daemon):
- getMailboxLock("INBOX") jetzt mit 30s Promise.race-Timeout gewrappt.
- Outlook/XOAUTH2 hat den Edge-Case, dass der Mailbox-Lock lautlos
  hängt nach erfolgreichem connect — die Session bleibt offen ohne
  Fortschritt bis der Renew-Timer (10min) ein imap.close() schickt.
  Mit Timeout wird das Failure-Mode explizit → Auth-Retry-Loop greift
  sauber + last_connect_error mit klarem Text (statt stiller Hänger).
- Root-Cause "warum hängt es" noch nicht behoben — Diagnose nach
  Deploy in Logs (mo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:23:45 +02:00
chahinebrini
206941e5e1 fix(mail-page): UX polish — FAB-revert, legend cap, activity NaNd, instant heartbeat
User-Feedback nach Live-Test:

Frontend:
- FAB raus, Plus-Button zurück in den Account-Liste-Section-Header
  (`add-circle-outline` in brandOrange + Label "Postfach hinzufügen").
  FAB stört am unteren Rand, oben passt zum iOS-NavBar-Pattern.
- Half-Donut Legend strikt max Top-3 + "Sonstige" — Threshold von ≤4
  auf ≤3 gesenkt. Auch bei 4 Connections wird jetzt schon komprimiert.
- Hero-Donut-Subtitle "über N Postfächer" entfernt — Title-Block ist
  jetzt eine Zeile: "XX blockiert · ● Live"
- Activity-Log default-collapsed war schon richtig (kein Change)
- Activity-Item-Redesign: x-Icon-Pille raus, Zeit + Provider als
  Sub-Zeile unter dem Subject ("vor 2h · GMX"), kein Zeit-Label rechts mehr

Bug-Fix — NaNd in Activity-Row:
- Root-Cause: snake_case/camelCase-Mismatch. Backend liefert
  `receivedAt`, `senderEmail`, `senderName`, `connectionId` (camelCase),
  Frontend-Type hatte snake_case → undefined-Werte → `new Date(undefined)`
  → NaN → "NaNd"-Render
- MailBlockedItem-Type auf camelCase umgestellt + nested `connection`-Objekt
  (passt jetzt zum Backend-Response)
- formatDate mit Number.isFinite-Guard — gibt null zurück bei ungültigem
  Datum statt NaN-String zu rendern

Backend (imap-idle daemon):
- Daemon schreibt jetzt unmittelbar nach `client.connect()` einen Heartbeat
  (last_idle_heartbeat_at = NOW()) + clear last_connect_error parallel
- Vorher: User sah 2-9min lang "wartet auf erste verbindung" obwohl
  Connection längst aktiv war (Heartbeat kam erst beim ersten NOOP-Cycle)
- Re-Connect-Pfad nach AUTHENTICATIONFAILED ist automatisch mit
  abgedeckt (geht durch denselben connect-Block)
- ESM-Daemon, kein Build-Step — Pipeline scp + pm2-restart reicht

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:55:50 +02:00
chahinebrini
432d9d27a3 feat(mail-page): hero-donut + FAB + collapsible bar-chart + legend truncation
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>
2026-05-13 22:39:45 +02:00
chahinebrini
cb5c193980 fix(mail/oauth): add email OIDC scope — id_token Email-Claim für Personal-Accounts
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>
2026-05-13 22:21:31 +02:00
chahinebrini
09d85180b6 fix(mail/oauth): drop User.Read scope — MS rejects multi-resource at /token
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>
2026-05-13 22:16:01 +02:00
chahinebrini
7fb76465f0 fix(mail): explicit imports for MS-OAuth handlers (createOauthPendingState et al.)
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>
2026-05-13 21:25:43 +02:00
chahinebrini
fc69a14f25 feat(mail): outlook oauth — full end-to-end (backend + daemon + frontend)
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>
2026-05-13 21:04:14 +02:00
chahinebrini
275637f0b0 feat(mail): separate mail_blocked_stats table — preserves charts beyond 24h cleanup
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>
2026-05-13 19:13:24 +02:00
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
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
chahinebrini
01d515d137 feat(rebreak-native): persistent FaceID-sign-in + iOS-grouped UI + Outlook guard + sparkline cooldowns
Auth / FaceID — eingeloggt bleiben funktioniert jetzt:
- AppLock-Init idempotent: late re-init durch router.replace-Re-Mount behält
  locked-State (fixt Endlosschleife: unlock → re-mount → init reset → lock)
- LockScreen-Auto-Prompt nur wenn AppState=active (verhindert silent FaceID-
  Fail wenn LockScreen während background-Event mountet — User sah dann nur
  Fallback-Button)
- index.tsx: wenn Session schon in AsyncStorage liegt → router.replace zu /(app),
  Landing wird übersprungen; early-return nach allen Hooks (Rules of Hooks)
- WebBrowser.dismissAuthSession vor openAuthSessionAsync (verhindert
  "Another web browser is already open" nach abgebrochenen OAuth-Flows)

UI — iOS-Grouped-Look auf Settings + Profile:
- Neue Theme-Tokens groupedBg (#F2F2F7 / #000) + card (#fff / #1c1c1e),
  identisch zu Apples systemGroupedBackground / secondarySystemGroupedBackground
- settings.tsx + profile/index.tsx + profile/[userId].tsx: Page-BG → groupedBg
- StreakSection / UrgeStatsCard / DemographicsAccordion / StatsBar /
  ApprovedDomainsList: Card-BG colors.surface → colors.card

Mail-Connect — Outlook-Tile entschärft:
- Microsoft hat App-Passwords für consumer-Outlook (.com/hotmail/live/msn) im
  September 2024 abgeschaltet, der bisherige Guide-Flow ist seit ~8 Monaten
  wirkungslos → AUTHENTICATIONFAILED
- Tile bleibt sichtbar mit opacity 0.45, "Kommt bald"-Sub-Label, disabled=true
- Provider-Typ um disabled? + disabledLabelKey? erweitert (wiederverwendbar)
- Backend-OAuth-Plan unter backend/docs/mail-outlook-oauth-plan.md (mo)
  → Generisches AuthMethod-Framework (app_password | oauth) geplant

Profile — Cooldown-Verlauf als Sparkline statt Endlos-Liste:
- 8 Wochen-Buckets, Bar-Höhe nach Frequenz (cap 5/Woche), leere Wochen als
  2px-Flatlines
- Sub-Label: "{n} Cooldowns in 8 Wochen · Ø 1 pro {avg} Wochen · zuletzt {date}"
- Neutral formuliert (Sucht-/Stigma-Sensibilität: Cooldown = Schutz-Pause,
  kein Rückfall)
- useProfileData.ts liefert rawStartedAt (ISO) zusätzlich zum formatierten Wert
- i18n-Keys unter profile.cooldown.* in DE + EN

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:15:54 +02:00
chahinebrini
a3f892ddac fix(native/mail): duplicate add-button in empty state + intro hints in ConnectMailSheet
- mail.tsx: hide section-header "+" button when accounts.length === 0 — MailEmptyState's CTA is the sole add trigger; also replaces Pressable with TouchableOpacity
- MailEmptyState: Pressable → TouchableOpacity (no-Pressable rule)
- SheetFieldStack: add optional `intro?: ReactNode` prop rendered in a flexShrink:1 ScrollView above chips/active-input so it compresses gracefully when the keyboard is up
- ConnectMailSheet: move app-password guide + green AES block into `intro` prop so they're visible from the start, before the user types anything

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:39:22 +02:00
chahinebrini
3eaf3f098a refactor(rebreak-native): migrate device sheets to FormSheet, delete KeyboardAwareSheet (phase 3C)
AddMacSheet + AddWindowsSheet now use FormSheet instead of the old
KeyboardAwareSheet. Steps with no TextInput disable growWithKeyboard;
Step 2 (long onboarding list) gets an internal ScrollView so content
is scrollable within the sheet cap. Sheet heights converted from fixed
px to initialHeightPct fractions.

KeyboardAwareSheet.tsx deleted — no remaining consumers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:12:35 +02:00
chahinebrini
f24c364c81 feat(rebreak-native): KeyboardAwareScreen composable + full-screen form migration (phase 3A)
New component/KeyboardAwareScreen.tsx encapsulates the standard
KeyboardAvoidingView pattern for full-screen forms:
- iOS behavior="padding", Android no-op (adjustResize covers it)
- scrollable prop: ScrollView with keyboardShouldPersistTaps="handled"
- non-scrollable: TouchableWithoutFeedback+View for tap-to-dismiss
- headerOffset prop for screens owning their own header padding

Migrated to KeyboardAwareScreen: signin, signup, forgot-password,
confirm-otp (SafeAreaView-wrapped, no headerOffset needed) and
profile/edit (KAV wrapper only, explicit ScrollView retained).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:12:29 +02:00
chahinebrini
7ec4be810b feat(rebreak-native): AppAlert composable, avatar compression, FamilyControls gate
- components/AppAlert.tsx — one parametrized alert composable (error / success /
  confirm), replacing scattered Alert.alert(). error mode sanitizes raw response
  bodies (strips HTML, detects 413/5xx/nginx → friendly generic text, raw text
  only in a collapsible "Details" section). Light backdrop, TouchableOpacity.
- profile/AvatarCropSheet — compress the cropped avatar via expo-image-manipulator
  (max 512×512, JPEG q0.7 → ~50–150 KB) before upload, so the nginx 1 MB cap on
  staging.rebreak.org/api/ no longer 413s; compress errors shown via AppAlert.
  (adds expo-image-manipulator ~14.0.7 — needs a fresh dev build)
- lib/protection.ts — FAMILY_CONTROLS_AVAILABLE = expoConfig.extra.familyControlsEnabled
- app/(app)/blocker.tsx — App-Lock toggle only rendered when FAMILY_CONTROLS_AVAILABLE;
  otherwise a quiet "App-Lock — coming soon" row + "bald" badge. The URL-filter
  banner / ProtectionLockedCard stay positive (the filter carries the protection).
- de/en strings for alert.* and blocker.app_lock_coming_soon_*

Follow-ups: nginx client_max_body_size → ≥5 MB on staging (backyard, separate);
ConfirmAlert/SuccessAlert call-site sweep onto AppAlert (separate).

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