E-postplattform
Transactional emails fra Impulse AI sendes gjennom en lagdelt plattform som speiler varslingsplattformen — producers eier hva og når, plattformlaget eier hvordan. Brevo er provider for selve leveransen.
Målgruppe: utviklere som skal legge til ny e-posttype, feilsøke leveringsproblemer, eller forstå suppression-modellen.
Ikke i scope: marketing-newsletter (im-landing eier den essays-listen), push-notifikasjoner (egen plattform), Apple ASC sub-emails (Apple sender automatisk — vi dupliserer ikke).
Arkitekturoversikt
heading.anchorLabelPRODUCERS PLATFORM LAYER(eier HVA og NÅR) (eier HVORDAN)
┌──────────────┐│ Account │ Auth callback│ (welcome) │ Fire-and-forget└──────┬───────┘ │┌──────┴───────┐ ┌─────────────────────────────────────┐│ Email Bus │──►│ Delivery Engine ││ │ │ ││ enqueue │ │ 1. Validate template + Zod params ││ Email() │ │ 2. Suppression check (email_status) ││ │ │ 3. Insert email_messages 'pending' ││ │ │ 4. BullMQ job → worker │└──────────────┘ │ 5. Resolve Brevo template ID │ │ 6. Send via Brevo /v3/smtp/email │ │ 7. Update row to 'sent' │ └────────────┬─────────────────────────┘ │ ▼ ┌─────────────────┐ │ Brevo │ │ Renders + sends│ │ DKIM/SPF/DMARC │ └────────┬────────┘ │ delivery events ▼ ┌─────────────────────────────┐ │ Webhook /webhooks/brevo │ │ │ │ Verify shared-secret token │ │ Update email_messages.status│ │ Flip profiles.email_status │ │ on bounce/spam/unsubscribe │ └─────────────────────────────┘Nøkkelprinsipp: Producers kaller enqueueEmail() med ferdig payload. De vet ingenting om Brevo, BullMQ, eller webhook-events. Delivery engine vet ingenting om hvilken hendelse som trigget e-posten.
Templates
heading.anchorLabelTemplates er utvidbarhetsaksen. Ny e-posttype = ny entry i registeret + ny producer. Bus, engine og webhook-handler er uberørt.
| Template | Formål | Subject | Trigger |
|---|---|---|---|
account.welcome | Velkomst etter første signup | ”Welcome to Impulse AI” | routes/auth/native.ts når isNewUser=true |
Phase 2-kandidater (ikke wired opp): account.deletion_confirmation, account.deletion_complete. subscription.* håndteres av Apple App Store Connect — vi sender ikke disse. quota.warning er besluttet droppet (push + in-app banner dekker det).
Template-registeret
heading.anchorLabelapps/api/src/services/email/template-registry.tsEnkelt TypeScript-objekt med per-template konfigurasjon: env-var-navn for Brevo template-ID, Zod-skjema for params, beskrivelse, og standard-tags. Compile-time type-narrow: TemplateParams[T] gjør at hver call-site får riktige felter for sin template.
export const TEMPLATE_REGISTRY = { 'account.welcome': { brevoTemplateIdEnvKey: 'BREVO_TEMPLATE_ID_ACCOUNT_WELCOME', paramsSchema: z.object({ firstName: z.string().min(1) }), description: 'Welcome email sent on first sign-up', tags: ['account', 'welcome'], },}Hvorfor template-ID via env-var
heading.anchorLabelBrevo issuer separate template-IDs i ulike kontoer (om vi skulle dele opp), og IDene må kunne pekes til ulike templates per miljø om nødvendig. Env-var-indireksjon gjør dette uten kode-deploy.
Email Bus
heading.anchorLabelapps/api/src/services/email/bus.tsEneste interface mellom producers og plattformen. Én funksjon:
enqueueEmail({ template, to, params, userId, metadata?})Bussen:
- Validerer at template eksisterer i registeret
- Validerer params mot Zod-skjema (kompilerings-typecheck + runtime-validering)
- Sjekker
profiles.email_status— skipper hvis ikke'active' - Sjekker
isBrevoAvailable()— skipper med'unconfigured'hvis env-vars ikke satt (dev-konvenens) - Inserter
email_messages-rad med status'pending' - Pusher BullMQ-jobb til
email-delivery-køen - Returnerer
{ messageId, jobId, skippedReason? }
Skip-modus lager fortsatt audit-rad med status='skipped' og årsak — gir komplett trail uten å bryte caller.
Delivery Engine
heading.anchorLabelapps/api/src/services/email/delivery-engine.tsBullMQ-worker som prosesserer jobber fra køen email-delivery:
- Resolver Brevo template-ID fra env-var. Permanent feil → marker rad
'failed', ikke retry. - Send via Brevo
POST /v3/smtp/emailmedtemplateId, sender, recipient, params, tags. - Mark sent — oppdater rad med
brevo_message_id,brevo_template_id,sent_at,status='sent'. - Feilhåndtering:
BrevoApiError.isTransient()→ kast → BullMQ retry med backoff (30s, 120s, 600s, max 3 forsøk)BrevoApiError.isPermanent()→ marker'failed'mederror_message, ikke retryBrevoConfigError→ marker'failed'med årsakbrevo_unconfigured- Ukjente errors → kast (BullMQ retry)
- Permanent failure-handler: ved BullMQ-uttømmet retry, marker raden
'failed'medpermanent_failure: <reason>(catches worker-død så rader ikke henger i'sent'evig).
Køen er konfigurert med priority 5 (batch-tier), concurrency 3, retry-delays [30000, 120000, 600000] ms.
Producers
heading.anchorLabelHver producer er én funksjon som domenekoden kaller direkte. Mønstret er identisk til notifications/producers/ — ingen ny event-bus i API-laget.
Account Producer
heading.anchorLabelapps/api/src/services/email/producers/account-producer.tsexport async function sendWelcomeEmail(userId: string): Promise<void>Henter profiles.email og display_name, ekstraherer firstName (første ord i display_name), og kaller enqueueEmail({ template: 'account.welcome', to, params, userId }). Fire-and-forget — error logges men kastes aldri tilbake til caller (auth-flyt skal aldri blokkeres på e-post-leveranse).
Idempotency håndteres ikke i produceren — caller skal kun trigge ved isNewUser=true. Gjentatte kall ville ført til duplikat-mailer; e-postsporet i email_messages gjør det enkelt å oppdage og rette.
Webhook-håndtering
heading.anchorLabelapps/api/src/services/email/webhooks/brevo-webhook-handler.tsapps/api/src/routes/webhooks/brevo.tsBrevo sender events for hver e-post via webhook (POST /webhooks/brevo). Per miljø (dev/stg/prd) ett distinkt webhook-secret. Auth via shared-token i query-string eller X-Brevo-Token-header — verifisert constant-time.
Events vi abonnerer på
heading.anchorLabeldelivered, hardBounce, softBounce, spam, unsubscribed, blocked, invalid, deferred
Mapping til status-transisjoner
heading.anchorLabel| Brevo event | email_messages.status | Side-effekt |
|---|---|---|
delivered | delivered | delivered_at = now() |
opened | opened | (logges, ikke kritisk) |
hard_bounce / soft_bounce | bounced | profiles.email_status = 'bounced' |
spam | spam | profiles.email_status = 'spam' |
unsubscribed | unsubscribed | profiles.email_status = 'unsubscribed' + newsletter_status = 'unsubscribed' |
blocked / invalid_email | blocked | profiles.email_status = 'bounced' |
Webhook-handleren returnerer alltid 200 etter auth-sjekk er passert — failures under prosessering logges, men retries er ikke ønsket (bursty bounce-events ville amplifisert last).
Hvorfor URL-token og ikke HMAC
heading.anchorLabelBrevo’s transactional webhooks signerer ikke body-innhold. Vi protect-er endpoint via shared-secret (HTTPS gir transport-sikring). En kommende host-rotasjon eller token-rotasjon krever bare oppdatering i Doppler + ny webhook-registrering hos Brevo.
Status-state-maskin
heading.anchorLabelHver email_messages-rad gjennomgår denne livssyklusen:
producer kaller bus.enqueueEmail() ↓ ┌─[ pending ]─┐ │ │ worker │ │ suppression sender │ │ ugyldig email OK │ │ Brevo unconfigured ▼ ▼ [ sent ] [ skipped ] │ Brevo webhook │ ┌────────────┼─────────┬──────────┬────────────────┐ ▼ ▼ ▼ ▼ ▼[delivered] [bounced] [spam] [unsubscribed] [blocked] │ │ (valgfritt) ▼[opened]
permanent error fra worker eller BullMQ DLQ ↓ [ failed ]Statusene delivered, opened, bounced, spam, unsubscribed, blocked, failed er terminal — ingen videre transisjoner.
Suppression
heading.anchorLabelprofiles.email_status (default 'active') bestemmer om en bruker kan motta e-post. Settes automatisk av webhook-handleren ved hard bounce / spam / unsubscribe / blocked.
profiles.email_status text NOT NULL DEFAULT 'active' CHECK (email_status IN ('active','bounced','spam','unsubscribed'))Bus-laget sjekker dette før hver send — hvis ikke 'active' skipper bus med skippedReason='suppressed' og audit-rad får status='skipped'.
Recovery-sti: Det finnes ingen automatisk reset. Hvis Brevo har feilklassifisert en bruker, må admin manuelt sette email_status='active' (planlagt: 90-dagers utløp på 'bounced' eller manuell unblock-CLI).
Database
heading.anchorLabelTabeller
heading.anchorLabelpublic.email_messages ( id uuid PK, user_id uuid → auth.users, template_name text, brevo_template_id integer, brevo_message_id text, to_email text, params jsonb, status text, -- pending|sent|delivered|opened|bounced|spam|unsubscribed|blocked|failed|skipped sent_at timestamptz, delivered_at timestamptz, error_message text, metadata jsonb, created_at, updated_at)
public.profiles ( ..., email_status text DEFAULT 'active' CHECK (email_status IN ('active','bounced','spam','unsubscribed')), newsletter_status text CHECK (newsletter_status IN ('subscribed','dismissed','unsubscribed')), newsletter_subscribed_at timestamptz, newsletter_dismissed_at timestamptz, newsletter_dismissed_at_impulse_count integer)Indekser
heading.anchorLabel(user_id, created_at desc)— per-bruker historikkbrevo_message_id(partial: where not null) — webhook-lookupstatus(partial: where status in ‘pending’,‘sent’) — overvåk in-flightemail_status(partial: where <> ‘active’) — suppression-liste
email_messages har RLS aktivert uten policies → kun service-role-klient (backend) kan lese/skrive. Iosen kan ikke fetche audit-rader direkte.
Brevo-konfigurasjon
heading.anchorLabelSenders
heading.anchorLabelVerifiserte avsendere (alle på domene impulseai.app, DKIM/SPF/DMARC OK):
| Sender | Bruk |
|---|---|
noreply@impulseai.app | Transactional (welcome, deletion) |
accounts@impulseai.app | Reservert konto-relatert (alternativ) |
insights@impulseai.app | Newsletter (Essays) |
Default er BREVO_SENDER_EMAIL=noreply@impulseai.app — overstyres ikke av template-konfig per send.
Webhook-registrering
heading.anchorLabelÉn webhook per miljø, registrert via Brevo API:
| Miljø | URL-mønster | Webhook ID (per Apr 2026) |
|---|---|---|
| Staging | https://api-stg.impulseai.app/webhooks/brevo?token=<STG_SECRET> | 1973808 |
| Prod | https://api.impulseai.app/webhooks/brevo?token=<PRD_SECRET> | 1973816 |
Begge webhooks fyrer for ALLE events fra Brevo-kontoen siden API-nøkkelen deles. Webhook-handler ignorerer ukjente brevo_message_id stille → ingen krysskontaminering.
Template-redigering
heading.anchorLabelTemplates redigeres i Brevo dashboard (https://app.brevo.com/templates/email/edit/<id>). Endringer påvirker neste send umiddelbart — ingen kode-deploy nødvendig. Subject, fra-felt og innhold kan endres fritt; struktur av params er kontraktet ({{ params.firstName }} osv) og må holdes i sync med Zod-skjemaet.
Env-variabler
heading.anchorLabelAlle satt via Doppler, propageres til Railway:
| Variabel | Beskrivelse |
|---|---|
BREVO_API_KEY | Transactional + contacts API-nøkkel |
BREVO_SENDER_EMAIL | Default sender på alle sendinger |
BREVO_SENDER_NAME | ”Impulse AI” |
BREVO_WEBHOOK_SECRET | Shared-token for webhook-auth (unik per miljø) |
BREVO_TEMPLATE_ID_ACCOUNT_WELCOME | Template-ID 3 |
BREVO_NEWSLETTER_LIST_ID | List-ID for essays-listen (newsletter, separat fra dette) |
Alle er optional — manglende vars skipper i stedet for å feile (graceful degradation, dev-konvenens).
Filstruktur
heading.anchorLabelapps/api/src/services/email/├── brevo-client.ts Fetch-wrapper for /v3/smtp/email├── bus.ts enqueueEmail() — eneste public API├── template-registry.ts Map navn → env-var + Zod-skjema├── delivery-engine.ts BullMQ-worker├── errors.ts BrevoApiError, BrevoConfigError├── producers/│ └── account-producer.ts sendWelcomeEmail()├── webhooks/│ └── brevo-webhook-handler.ts Status-loop├── contacts/ Newsletter (separat — se newsletter-spec)│ ├── brevo-contacts-client.ts│ └── newsletter-service.ts└── index.ts Public exports
apps/api/src/routes/webhooks/└── brevo.ts POST /webhooks/brevo (auth + delegering)Notification Privacy Rule
heading.anchorLabelPush-notifikasjoner har en streng regel om at brukerinnhold aldri skal vises (lock screen, notification center). E-post er en mer offentlig kanal:
- E-post indekseres (Gmail-søk, Spotlight)
- E-post videresendes
- E-post lagres permanent på enheter
- E-post leses ofte i åpne kontorlandskap
Regelen utvides for transactional email:
✅ Tillatt:
- Kontonavn/firstName
- Plan-navn (Foundation, Mastery)
- Generelle datoer (sletting planlagt for X)
- App-navn og produkt-tekst
❌ Aldri:
- Brukerens impulse-innhold (titler, beskrivelser)
- Reflektion-tekst eller AI-generert analyse
- Kort-numre eller betalings-detaljer (maskere alltid)
- Tredjeparts personnavn
Legge til ny transactional email
heading.anchorLabelCookbook for fremtidig Phase 2-arbeid:
- Forfatt template i Brevo (Templates → Email templates → Create). Pin variabler som
{{ params.X }}. Hent ID. - Opprett env-var i Doppler × 3 miljøer:
BREVO_TEMPLATE_ID_<NAME>=<id>. - Add to TEMPLATE_REGISTRY (
template-registry.ts):'domain.action': {brevoTemplateIdEnvKey: 'BREVO_TEMPLATE_ID_DOMAIN_ACTION',paramsSchema: z.object({ /* per-template fields */ }),description: '...',tags: ['domain', 'action'],} - Skriv producer-funksjon (
producers/<domain>-producer.ts):- Hent påkrevd brukerdata
- Bygg params-payload
- Kall
await enqueueEmail({...})— fire-and-forget - Log business-event
- Wire call-site i domenekode (én linje):
void sendActionEmail(userId, ...args)
- Test ende-til-ende:
- Send manuelt via Brevo API for å bekrefte template-rendering
- Trigge gjennom call-site i staging
- Verifiser
email_messages.statusflyterpending → sent → delivered
Smoke-test (manuell)
heading.anchorLabel# 1. Send via Brevo API direkte (omgår vår pipeline — sjekker bare leveranse)BREVO_KEY=$(doppler secrets get --project im --config stg BREVO_API_KEY --plain)curl -X POST "https://api.brevo.com/v3/smtp/email" \ -H "api-key: $BREVO_KEY" -H "Content-Type: application/json" \ -d '{ "templateId": 3, "sender": { "name": "Impulse AI", "email": "noreply@impulseai.app" }, "to": [{ "email": "test@example.com" }], "params": { "firstName": "Test" }, "tags": ["smoke-test"] }'
# 2. Sjekk status i email_messagescurl "$SUPABASE_URL/rest/v1/email_messages?to_email=eq.test@example.com&order=created_at.desc&limit=1" \ -H "apikey: $SUPABASE_KEY" -H "Authorization: Bearer $SUPABASE_KEY"
# 3. Sjekk Brevo's event logcurl "https://api.brevo.com/v3/smtp/statistics/events?email=test@example.com&limit=5" \ -H "api-key: $BREVO_KEY"