74 Commits

Author SHA1 Message Date
chahinebrini
dba33b5733 feat(db): enable realtime publication for direct_messages + chat_messages
Same pattern as notifications/protected_devices/user_custom_domains:
without ALTER PUBLICATION supabase_realtime ADD TABLE, Postgres does
not broadcast inserts and clients receive no live messages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 08:50:04 +02:00
chahinebrini
2cc0b20fc8 fix(backend/dm): include attachmentUrl + replyTo in history response
Map-response dropped attachment fields and reply reference even though
getDmHistory loads them — caused images and reply quotes to disappear
on conversation re-open.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 08:49:59 +02:00
chahinebrini
4573d16e1a refactor(mail-classifier): display-name aus Score-Pfad entfernen (v1.0)
SENDER_NAME_GAMBLING_KEYWORD (+30) und SENDER_NAME_BRAND_MATCH (+20) aus
SCORE_WEIGHTS entfernt. Layer-2.5-Brand-Match prüft nur noch Domain-Root
und Relay-Domain, nicht mehr displayNameNorm. Sender-Name-Keywords-Block
in computeScore() entfernt. keywordHitsName bleibt im Interface für v1.1.

Tests: Brand+Random-Tests die Display-Name als einzige Brand-Source hatten
auf neues v1.0-Verhalten (PASS) umgeschrieben. Zwei neue Tests: Display-Name-
only Casino-Signal → Score=0 → PASS verifiziert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 05:18:00 +02:00
chahinebrini
00ec716694 fix(mail): skip Gmail system folders in scan + raise subject-keyword score to 50
Fix 1 (scan-internal): filter out \All, \Drafts, \Sent, \Trash, \Flagged via
specialUse — stops [Gmail]/All Mail from consuming the SCAN_LIMIT=200 and
blocking new INBOX mails from reaching fetch range. \Junk/\Spam stay in scope.
Folders without specialUse (iCloud, GMX) pass through untouched — no false
exclusions without confirmed metadata.

Fix 2 (mail-classifier): raise SUBJECT_GAMBLING_KEYWORD from 35 to 50 so a
single unambiguous casino/jackpot/freispiel subject hit alone reaches the
SCORE_BLOCK_MIDRANGE threshold and triggers a block. Previously 35 pts fell
short when sender domain was generic and display name empty.

Tests: 9 new cases added (2 Fix-2 classifier + 4 Fix-1 folder-filter unit +
1 computeScore score=50 exact assertion). All 265 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 05:12:14 +02:00
chahinebrini
bf6affb3eb fix(mail): Gmail-Delete als Trash-Move + Scan-Trigger nach Custom-Domain-Add
Fix 1 (scan-internal): Gmail ignoriert IMAP EXPUNGE — stattdessen messageMove()
in Trash-Folder (via specialUse='\\Trash', Fallback '[Gmail]/Trash'). Verhindert
dass Gambling-Mails bei Gmail-Usern in 'All Mail' verbleiben statt zu verschwinden.
Alle anderen Provider (iCloud, Outlook, IONOS) bleiben beim bestehenden
messageDelete() + EXPUNGE-Fallback.

Fix 2 (custom-domains): Nach erfolgreichem mail_domain-Add fire-and-forget
$fetch auf /api/mail/scan-internal — damit neue Mail-Patterns sofort (< 5s)
wirken statt erst beim nächsten 30min-Cron. Scan-Fehler blockieren den POST nicht.

Tests: 16 neue Tests (gmail-delete-strategy + scan-trigger). 259 passed, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 05:03:09 +02:00
chahinebrini
a2680f6e19 feat(backend): auto-detect kind from pattern when body omits kind/type
POST /api/custom-domains now accepts a third body variant — { pattern }
without an explicit kind or type — which the resolver infers from the
pattern shape so the frontend can ship a single dynamic input field
instead of asking the user to choose between Seite / E-Mail in advance.

- pattern contains '@'             → treat as kind='mail', strip the
                                     local-part, store as mail_domain
                                     after the same TLD / DOMAIN_RE
                                     validation as the explicit-kind path
- pattern contains '.' (no '@')   → treat as kind='web'
- neither                          → 400 INVALID_PATTERN with a clear
                                     message ("Bitte eine Domain oder
                                     Mail-Adresse eingeben")

Variant A ({ pattern, kind }) and Variant B ({ domain, type }) stay
fully supported, plus a `kind: 'auto'` keyword if a client prefers an
explicit opt-in to the auto-detect path. The display-name path is still
locked off in v1.0 — pure tokens without dots route into the same
INVALID_PATTERN response, which keeps the v1.0 guarantee intact.

Plan-limits.test.ts grew the matching test cases — auto-detect for a
domain, auto-detect for a full address (local-part stripped to mail_-
domain), auto-detect rejection for a bare token. All existing tests
keep their pass status.
2026-05-16 02:49:48 +02:00
chahinebrini
f19d00017a feat: pre-check global blocklist on add + collapse Mails on load
User found that adding bet365.com (which is in the 208k global filter)
silently took a custom-domain slot — they paid a slot for something
the global blocklist already covered. Two pieces:

1. backend/custom-domains/index.post.ts: before any slot-limit check or
   DB insert, look the domain up in blocklist_domain (active rows). If
   present, return 200 { alreadyGlobal: true, domain }. No row gets
   written, no slot consumed. The existing frontend hook + AddSheet
   already handle the alreadyGlobal flag — they surface the
   "bereits global blockiert" alert and don't refresh as if the entry
   landed in the user's list.

2. blocker.tsx default mailOpen state flipped from true to false so the
   Eigene Mails section starts collapsed on page load. Domains stays
   the primary affordance; mail-patterns are an opt-in expansion.
2026-05-16 02:42:42 +02:00
chahinebrini
1215356990 feat(backend): add POST /api/devices/check-lock for native auth flow
Native app uses supabase.auth.signInWithPassword directly, bypassing
/api/auth/login. This authenticated endpoint runs the same device-lock
check post-auth: 409 DEVICE_LOCKED if bound to another user, 200+bind
if Pro/Legend user, no-op bind for Free users. CORS headers extended
to include x-device-name/model/os. 34 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:38:59 +02:00
chahinebrini
34491ad220 feat(backend): denormalize domain_submissions.type for admin + lyra + notifications
User asked for the admin review tooling — and the lyra-bot community
post / notification text that goes out with each submission — to know
whether a submission is a website-domain or a mail-sender-domain. Until
now the type lived only on user_custom_domains and the submission
inherited it implicitly via the foreign key. Reading it back for the
admin list or the lyra prompt meant joining the source row every time.

- migration 20260516_domain_submission_type adds a type column to
  rebreak.domain_submissions with a default of 'web' and backfills
  every existing row from its linked user_custom_domains.type. The
  backfill is idempotent (UPDATE … FROM with the type comparison).
- Composite index (type, status) so the admin pending-list can scope
  by category without scanning the whole table.
- submitDomainForReview now copies the source row's type into the new
  submission. The submit endpoint picks it up to vary the auto-generated
  community-vote post copy: a website framing for type='web' and an
  "Mail-Absender"-framing for type='mail_domain'. The user's nickname
  is the only PII referenced.
- adminApproveSubmission returns the type alongside the domain so the
  approve endpoint's Lyra-bot Groq prompt can swap its subject/action
  labels per category. Reject path unchanged — the notification just
  carries the bare domain string, no type framing needed.
- BlocklistDomain stays type-agnostic on purpose. The mail-daemon's
  getBlocklistedDomainsSet is a flat string-set match against sender
  domain or URL host, and works for both categories without splitting.
  Adding a type there would be redundant work in v1.0 — revisit only
  if we ever need a UI to surface what category each global entry
  came from.

38/38 backend tests pass (8 admin/domains, 30 plan-limits including
5 new for the type-copy semantics and community-post text variants).
2026-05-16 02:24:42 +02:00
chahinebrini
c1250836a3 fix(backend): remove display-name pattern support for v1.0
User explicitly chose to drop display-name matching from v1.0 after
the UX trap surfaced — a user typing "EXTRASPIN" without a domain got
a 400 INVALID_DOMAIN back, which is a confusing dead-end. v1.1 will
ship a dedicated display-name UI; until then mail input is domain-only.

- resolveTypeAndValue returns a discriminated union — kind='mail' with
  no dot or @ now resolves to { ok: false, error: 'INVALID_MAIL_DOMAIN' }
  instead of silently turning into a mail_display_name row.
- Full-address mail input (local@domain.tld) still gets its local-part
  stripped server-side so the stored value is always a clean domain.
- Variant-B body { type: 'mail_display_name' } returns 400
  DISPLAY_NAME_NOT_SUPPORTED for direct API consumers.
- The DISPLAY_NAME_PATTERN regex is gone — the path that used it can
  no longer be reached.
- classifyMail's Layer 2.6 (the display-name substring match) is
  intentionally left in place as dead code with a v1.1 marker, so
  re-enabling later is just wiring the input field back up and feeding
  the customDisplayNames array.
- Tests rewritten: the two pre-existing display-name tests now assert
  the 400 INVALID_MAIL_DOMAIN path, plus a new positive case for the
  full-address local-part strip. 217 vitest passes, 4 pre-existing skips.

Staging DB clean — the type column hasn't been deployed yet so no
mail_display_name rows exist to backfill.
2026-05-16 02:17:50 +02:00
chahinebrini
f2b81eef54 feat(backend/plan): separate web/mail slot pools + display-name submit lock
plan-features.customDomains is now { web, mail } per plan instead of a
single number. Free 5+5, Pro 5+5, Legend 10+10 — the user explicitly
chose separate pools so users don't have to trade a website slot for a
mail-pattern slot or vice versa.

- countActiveCustomDomainsSplit(userId) groupBy type → { web, mail }
  (mail aggregates mail_domain + mail_display_name). Old single-count
  function stays as a deprecated alias for any caller still on it.
- POST /api/custom-domains: body-compat accepts both { pattern, kind }
  (current frontend) and { domain, type } (legacy / direct). kind='mail'
  is split into mail_domain vs mail_display_name server-side based on
  whether the pattern looks like a domain. Slot check is per-bucket;
  errors are WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED so the UI can show
  the right limit-reached message per tab.
- GET /api/custom-domains: response shape extended to
  { items, counts: { web, mail }, limits: { web, mail } } so the
  frontend can drive the per-tab counter without client-side estimation.
- POST /api/custom-domains/:id/submit: hard-blocks mail_display_name
  with 400 DISPLAY_NAME_NOT_SUBMITTABLE. Display-name submission to the
  global blocklist is deferred to v1.1 — would require a schema split
  on BlocklistDomain that's risky pre-TestFlight. mail_domain still
  flows through the community-vote pipeline like web entries.
- auth/me.get.ts, plan/change-preview.get.ts, coach/message.post.ts
  updated for the new shape (Lyra prompts untouched, only template
  variables split web vs mail counts).

24 vitest cases in backend/tests/custom-domains/plan-limits.test.ts
cover the new shape, body compat, bucket logic, and the submit guard;
216/216 total backend tests pass.
2026-05-16 02:03:26 +02:00
chahinebrini
7dbcac6700 feat(backend): custom mail patterns — display-name match + type-aware api
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).
2026-05-16 01:53:59 +02:00
chahinebrini
ba170afd20 feat(native): chat tab badge for unread DMs
Adds a tabBarBadge on the bottom Chat tab driven by the same
dm-conversations query the chat screen already uses — React Query
dedupes the call. Badge shows the unread total (capped to "99+")
and disappears when 0. Query is gated on session so unauthenticated
launches don't fire it.
2026-05-16 01:53:03 +02:00
chahinebrini
6f760f3aea Revert "fix(backend/realtime): add community_posts to supabase_realtime publication"
This reverts commit 0679aa6218e56710a7290770bcb94d0913d9721d.
2026-05-16 00:48:13 +02:00
chahinebrini
0679aa6218 fix(backend/realtime): add community_posts to supabase_realtime publication
The community feed's likes/dislikes/comments/reposts counters never live-
updated for foreign actions because the table simply wasn't in the
publication. useCommunityRealtime subscribed to UPDATE on community_posts,
the channel opened cleanly, but no events ever arrived for that table —
Supabase only broadcasts what's published.

The notifications channel (rebreak.notifications, added in 20260511) was
in the publication from day one, so users got the "X liked your post"
banner correctly. That made the gap look like a frontend rendering bug
all along; it was actually a missing one-line publication grant.

After this migration deploys, the React-Query cache patcher in
useCommunityRealtime will receive UPDATE events, patch the post in place,
and PostCard will re-render with the correct displayedCount derived from
post.likesCount + the (now-cleared) optimistic delta.
2026-05-16 00:47:44 +02:00
chahinebrini
1bc38e0732 feat(backend): device-account binding for pro/legend users
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
2026-05-16 00:29:35 +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
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
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
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
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
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
5291a8a95a fix(realtime): domainRealtime CHANNEL_ERROR — wrong filter column + missing publication
Two bugs caused the domainRealtime channel to fail with CHANNEL_ERROR and
reconnect-loop every 3s (which also dragged down the notifRealtime channel via
the shared websocket close):
  1. useDomainSubmissionRealtime.ts filtered domain_submissions on a column that
     doesn't exist (`submitter_id`) — the actual column is `user_id`. Postgres
     raised on the publication-side filter registration → CHANNEL_ERROR.
  2. rebreak.user_custom_domains was never added to the supabase_realtime
     publication — the channel also subscribes to that table. New migration
     20260511_fix_realtime_user_custom_domains adds it.

(Diagnosis via backyard agent against the self-hosted Supabase on the Hetzner box.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:19:37 +02:00
chahinebrini
335945fe2c feat(tier): plan limits Rev.2 + downgrade reconciliation + change-preview (Phase 2 backend)
- plan-features.ts: globalBlocklist 'curated'|'full' (curated = 30-domain stub,
  TODO real ~1-2k HaGeZi subset); maxAppDevices vs maxProtectedDevices split
  (legend maxProtectedDevices: 2); mail 1/3/Infinity
- limit-enforcement structured errors on mail/connect, custom-domains/add, devices/enroll
  ({ error:'plan_limit', resource, current, limit }); approved-own-submissions already
  excluded from custom-domain count (slot frees on approval)
- server/utils/downgrade-reconciliation.ts: founding-member exemption; re-upgrade
  reactivates paused mail + degraded devices; downgrade pauses newest-N mail accounts
  (isActive=false, pausedAt, pausedReason; pre-pause sets nextScanAt=now for a final
  sweep — real direct IMAP scan is TODO/stub); degrades excess device profiles
  (status='degraded', degradedAt); free → globalBlocklistGraceUntil = now+14d;
  custom domains grandfathered
- set-plan.post.ts + stripe/webhook.post.ts: run reconciliation on plan change;
  set-plan accepts { foundingMember } for testing
- GET /api/plan/change-preview?to=<plan>: gains/keeps/changes per resource (8 axes),
  founding-member → direction 'same'
- me.get.ts: + foundingMember, globalBlocklistGraceUntil, planLimits block
- blocklist + mail-scan honour globalBlocklistGraceUntil (grace → treat as 'full')
- db: countMailConnections/getMailConnections exclude paused; getAllMailConnections;
  getDeviceBlocklistMode (active|grace|passthrough|revoked)
- migration 20260511_tier_system_phase2 (profiles.founding_member +
  global_blocklist_grace_until; mail_connections.paused_at/paused_reason;
  protected_devices.degraded_at). prisma generate + build:backend clean.

TODOs (separate tickets): founding-member auto-counter on signup; real direct IMAP
final-scan (not just nextScanAt nudge); real curated blocklist data + wiring the
stub into the blocklist response for free users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:23:02 +02:00
chahinebrini
eb871073f2 feat(backend): __DEV__ /api/dev/set-plan — user sets own plan (non-prod only)
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>
2026-05-11 15:44:26 +02:00
chahinebrini
1ad964f54b fix(backend): alias NUXT_*_BOT_USER_ID env vars in start-staging.sh
Infisical staging holds the bot user IDs as NUXT_LYRA_BOT_USER_ID /
NUXT_REBREAK_BOT_USER_ID, but nitro.config.ts reads process.env.LYRA_BOT_USER_ID
(no NUXT_ fallback) and start-staging.sh had no alias for them → config.lyraBotUserId
was empty → POST /api/admin/lyra-post threw 500 "LYRA_BOT_USER_ID nicht
konfiguriert" (surfaced via the admin app proxy). Adds the alias + NITRO_ override
exports, same pattern as the other keys.

Also: ops/strategy/pricing-tiers.md — strategist's tier-pricing analysis,
stress-test, downgrade-policy matrix, plan-change briefing-screen content +
scenario test matrix (Task #8 Phase 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:37:38 +02:00
chahinebrini
518510c088 feat(mail): IONOS-Detection + MX-Lookup-Fallback + humanisierte Error-Messages
- imap-providers: IONOS/1&1/1blu, msn.com, magenta.de, yahoo.co.uk, ymail.com, tutanota hinzugefügt
- detectImapProviderAsync: MX-Lookup-Fallback für Custom-Domains (IONOS kundenserver.de/ionos.de Pattern)
- connect.post.ts: nutzt jetzt detectImapProviderAsync statt sync-Variante
- ConnectMailSheet: rohe Server-Errors werden via humanizeMailError() + t() übersetzt
- useMailConnect: IONOS/t-online/freenet Domains in Client-Side-Detection ergänzt
- Locale de/en: provider_other, app_password_guide_other, host_unreachable, unknown Text präzisiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 05:15:29 +02:00
chahinebrini
6962e09403 feat(devices): Windows 11 DoH protection — reg-file endpoint + tests
- Add server/utils/regfile.ts: generateWindowsDohRegFile() producing
  UTF-16 LE + BOM .reg content for DohWellKnownServers registry path.
  label and dohTemplate values are properly escape'd (\, ", \n, \r, \t).
- Add GET /api/devices/:id/profile.reg — public, windows-platform-gated,
  returns octet-stream with Content-Disposition attachment.
- Update enroll.post.ts: downloadUrl is now platform-aware
  (windows → .reg, all others → .mobileconfig).
- Add tests/devices/regfile.test.ts: 13 tests covering BOM, CRLF,
  token embed, subkey naming, AutoUpgradeFlag, label escaping (", \, \n),
  and labelToSlug edge cases. All 111 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 04:48:51 +02:00
chahinebrini
58287f206d fix(realtime): enable Supabase Realtime publication for rebreak.notifications
Fixes [notifRealtime] CHANNEL_ERROR — table was not in supabase_realtime
publication, so postgres_changes events never arrived. Added by backyard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:40:32 +02:00
chahinebrini
677b67902b feat(devices): protected device enrollment + mobileconfig generator
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>
2026-05-11 04:06:49 +02:00
chahinebrini
2d0983d6c8 fix(mail): clear lastConnectError on successful re-connect
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>
2026-05-11 00:00:33 +02:00
chahinebrini
86445d8607 feat(url-filter): add blocklist.txt endpoint for DNS-filter sync
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>
2026-05-10 16:18:11 +02:00
chahinebrini
c1a66e3d07 feat(mail): connect-error tracking + IDLE-heartbeat for accurate UI status
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>
2026-05-09 23:48:11 +02:00
chahinebrini
01420eaa09 fix(imap-idle): IDLE-renew 25min→10min + NOOP-heartbeat (GMX silent-drop fix)
User-test: Casino-mail an Chahine@gmx.net wurde nicht geblockt obwohl
daemon "connected" zeigte. Mo's diagnose: GMX dropped IDLE-connection
silent (kein TCP-error, kein logout). ImapFlow.idle() hängt unbegrenzt
ohne reject — exists-events kommen nie an, daemon ist faktisch tot.

2 Fixes:
1) IDLE_RENEW_INTERVAL_MS: 25 min → 10 min. GMX timeout-window ist
   ~10-15min, 25min war zu lang. Trade-off: alle 10min full reconnect.
2) NOOP-heartbeat alle 2min während IDLE-loop. Wenn NOOP fail
   (= silent-drop detected) → close → reconnect-loop. Early-detection.

Andere provider (Gmail/iCloud/Outlook) sind unaffected — die haben
~29min IDLE-timeout, also passt 10min auch dort safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:42:09 +02:00
chahinebrini
a81ba2e54a feat(community): Post.gameName + GameShareBanner-rendering chain
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>
2026-05-09 22:28:07 +02:00
chahinebrini
fd737f8658 fix(imap-idle): use snake_case table + columns (match Prisma @map)
Daemon SQL used PascalCase "MailConnection" + camelCase column-names
that match the Prisma model field-names — but actual DB has snake_case
table "mail_connections" with snake_case columns (per @map decorators).

Result: daemon was online but ALL queries failed with
  relation "rebreak.MailConnection" does not exist
→ no mailboxes loaded → no IDLE-sessions established.

Fix: query "rebreak.mail_connections" with snake_case columns, alias
back to camelCase via SQL AS so rest of the daemon code works unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:57:14 +02:00