Impulse AI Docs
Intern dokumentasjon
Hopp til innhold

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.anchorLabel
PRODUCERS 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 er utvidbarhetsaksen. Ny e-posttype = ny entry i registeret + ny producer. Bus, engine og webhook-handler er uberørt.

TemplateFormålSubjectTrigger
account.welcomeVelkomst 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.anchorLabel
apps/api/src/services/email/template-registry.ts

Enkelt 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.anchorLabel

Brevo 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.

apps/api/src/services/email/bus.ts

Eneste interface mellom producers og plattformen. Én funksjon:

enqueueEmail({
template, to, params, userId, metadata?
})

Bussen:

  1. Validerer at template eksisterer i registeret
  2. Validerer params mot Zod-skjema (kompilerings-typecheck + runtime-validering)
  3. Sjekker profiles.email_status — skipper hvis ikke 'active'
  4. Sjekker isBrevoAvailable() — skipper med 'unconfigured' hvis env-vars ikke satt (dev-konvenens)
  5. Inserter email_messages-rad med status 'pending'
  6. Pusher BullMQ-jobb til email-delivery-køen
  7. Returnerer { messageId, jobId, skippedReason? }

Skip-modus lager fortsatt audit-rad med status='skipped' og årsak — gir komplett trail uten å bryte caller.

Delivery Engine

heading.anchorLabel
apps/api/src/services/email/delivery-engine.ts

BullMQ-worker som prosesserer jobber fra køen email-delivery:

  1. Resolver Brevo template-ID fra env-var. Permanent feil → marker rad 'failed', ikke retry.
  2. Send via Brevo POST /v3/smtp/email med templateId, sender, recipient, params, tags.
  3. Mark sent — oppdater rad med brevo_message_id, brevo_template_id, sent_at, status='sent'.
  4. Feilhåndtering:
    • BrevoApiError.isTransient() → kast → BullMQ retry med backoff (30s, 120s, 600s, max 3 forsøk)
    • BrevoApiError.isPermanent() → marker 'failed' med error_message, ikke retry
    • BrevoConfigError → marker 'failed' med årsak brevo_unconfigured
    • Ukjente errors → kast (BullMQ retry)
  5. Permanent failure-handler: ved BullMQ-uttømmet retry, marker raden 'failed' med permanent_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.

Hver 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.anchorLabel
apps/api/src/services/email/producers/account-producer.ts
export 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.anchorLabel
apps/api/src/services/email/webhooks/brevo-webhook-handler.ts
apps/api/src/routes/webhooks/brevo.ts

Brevo 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.anchorLabel

delivered, hardBounce, softBounce, spam, unsubscribed, blocked, invalid, deferred

Mapping til status-transisjoner

heading.anchorLabel
Brevo eventemail_messages.statusSide-effekt
delivereddelivereddelivered_at = now()
openedopened(logges, ikke kritisk)
hard_bounce / soft_bouncebouncedprofiles.email_status = 'bounced'
spamspamprofiles.email_status = 'spam'
unsubscribedunsubscribedprofiles.email_status = 'unsubscribed' + newsletter_status = 'unsubscribed'
blocked / invalid_emailblockedprofiles.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.anchorLabel

Brevo’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.anchorLabel

Hver 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.

profiles.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).

public.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
)
  • (user_id, created_at desc) — per-bruker historikk
  • brevo_message_id (partial: where not null) — webhook-lookup
  • status (partial: where status in ‘pending’,‘sent’) — overvåk in-flight
  • email_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.anchorLabel

Verifiserte avsendere (alle på domene impulseai.app, DKIM/SPF/DMARC OK):

SenderBruk
noreply@impulseai.appTransactional (welcome, deletion)
accounts@impulseai.appReservert konto-relatert (alternativ)
insights@impulseai.appNewsletter (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ønsterWebhook ID (per Apr 2026)
Staginghttps://api-stg.impulseai.app/webhooks/brevo?token=<STG_SECRET>1973808
Prodhttps://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.anchorLabel

Templates 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.anchorLabel

Alle satt via Doppler, propageres til Railway:

VariabelBeskrivelse
BREVO_API_KEYTransactional + contacts API-nøkkel
BREVO_SENDER_EMAILDefault sender på alle sendinger
BREVO_SENDER_NAME”Impulse AI”
BREVO_WEBHOOK_SECRETShared-token for webhook-auth (unik per miljø)
BREVO_TEMPLATE_ID_ACCOUNT_WELCOMETemplate-ID 3
BREVO_NEWSLETTER_LIST_IDList-ID for essays-listen (newsletter, separat fra dette)

Alle er optional — manglende vars skipper i stedet for å feile (graceful degradation, dev-konvenens).

apps/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.anchorLabel

Push-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.anchorLabel

Cookbook for fremtidig Phase 2-arbeid:

  1. Forfatt template i Brevo (Templates → Email templates → Create). Pin variabler som {{ params.X }}. Hent ID.
  2. Opprett env-var i Doppler × 3 miljøer: BREVO_TEMPLATE_ID_<NAME>=<id>.
  3. 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'],
    }
  4. Skriv producer-funksjon (producers/<domain>-producer.ts):
    • Hent påkrevd brukerdata
    • Bygg params-payload
    • Kall await enqueueEmail({...}) — fire-and-forget
    • Log business-event
  5. Wire call-site i domenekode (én linje):
    void sendActionEmail(userId, ...args)
  6. Test ende-til-ende:
    • Send manuelt via Brevo API for å bekrefte template-rendering
    • Trigge gjennom call-site i staging
    • Verifiser email_messages.status flyter pending → sent → delivered

Smoke-test (manuell)

heading.anchorLabel
Terminal window
# 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_messages
curl "$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 log
curl "https://api.brevo.com/v3/smtp/statistics/events?email=test@example.com&limit=5" \
-H "api-key: $BREVO_KEY"

Designdokumenter

heading.anchorLabel