Commit Graph
832 Commits
Author SHA1 Message Date
Marc-André Bombeck ca196f27a6 fix(onboarding): skip the achievements tour stop when the module is off
The dashboard tour launcher now derives includeAchievements from the
per-user module map (modules?.achievements !== false), so an account
that has the achievements module turned off no longer walks the user to
a page it has hidden.

Mention the module on/off concept in the integrations tour stop, where
the tour introduces Settings and customization, so a first-time user
learns whole areas can be turned on or off.
2026-06-16 02:04:20 +02:00
Marc-André Bombeck b51c76238a docs(medications): correct the stale dose-status comment to the neutral med-card rule
The compliance number + card already render neutral (no grade tint); only a
stale comment still described a green take-window highlight the implementation
does not do. Reword it to state status drives a discreet line/glyph, never a
card background/border tint.
2026-06-16 01:49:03 +02:00
Marc-André Bombeck 6c9004b055 refactor(settings): route section headers through the shared primitive
mood, medications, Vorsorge, the health-record export panel and the export
section hand-rolled their card headers; route them through the shared settings
card-header primitive so every settings card header is structurally identical.
2026-06-16 01:44:44 +02:00
Marc-André Bombeck 0d963fffa9 fix(dashboard): unify recent-card view-all affordance and neutralise chrome 2026-06-16 01:27:55 +02:00
Marc-André Bombeck 2425832f47 merge(reconcile): r4-logic audit findings 2026-06-16 00:59:31 +02:00
Marc-André Bombeck c2537e2c08 merge(reconcile): r2-labs audit findings 2026-06-16 00:59:31 +02:00
Marc-André Bombeck bf5deb33e2 refactor(labs): one shared reference-range classifier and range formatter
Reference-range classification + low/high range rendering was implemented three
times (validations, PDF core, lab list). Extract a single classifier and a
structure-only range formatter that takes a per-surface number formatter, so
each surface keeps its own number formatting while sharing the canonical
classification. Behaviour is unchanged per surface and now covered by one test.
2026-06-16 00:58:27 +02:00
Marc-André Bombeck 91d01d154a fix(settings): make delegated module rows read-only managed deep-links
The Module hub rendered live switches for the delegated keys cycle and coach,
but the gate resolves those from their own sources (cycle from CycleProfile,
coach from the operator flag + disableCoach), so a toggle wrote an inert entry
to modulePreferencesJson, snapped back, and still fired a success toast. Render
delegated modules as read-only rows that deep-link to their real control, strip
delegated keys from the modules PATCH schema so no inert value is stored, drop
the duplicate cycle control, and surface an operator-disabled state with the
previously orphaned hint string.
2026-06-16 00:51:16 +02:00
Marc-André Bombeck ba7a72f484 fix(labs): confirm destructive deletes on lab results and Vorsorge reminders
The single-row delete on lab results and on preventive-care reminders
fired immediately on click, unlike the measurement and mood lists which
gate the same action behind a confirm dialog. Route both through the
shared DeleteButton so a stray tap can no longer drop a reading or a
reminder, and add the per-surface confirm copy across all locales.
2026-06-16 00:45:19 +02:00
Marc-André BombeckandClaude Opus 4.8 3de53f549c fix(health-score): drop the mood pillar when the mood module is disabled
The Personal Health Score weighted the 0.20 mood-stability pillar
unconditionally — the fast-path read mood entries regardless of the
account's module state, so a user who turned the mood module off was
still scored (and silently penalised or rewarded) by a pillar hidden
everywhere else in the product. The dashboard already blanks the mood
tile and the coach snapshot unions mood out; the score ring and the
insights card pillar list did not.

Thread a default-on `moodEnabled` flag through the fast-path. When the
caller resolves the mood module as disabled the helper skips the mood
read entirely and leaves the series empty, so `moodStability` returns
null and the existing proportional null-redistribution re-scales the
surviving pillars to sum to 100. The analytics route resolves the
module server-side and threads the flag; the insights card hides the
Mood row via a new SSR-safe `useModuleEnabled` accessor so it never
advertises a pillar the account turned off.

Golden fixtures pin mood-on vs mood-off to different, correctly
re-weighted scores and assert the disabled read is never issued.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:39:55 +02:00
Marc-André Bombeck 9c5e7a0186 feat(settings): gather Coach settings into a dedicated entry
Move the Coach preference cards (disable toggle, preferences, durable-fact
memory) out of the AI / Assistant section into their own Coach entry,
module-gated on the coach module. The AI entry keeps its name and owns
provider / model / BYOK configuration plus the "about me" context (which
the daily briefing reads too, so it is not Coach-only).

The proactive Coach-nudge card stays in Notifications: it is a reminder
TYPE, already coach-module-gated there alongside the other reminder types,
and belongs with the reminder family rather than the Coach preferences.
2026-06-15 23:28:22 +02:00
Marc-André Bombeck fce9ca8f1a feat(settings): gather medication settings into a Medikamente entry
Promote the Medikamente section from a hidden Layout-hub child to its own
standalone nav entry (always shown — medications is a CORE domain) and
gather the injection-site exclusions card here from the account profile,
so every medication-specific preference lives on one screen. The Layout
hub keeps only the dashboard + insights arrangement editors.

The low-stock card stays whole in Notifications: its runway config and the
low-stock reminder toggle are one tightly-coupled control writing a single
prefs row, so splitting only the config out would break the card.
2026-06-15 23:23:50 +02:00
Marc-André Bombeck 16bd43c08f feat(settings): move mood settings into a dedicated Stimmung entry
Promote the mood-tag management surface from a hidden Layout-hub child to
its own standalone settings nav entry, shown only when the mood module is
enabled. The shell now module-gates per-submodule entries off the resolved
`useAuth().user.modules` map (fail-open: a missing key reads as enabled).
The Layout hub keeps only genuine layout/personalisation editors.
2026-06-15 23:18:54 +02:00
Marc-André Bombeck 59fea72d8b feat(settings): give the health record its own Gesundheitsakte entry
Lift the full health-record export (PDF + FHIR R4 + zip package) out of
Export & Import into a dedicated top-level Gesundheitsakte section. Export
& Import keeps the generic CSV/JSON data-out and import paths only. The
new section is always available (not module-gated).
2026-06-15 23:14:28 +02:00
Marc-André Bombeck 604778f166 feat(settings): make Benachrichtigungen the single module-gated reminder-types home
Consolidate every reminder TYPE onto the Notifications screen and drop the
redundant stacked breadcrumb for one clean heading. Each reminder-type card
is shown only when its module is enabled, read from the resolved
useAuth().user.modules map (mood card on mood, Coach nudge on coach); the
gate fails open so a stale /me payload never blanks a card. Low-stock maps
to the medications CORE domain and Vorsorge (preventive care) is not a
toggleable module, so both stay shown — the Vorsorge editor embeds in place.
The delivery channels keep a single concise pointer to Integrations rather
than a duplicated section. Reword the heading copy and drop the now-unused
settings.sections.reminders.* keys across all six locales.
2026-06-15 23:06:45 +02:00
Marc-André Bombeck fccdcb378e refactor(settings): remove the redundant Erinnerungen hub page
The standalone /settings/reminders page was a link-only hub that merely
deep-linked into the canonical editors — duplicating the Notifications
entry. Drop the section, its slug, and its placeholder/route wiring, and
301-redirect /settings/reminders to /settings/notifications. Reminder
types consolidate onto the Notifications home in a follow-up.
2026-06-15 23:03:52 +02:00
Marc-André Bombeck 7b80a38ea2 refactor(settings): fold Sources into Integrationen as a sub-tab
Source priority decides which connection wins when two report the same
metric, so it belongs with the integrations rather than as a standalone
settings page. Sources renders as the third Integrations sub-tab
(Connections / Channels / Sources); the standalone `sources` slug is
removed from the shell nav, slug registry, and route dispatch, and
`/settings/sources` 301-redirects to `/settings/integrations`.
2026-06-15 22:58:44 +02:00
Marc-André Bombeck d3d8731209 refactor(settings): move notification channels into Integrationen
Channels (Telegram, ntfy, Web Push, Webhook, Email) and the live
per-channel status surface are delivery providers, not notification
preferences, so they now sit under Integrations next to the connected
services. Integrations gains a Connections / Channels sub-tab split;
Notifications keeps the reminder-type content (mood, low-stock, coach
nudge) and cross-links to the relocated channels.
2026-06-15 22:54:19 +02:00
Marc-André Bombeck 4a1971c0dd feat(admin): module availability matrix
Add an operator-side toggle matrix to the admin assistant section that
flips each toggleable module on or off server-wide via PATCH
/api/admin/settings/module-availability. Mirrors the assistant-flags
matrix: one neutral toggle per module, optimistic flip with a revert on
failure, and an authMe invalidation so the resolved module map re-gates
the session immediately. New admin.modules.* labels across all six
locales; core domains never appear.
2026-06-15 22:48:05 +02:00
Marc-André Bombeck 3cae7beda8 test(settings): cover the Module hub
Render every toggleable and core switch, confirm a disabled module reads
unchecked while an absent key stays default-on, assert the core domains
are locked, and verify a toggle PATCHes only the flipped key as a
disabled-allowlist entry and invalidates the auth/me query.
2026-06-15 22:37:34 +02:00
Marc-André Bombeck f50f92902e feat(settings): add the Module (what-you-track) hub
Surface a dedicated settings section for enabling and disabling the
secondary tracking domains (mood, sleep, glucose, workouts, recovery,
labs, achievements, coach, insights, doctor report, cycle). Each row
carries an icon, label, and one-line description with a switch bound to
the resolved module map on /auth/me; toggling PATCHes /api/auth/me/modules
and invalidates the auth/me query so the navigation, Insights pills, and
dashboard tiles re-gate live. Core domains (weight, blood pressure, pulse,
medications) render as a read-only always-on group. The entry sits right
below Account in the settings navigation.
2026-06-15 22:36:04 +02:00
Marc-André Bombeck 6bde45ce0a merge(modules): gate achievements behind the achievements module flag
# Conflicts:
#	src/hooks/use-auth.ts
2026-06-15 21:57:43 +02:00
Marc-André Bombeck 70402ea95a feat(modules): gate achievements behind the achievements module flag
When an account turns the achievements module off the whole gamification
surface disappears. The route enforces the gate after auth and returns the
403 module.disabled envelope, so no badge evaluation, unlock persistence,
or payload is produced. The achievements page, the dashboard recent-unlocks
tile, and the unlock notifier read the resolved module map from
GET /api/auth/me and render nothing / disable their query when the module
is off. Default-on: an absent key reads as enabled, a stale /me payload
coerces to an empty map so no surface vanishes on a partial-deploy
rollback.
2026-06-15 21:25:40 +02:00
Marc-André Bombeck 5682055fe2 feat(modules): hide nav entries and Insights pills for disabled modules
Generalise the per-destination cycle gate into a requiresModule marker
on each nav destination and read it from the account's resolved module
map (the modules field on GET /api/auth/me). Fold the bespoke cycle
boolean into requiresModule: "cycle" so there is one gating mechanism;
cycle and coach are delegated keys already reflected in the map, so
reading them here is not a re-derivation.

Tag mood, cycle, labs, coach and achievements with their module keys;
core destinations (measurements, medications, vorsorge, insights, the
dashboard) carry none and always render. The sidebar, the mobile More
hub, and the Insights tab strip all filter on the same map.

Insights pills: a metric pill belonging to a toggleable module (mood,
sleep, glucose, workouts) hides when its module is disabled, on top of
the existing data + saved-layout gates; the Recovery pill gates on the
recovery module. Overview and core-metric pills always show. Every gate
fails open — a missing key or an absent map keeps the entry, matching
the gate's default-on contract, so a stale /me payload never blanks the
nav.
2026-06-15 21:18:47 +02:00
Marc-André Bombeck f0a030f1ce refactor(nav): drop Workouts from the left nav (already an Insights tab)
Workouts already surfaces as an Insights tab-strip pill (the workouts
slug, gated on a logged workout row), so a parallel left-nav home was
redundant. Removing it from NAV_DESTINATIONS lets the Insights nav home
read active on /insights/workouts like any other /insights/* sub-route.
2026-06-15 21:05:24 +02:00
Marc-André Bombeck 640793a3e8 refactor(nav): surface Recovery as an Insights tab instead of a left-nav item
Recovery is a composite wearable surface (WHOOP / Polar / Oura recovery,
strain, ANS charge, cardio load), not a top-level tracking destination.
It moves off the left nav and onto the Insights tab strip as a pill at
/insights/recovery. The pill is always present (like Overview) since the
page self-gates every block and falls back to a calm empty note rather
than carrying its own availability signal. Adds the insights.navRecovery
label across all six locales.
2026-06-15 21:03:48 +02:00
Marc-André Bombeck aa27708ebc refactor(nav): move Coach to a standalone /coach page
The Coach lived under /insights/coach, rendering inside the Insights
layout's tab strip. It is the product's headline surface, not a metric
sub-page, so it now lives at the top-level /coach in the standard page
chrome without the Insights tab strip. The nav model points at /coach,
every in-app link and the proactive-nudge deep link follow, and a 301
redirect keeps /insights/coach (bookmarks, cached PWA navigation, older
push payloads) resolving.
2026-06-15 21:00:46 +02:00
Marc-André Bombeck 56c92e0cfe fix(nav): keep More-hub tile labels inside their tiles
The mobile "More" overflow sheet renders a two-column grid of icon-plus-label
tiles. The label span carried min-w-0 but no break handling, so a long
single-word label such as the German "Benachrichtigungen" — which has no
natural break opportunity — pushed past its grid track and overflowed the
tile box.

Add break-words to the label so an unbroken long word breaks inside the tile;
combined with the existing min-w-0 shrink and the auto-height tile (min-h-14),
the second line is absorbed cleanly. The fix is layout-level, so every label
in all six locales fits without special-casing the longest one.
2026-06-15 20:34:33 +02:00
Marc-André Bombeck d7691b6cbe merge(integrations): honest Polar sleep timeline, shared reconstruction, OAuth returns, status envelope 2026-06-15 01:35:27 +02:00
Marc-André Bombeck 9430e89889 feat(integrations): unify OAuth returns and the status envelope
The WHOOP and Fitbit OAuth callbacks redirect back with
`?whoop=connected|error` / `?fitbit=connected|error`, but the settings page
only read that param for Withings, Polar and Oura — a user returning from a
WHOOP or Fitbit round-trip landed on a silently unchanged page with no toast.
Extend the generic return handler to all four OAuth providers, add the
matching connected/failed/error i18n keys across every locale, and invalidate
both the per-card key and the consolidated envelope on a successful return.

Polar and Oura already write the shared IntegrationKey ledger but each fired
its own `/api/<provider>/status` round-trip from the card, so the page issued
one consolidated request plus three per-card ones. Fold Polar and Oura into
`/api/integrations/status` and let the cards read a passed view-model instead
of fetching, retiring the extra round-trips. The per-provider status routes
stay for the iOS and test callers.
2026-06-15 01:34:30 +02:00
Marc-André Bombeck 7574ea00ff merge(reliability): SMTP timeouts, cron index use, doctor-report sleep parity, VAPID dialog, redaction 2026-06-15 01:29:06 +02:00
Marc-André Bombeck b336ed5b73 fix(settings): confirm VAPID regenerate in-app; let labs subtitle wrap
The VAPID regenerate overwrite guard used a native window.confirm for a
subscription-invalidating destructive action, where the rest of the
release uses the project's AlertDialog. Drive the overwrite confirm
through an AlertDialog (calm in-app dialog, destructive action variant)
and retry with force from its action.

The /labs header subtitle used truncate, clipping mid-word in longer
locales on a 375px viewport. Drop truncate so it wraps; the add button
stays shrink-0 and the row aligns to the top.
2026-06-15 01:28:12 +02:00
Marc-André Bombeck eab86b21d5 merge(nav): top-level Vorsorge home, shared utility destinations with parity guard 2026-06-15 01:24:50 +02:00
Marc-André Bombeck 34875ce8c5 merge(import): success token, CSV header single-source, Link nav, responsive grid, counters 2026-06-15 01:24:10 +02:00
Marc-André Bombeck 3fc195e1c0 feat(settings): add a back-to-hub link on the Layout child editors
The four Layout personalization editors (dashboard, insights, medications,
mood) are reached through the Layout & Personalization hub or a page-header
cog, and the left rail highlights the hub while the body shows the child.
The only return path was tapping the highlighted rail entry — no explicit
up-affordance, sharper on mobile where the rail collapses to a chip strip.
Add a shared back-to-hub link at the top of each child so the hub → child
→ hub loop reads clearly.

Also fix the stale doc-comment on the settings section route: the slug set
is 16 (12 nav-visible plus the four routable-but-hidden Layout children),
not eight.
2026-06-15 01:24:03 +02:00
Marc-André Bombeck 6a994cd1c3 feat(nav): give Vorsorge a nav home and fold the utility tail into one model
Vorsorge (preventive-care) was a top-level tracking surface reachable only
three taps deep through Settings → Reminders, while peer surfaces (Labs,
Recovery) already had a top-level nav home. Add it to the one shared
destination model in the clinical spine, so it renders on the desktop
sidebar and the mobile More hub like every other feature, with a
nav.vorsorge key across all six locales.

Bug Report, Settings and Notifications were still a second hand-curated
list on each bar — the exact drift the one-model contract set out to kill,
just pushed down a level. Promote them to a shared NAV_UTILITY_DESTINATIONS
list both bars consume: the sidebar footer + avatar menu and the bottom-nav
More-hub tail now read from one source and can no longer disagree. The
More hub itself is now computed by mobileMoreHubDestinations() so the
headline invariant (hub == feature list minus primary slots, plus the
utility tail) is a tested model function rather than inline bar logic.

Add a sidebar utility-parity guard and a mobile More-hub invariant test.
2026-06-15 01:23:50 +02:00
Marc-André Bombeck dba107de51 fix(vorsorge): add a loading state and align the cards on the shared primitives
The reminder list read isLoading but rendered nothing, so the page
showed a header over blank space while fetching.

- Loading now paints a card-shaped Skeleton stack via the shared
  primitive.
- The no-data case routes through the shared EmptyState with an icon
  and an add action, matching the lab empty.
- Reminder cards title themselves with CardHeader/CardTitle + CardAction
  and the form card drops its pt-6 override, so the card token owns the
  vertical rhythm and the double-pad goes away. The card stays neutral —
  due state still reads only through the discreet badge.
2026-06-15 01:22:57 +02:00
Marc-André Bombeck bc237d61e2 fix(labs): converge the lab tile + loading onto the shared primitives
The lab list shipped a second bespoke compact-tile shape and a
centred Loader2 that disagreed in size with the page spinner above it.

- Lab cards now title themselves with CardHeader/CardTitle and pin the
  sparkline + delete control in CardAction, matching the canonical card
  contract; the py-4 content override is dropped so the card token
  drives spacing.
- Loading paints a tile-shaped Skeleton stack via the shared primitive
  instead of the bespoke spinner, removing the size drift and the
  layout pop-in.
2026-06-15 01:22:50 +02:00
Marc-André Bombeck dad0b89a8c fix(insights): unify device-score tiles onto the shared card and skeleton primitives
Route the recovery and sleep-quality device-score surfaces through the
canonical card and loading primitives so they read as one family:

- DeviceScoreTile titles itself with CardHeader/CardTitle + CardAction
  and drops its bespoke header row and padding override, so it matches
  the sibling sleep-debt and chronotype cards on the unified
  p-4 md:p-6 token.
- Add DeviceScoreTileSkeleton / DeviceScoreGridSkeleton, a shared
  tile-shaped placeholder that mirrors the loaded layout. Recovery and
  sleep-quality paint it while their analytics slice loads instead of
  popping in.
- Recovery's no-data branch now renders the shared EmptyState with an
  icon rather than a hand-rolled div; the sleep-quality sub-section
  keeps its calm collapse-to-null once data lands.
- New insights.recovery.emptyTitle key across all six locales.
2026-06-15 01:22:43 +02:00
Marc-André Bombeck 9f960a59d0 feat(import): show character counts and a Preview busy state
The JSON and CSV paste boxes now cap input at the 16 MB body ceiling the
routes enforce and show a live character count beneath each, so an over-long
paste is bounded with feedback instead of silently rejected at submit. The
CSV Preview button also renders the same spinner the Import button does while
a dry-run is in flight, with motion-reduce honoured, so the tap no longer
looks inert on a slow preview.
2026-06-15 01:21:45 +02:00
Marc-André Bombeck c6beba2229 fix(import): keep the import cards on the SPA and balance the trio
The JSON and CSV cards linked to the in-app docs with a raw anchor, so
tapping the schema link triggered a full-page reload that discarded an
in-progress paste or upload in the sibling card; route them through the
Next Link instead. The three import cards also dropped to a two-column grid
at md, orphaning the CSV card alone on a second row — add a three-column
track at xl so the set reads as one family.
2026-06-15 01:21:35 +02:00
Marc-André Bombeck 5a62d04651 fix(import): use the success token for import success ticks
The three import success check-icons hardcoded text-emerald-500, bypassing
the semantic text-success token every other surface uses. The token is
AA-tuned per theme (a darker green in light mode) where emerald-500 is a
single fixed value, so the import ticks sat off-tone in light mode and could
fall short of the AA bar the light tokens are tuned for. Swap to text-success
to match the integration-card family.
2026-06-15 01:21:10 +02:00
Marc-André Bombeck 7a96a934bf refactor(import): derive the CSV example header from the shared column list
The downloadable CSV example pinned its header as an inline string literal
while csv-measurements.ts exported CSV_EXAMPLE_COLUMNS as the documented
single source of truth for that order — so the two could silently drift.
Derive the example header from the constant; the column order now has one
owner and the previously unused export is wired in.
2026-06-15 01:20:49 +02:00
Marc-André Bombeck a27247f93c merge(settings): reminders hub, uniform integration cards, per-integration setup-guide links 2026-06-15 01:00:43 +02:00
Marc-André Bombeck 78c110a6c8 feat(settings): add a Reminders & Notifications home
Reminders used to be scattered across three places: medication reminders on
each med card, mood / low-stock / coach-nudge inside the Notifications channel
screen, and the Vorsorge reminders on their own page. The new Reminders
section is the single front door. It draws a clean line between the two
concepts — reminder categories (the what/when) and notification channels (the
how/where) — and links to each canonical editor, which keeps its own route and
deep link intact. Consolidation by linking, not a rewrite.

The section sits right after Notifications in the settings nav.
2026-06-15 00:59:47 +02:00
Marc-André Bombeck 001c158203 feat(integrations): add a Setup-guide doc-link to every integration card
Each integration card now carries the same discreet "Setup guide" link to
its runbook under docs.healthlog.dev/integrations/<provider>, so a user who
is mid-setup always knows where to go. The docs host lives in one shared
constant and the affordance renders identically across all six providers
(WHOOP, Withings, Fitbit, Polar, Oura, Nightscout).
2026-06-15 00:59:33 +02:00
Marc-André Bombeck d0ba4b061b feat(notifications): generate VAPID keys from the admin panel
Add a server-side VAPID keypair generator so a self-hoster no longer has
to run a shell command to enable Web Push. A new admin-only route
(POST /api/admin/settings/web-push-vapid/generate) calls web-push's
generateVAPIDKeys(), stores the private key encrypted at rest, seeds a
placeholder mailto: subject the operator can edit, and returns only the
public key. The route is cookie-only via requireAdmin(); a Bearer token
cannot reach it. The private key is never returned and never logged — the
audit detail carries a marker only.

The admin Web Push VAPID card gains a Generate keys button that fills the
public key and subject fields on success. When a keypair already exists
the route refuses with 409 and the card confirms before retrying with
force, warning that regenerating invalidates existing browser
subscriptions.
2026-06-15 00:58:30 +02:00
Marc-André Bombeck 45ccdcb305 merge(notifications): generic webhook + SMTP email channels and operator delivery health 2026-06-15 00:47:16 +02:00
Marc-André Bombeck 17dd10c3b9 feat(admin): operator-wide notification health panel
Surface the webhook + email cards on the notifications settings page, and
add an operator-wide delivery-health view the per-self diagnostic could
not give. A single groupBy(channel, result) over the trailing 24h of
push_attempts lets the operator answer "are anyone's pushes failing?"
across all users without DB shell, and a count of auto-disabled channels
(enabled=false with an automated disabledReason) flags a flapping upstream
that silently disabled a channel after five failures. The aggregate is
anonymous — it counts channel x result, never names a user. Admin-only by
construction (cookie auth, never Bearer); the window is clamped 1..168h.
The panel rides the existing admin Reminders section.
2026-06-15 00:46:21 +02:00
Marc-André Bombeck 909c2aa99e feat(notifications): add SMTP email channel
Alerts and reminders over email — the one channel every self-hoster
already has. The SMTP transport (host/port/auth/from) is operator-supplied
via SMTP_* env and loaded once per process like the APNs key; the only
per-user value is the recipient address, so SMTP credentials never enter a
user's encrypted channel blob. Absent SMTP env disables the channel
cleanly: the dispatcher skips it and the Settings card hides itself.

Bodies are plain text — HTML is stripped and routine reminders run through
the emoji-strip path; no markdown library is added. SMTP 5xx maps to a
hard reject (permanent — auto-disable); connection/4xx errors are
transient and ride the backoff schedule. Adds nodemailer (the standard,
well-audited mail transport) and its types.
2026-06-15 00:46:12 +02:00