Email system rebuild · 2026-05-19
Branded, locale-aware (FR · EN), table-based HTML email pipeline.
Replaces the inline templates that previously lived in a single
src/lib/email.ts file.
What changed
- New directory
src/lib/email/templates/· one file per template. - New shared layout
src/lib/email/templates/base.ts· Sigma navy header with inline SVG logo (Σ wordmark), max-width 640px, mobile media query, factual footer with coverage line, postal address, unsubscribe + copyright. - New helpers
src/lib/email/brand.ts(tokens · button · esc), andsrc/lib/email/locale.ts(inferSignupLocale(req)+normalizeLocale). - Unified sender
src/lib/email/send.tsexposessendTemplate({ to, template, props, locale, userId, kind }). Resolves locale (explicit arg →user.signupLocale→"en"), renders the template, delegates to the legacysendEmail(nodemailer transport unchanged), persists anEmailEventrow whenuserIdis supplied. Sets a defaultReply-Toofsimon.azoulay.pro@gmail.com(overridable viaEMAIL_REPLY_TO). - Schema
User.signupLocale String @default("en")(migration20260519800000_user_signup_locale). Inferred at signup time from the request pathname (/fr/...→"fr"),x-localeheader injected by the proxy, referer, orAccept-Language. - Legacy
src/lib/email.tstransactional senders now thin-wrap the new template renderers and accept an optionallocaleargument. Existing daily / weekly / custom-alert renderers stay in place · they already speak FR · EN. All other call sites have been updated to pass the user'ssignupLocale(or to infer from the request at signup).
Template inventory
| File | Purpose | Localised |
|---|---|---|
src/lib/email/templates/base.ts |
Shared HTML layout (header, footer, mobile media query) | FR · EN |
src/lib/email/templates/welcome.ts |
Welcome (post email-verify) | FR · EN |
src/lib/email/templates/magic-link.ts |
Magic sign-in link (24h) | FR · EN |
src/lib/email/templates/verify.ts |
Email verification (24h) | FR · EN |
src/lib/email/templates/password-reset.ts |
Password reset (1h) | FR · EN |
src/lib/email/templates/upgrade-waitlist.ts |
Upgrade waitlist confirm + follow-up (Pro / Quant) | FR · EN |
src/lib/email/templates/signal-alert.ts |
Single-signal insider buy / sell alert | FR · EN |
src/lib/email/templates/cluster-alert.ts |
Cluster-formation alert (≥3 insiders) | FR · EN |
src/lib/email/templates/watchlist.ts |
Watchlist follow notification | FR · EN |
src/lib/email/templates/delete-confirm.ts |
Account deletion confirmation | FR · EN |
src/lib/email/templates/quota-warning.ts |
Free quota at 80% | FR · EN |
src/lib/email/templates/index.ts |
Public re-export surface | — |
Daily digest, weekly digest and custom alert renderers stay in
src/lib/email.ts and src/lib/alerts/email.ts · they were already
bilingual (or specifically the editorial FR voice for the digests).
Subject lines
| Template | EN | FR |
|---|---|---|
| welcome | Welcome to InsiderTrades Sigma | Bienvenue sur InsiderTrades Sigma |
| magic-link | Your sign-in link · expires in 24h | Votre lien de connexion · expire dans 24h |
| verify | Verify your email · InsiderTrades Sigma | Vérifiez votre adresse · InsiderTrades Sigma |
| password-reset | Reset your password · InsiderTrades Sigma | Réinitialisation de mot de passe · InsiderTrades Sigma |
| upgrade-confirm (Pro) | Request received · Pro waitlist · Sigma | Demande reçue · liste d'attente Pro · Sigma |
| upgrade-confirm (Quant) | Request received · Quant waitlist · Sigma | Demande reçue · liste d'attente Quant · Sigma |
| upgrade-followup | Still on the Pro list · status update · Sigma | Toujours sur la liste Pro · point d'avancement · Sigma |
| signal-alert (BUY) | {insider} just bought {company} ({amount}) |
{insider} vient d'acheter {company} ({amount}) |
| signal-alert (SELL) | {insider} just sold {company} ({amount}) |
{insider} vient de céder {company} ({amount}) |
| cluster-alert | Cluster forming on {company} ({n} insiders) |
Cluster en formation sur {company} ({n} dirigeants) |
| watchlist | {n} new signal(s) · {entity} |
`{n} nouveau(x) signa(l |
| delete-confirm | Account deleted · InsiderTrades Sigma | Compte supprimé · InsiderTrades Sigma |
| quota-warning | Free quota at {pct}% · Sigma |
Quota Free atteint à {pct}% · Sigma |
Locale resolution
explicit `locale` argument
→ user.signupLocale (DB, set at signup)
→ "en" (default site language)
At signup (/api/auth/register, /api/auth/register/magic) the locale is
inferred by inferSignupLocale(req):
- URL pathname prefix (
/fr/auth/register→"fr"). x-localeheader injected bysrc/proxy.ts.refererheader (browser usually sends the page the form was on).- First language in
Accept-Language. - Default
"en".
The resolved value is persisted to User.signupLocale and re-used by all
subsequent transactional and alert emails.
Wiring updated
src/app/api/auth/register/route.ts— persistssignupLocale, passes it tosendVerificationEmail.src/app/api/auth/register/magic/route.ts— persistssignupLocale, uses newrenderMagicLinktemplate (replaces the inline 12-line HTML).src/app/api/auth/verify/route.ts— readsuser.signupLocale, passes tosendWelcomeEmail.src/app/api/auth/forgot-password/route.ts— readsuser.signupLocale, passes tosendPasswordResetEmail.src/app/api/upgrade-request/route.ts— infers locale from request, passes to confirmation email.src/app/api/cron/waitlist-followup/route.ts— readsuser.signupLocaleper row.src/app/api/cron/alerts-realtime/route.ts— readsuser.signupLocale(was hard-coded"fr").src/app/api/cron/alerts-dispatch/route.ts— same fix.src/app/api/admin/send-test-email/route.ts— accepts?locale=fr|enfor previewing both languages.
Live test send
Script scripts/test-emails.ts (npm scripts test:emails:dry,
test:emails:live) renders + sends every template in both locales to a
target inbox.
Run on 2026-05-19 against simon.azoulay.pro@gmail.com:
[test-emails] target=simon.azoulay.pro@gmail.com dry=false only=all
SENT EN welcome Welcome to InsiderTrades Sigma
SENT FR welcome Bienvenue sur InsiderTrades Sigma
SENT EN magic-link Your sign-in link · expires in 24h
SENT FR magic-link Votre lien de connexion · expire dans 24h
SENT EN verify Verify your email · InsiderTrades Sigma
SENT FR verify Vérifiez votre adresse · InsiderTrades Sigma
SENT EN password-reset Reset your password · InsiderTrades Sigma
SENT FR password-reset Réinitialisation de mot de passe · InsiderTrades Sigma
SENT EN upgrade-confirm-pro Request received · Pro waitlist · Sigma
SENT FR upgrade-confirm-pro Demande reçue · liste d'attente Pro · Sigma
SENT EN upgrade-confirm-quant Request received · Quant waitlist · Sigma
SENT FR upgrade-confirm-quant Demande reçue · liste d'attente Quant · Sigma
SENT EN upgrade-followup Still on the Pro list · status update · Sigma
SENT FR upgrade-followup Toujours sur la liste Pro · point d'avancement · Sigma
SENT EN signal-alert-buy Jean Dupont just bought Société TEST (EUR 1.4M)
SENT FR signal-alert-buy Jean Dupont vient d'acheter Société TEST (1.4 M EUR)
SENT EN signal-alert-sell Marie Martin just sold Demo Corp (EUR 3.2M)
SENT FR signal-alert-sell Marie Martin vient de céder Demo Corp (3.2 M EUR)
SENT EN cluster-alert Cluster forming on Cluster Test SA (5 insiders)
SENT FR cluster-alert Cluster en formation sur Cluster Test SA (5 dirigeants)
SENT EN watchlist 3 new signals · LVMH
SENT FR watchlist 3 nouveaux signaux · LVMH
SENT EN delete-confirm Account deleted · InsiderTrades Sigma
SENT FR delete-confirm Compte supprimé · InsiderTrades Sigma
SENT EN quota-warning Free quota at 80% · Sigma
SENT FR quota-warning Quota Free atteint à 80% · Sigma
[test-emails] done · 26/26 ok · 0 failed · 45.5s
26 of 26 sends OK (13 templates × 2 locales). All delivered, all rendered with the same navy header, inline Σ SVG, gold CTA, mobile media query, factual footer.
Validation
npm run lint:emdash→ OK, no em-dashes in user-facing copy.npm run lint:emoji→ OK, 449 files scanned, no disallowed emoji.npm run typecheck→ OK, no errors.npm run build→ OK.npx prisma migrate deploy→2 migration(s) deployed(signup locale + earlier insider-name index).
Constraints honored
- NO em-dashes anywhere · middle-dot (
·) used as separator. - NO emoji · CSS dots,
→and inline SVG only. - Inline CSS throughout (Outlook · Gmail compat).
- Table-based layout, max 640px wide, single media query for mobile.
- Logo is pure inline SVG (no external image, no privacy leak, no spam filter trigger).
- FR uses vouvoiement throughout.
- Reply-To header always set, defaults to
simon.azoulay.pro@gmail.com.