Vai al contenuto
ErmisAI

Transactional and campaign email

Resend, the react-email templates, suppression, and the rules for bulk sending.

Every outgoing message in ErmisAI goes through one provider (Resend), one send function (sendEmail), and one suppression store. There is no second path. The campaign CLI is built on top of the same function, so a bulk send inherits the suppression checks, the postal-address requirement, and the per-recipient List-Unsubscribe headers that a single transactional send gets.

Driving Resend's dashboard broadcast feature directly bypasses all of it — the suppression store, the one-click headers, and the warm-up ramp. docs/ops/email-campaign-runbook.md states plainly that this is not supported.

What actually sends mail

Six react-email templates live in src/emails/. Five have a live call path; one does not.

TemplateHelperCategoryLocaleTrigger
welcome.tsxsendWelcomeEmailtransactionalnewsroom UI localeThe PUT /api/newsroom/profile call that first flips the profile to complete
upsell-upgrade.tsxsendUpsellEmailmarketingnewsroom content localeCrossing 80% or 100% of the monthly AI capacity
erasure-confirmation.tsxsendErasureConfirmationEmailtransactionalerased newsroom's UI localeAfter a GDPR purge transaction commits
launch-campaign.tsxsendCampaign({ kind: 'launch' })marketingper recipientpnpm email:campaign
waitlist-invite.tsxsendCampaign({ kind: 'waitlist' })marketingper recipientpnpm email:campaign
team-invite.tsxsendTeamInviteEmailinviter's content localeNo call site

English subject lines, from messages/en.jsonemails:

  • welcome: "Welcome to ErmisAI, {firstName}"
  • upsell: "You're at €{spentEur} of your {currentPlan} monthly AI capacity - upgrade to {targetPlan}"
  • erasure: "Your data has been erased from ErmisAI"
  • launch campaign: "ErmisAI is live - a month free for newsrooms"
  • waitlist invite: "The first Greek AI newsroom - join the waitlist"

The three transactional triggers in detail

Welcome fires once, on the transition into completeness. src/app/api/newsroom/profile/route.ts:292 computes justCompletedOnboarding; only that branch calls sendOnboardingWelcomeEmail (:308-313). The recipient is the Clerk user's primary email; the greeting name is their first name, falling back to the publication name (route.ts:156-163). The whole helper is wrapped in a bare try/catch (:165-167) — a mail failure must never fail onboarding, and it is not retried.

Upsell is dispatched by dispatchUpsellEmailForThresholdCrossing (src/lib/email/upsell-trigger.ts:36-96) and is narrower than it looks. It returns early unless the plan is individual_free or individual_plus (:11-12), and again unless the subscription is in good standing (:49-53). A Business Pro tenant never receives it. It fires at 80% and 100% only — the 50% threshold produces an in-app notification and no mail. Deduplication is the caller's job: the threshold notifier records each firing at most once per tenant, billing period, and threshold. The whole function swallows its own errors to stderr so the AI metering path is never affected by mail.

Erasure confirmation is sent by sendErasureConfirmationNotices (src/lib/db/data-erasure-repository.ts:621-650) after the purge commits. Recipient addresses and the locale are snapshotted before the purge, because the purge deletes the rows that hold them. Delivery is best-effort per recipient; a failure is logged, never thrown, because the data is already gone.

The team invite template is not wired

sendTeamInviteEmail (src/lib/email/send.ts:229-266) has no caller. Clerk sends the organization invitation natively from createOrganizationInvitation (src/lib/auth/clerk-organization-management.ts:346), and that mail carries the accept link. Calling the ErmisAI helper as things stand would double-send. Using the branded template requires disabling Clerk's own invitation email and threading the Clerk invitation URL through as acceptUrl.

The send pipeline

sendEmail (src/lib/email/send.ts:32-90) runs a fixed order:

Hard-bounce check. isHardBounceSuppressed(to) short-circuits every category, transactional included (:42-48). A permanently undeliverable address is never retried, because retrying it damages sender reputation.

Marketing suppression check. Only for category: 'marketing' (:50-56). A marketing-unsubscribed recipient still receives transactional mail.

Postal address requirement. Marketing sends throw when EMAIL_POSTAL_ADDRESS is unset (:58-60). Transactional sends do not.

Render. The unsubscribe URL is built for the recipient, then the template is rendered twice — HTML and plaintext (:62-65).

Send. from is EMAIL_MARKETING_FROM for marketing and EMAIL_FROM otherwise (:69). Every message, transactional included, carries List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click (:79-82). A Resend-side error is rethrown as Resend error: <message> (:85-87).

The return type is deliberately not a bare id:

type SendEmailResult =
  | { id: string }
  | { id: null; skipped: true; reason: 'marketing_unsubscribed' | 'hard_bounced' }

Callers that care about the difference — the campaign runner does — branch on 'skipped' in result.

src/lib/email/resend.ts:3-5 throws at module load when RESEND_API_KEY is unset. That is why every call site imports @/lib/email/send lazily inside the function body (newsroom/profile/route.ts:154, data-erasure-repository.ts:630, upsell-trigger.ts:78). Adding a top-level import { sendEmail } to a route module makes that whole route unloadable in any environment without the key.

Templates and the shared chrome

Every template wraps its body in EmailBase (src/emails/components/EmailBase.tsx:192-251), which supplies the header wordmark ErmisAI, inlined design tokens (email clients ignore stylesheets), and the footer.

The footer renders, in order: the notice line, the postal address when EMAIL_POSTAL_ADDRESS is set, then Unsubscribe and Privacy links (:228-246). The privacy link defaults to ${origin}/privacy. The default notice is emails.footer.notice"You are receiving this email because you have an ErmisAI account." Both campaign templates override it with a cold-contact notice instead (launch-campaign.tsx:44, waitlist-invite.tsx:56), because that sentence is false for a press list:

You're receiving this one-time invitation because your work in media may make ErmisAI relevant to you. We're not adding you to a list - unsubscribe below to opt out of any further email.

All links resolve through resolveAppOriginForEmails() (src/lib/email/app-origin.ts:6-14): APP_URL, then NEXT_PUBLIC_APP_URL, then the literal https://ermisai.com. A preview deployment with APP_URL set links back to itself; one without it links to production.

Preview templates locally with pnpm email:preview (email dev --dir src/emails). The preview server cannot await the async translator, so src/emails/i18n.ts:45-49 exports a synchronous English previewEmailTranslator for that path.

Locale selection

Email templates render outside the next-intl request context, so they cannot use useTranslations. createEmailTranslator(locale) (src/emails/i18n.ts:28-37) builds a standalone createTranslator scoped to the emails namespace, with one dynamic import per locale (:13-24) so only the sent locale's catalog loads.

There is no English deep-merge on the email path. src/i18n/request.ts merges a non-English catalog over English so a missing key renders English; the email translator loads exactly one file. A key present in messages/en.json but missing from messages/fi.json does not fall back — it hits next-intl's missing-message behaviour inside a rendered email. tests/locale-parity.vitest.ts is the only thing standing between a partial translation and a broken send.

Every helper requires an explicit locale with no default, so a silent English fallback cannot slip in through a missing argument. Which locale each one uses is a deliberate split: mail about the workspace itself (welcome, erasure) follows the UI locale; mail about editorial output and capacity (upsell, team invite) follows the content locale. See Localization system for the two locale lists.

Campaigns resolve a per-recipient locale from the input row through normalizeUiLocale, falling back to the caller's defaultLocale, and cache one translator per locale for the run (src/lib/email/campaign.ts:188, :192-201).

Suppression

Two hash key families in platform_state_hash (src/lib/email/suppression.ts:8-9):

KeyWritten byBlocks
platform:email-suppressions:marketing/unsubscribe POSTmarketing sends only
platform:email-suppressions:bouncesthe Resend webhookevery category

A record is { token, source, createdAt }, where source is one of link, one-click, manual, bounce, or complaint. The manual value is defined but has no writer.

Tokens, not addresses

The field key is base64url(HMAC-SHA256(lowercased, trimmed address)) keyed by EMAIL_UNSUBSCRIBE_SECRET (suppression.ts:44-54). No plaintext address is ever stored. That is also why suppression records survive a GDPR erasure: there is no personal data in them to erase, and re-consenting a bounced address would damage deliverability.

resolveUnsubscribeSecret (:24-38) throws when the secret is unset and either NODE_ENV === 'production' or VERCEL_ENV is set. Locally it falls back to the literal development-email-unsubscribe-secret, so tokens minted in dev never match production records.

Rotating EMAIL_UNSUBSCRIBE_SECRET without preserving the old value silently re-consents everyone who unsubscribed. Lookups derive the token from the address on every check, so a new secret produces a token that matches nothing. The rotation protocol is: move the old value to EMAIL_UNSUBSCRIBE_SECRET_PREVIOUS before setting the new one. buildEmailSuppressionTokenCandidates (:63-76) checks both; new links and new records always use the current secret.

Writes are insert-if-absent

Both writers go through addHashRecordIfAbsent, which is a platform_state_hash insert with ON CONFLICT DO NOTHING (src/lib/platform/shared/runtime-state.ts:367-400). Two unsubscribe clicks, or a bounce webhook racing a one-click unsubscribe, cannot lose a record the way a read-modify-write on a JSON blob would. It also makes webhook replays harmless.

Reads have opposite failure modes

This is the part to remember during a Postgres incident:

  • isMarketingEmailSuppressed fails closed (suppression.ts:148-162). A store error is treated as "suppressed". Marketing mail stops entirely rather than risking a send to someone who opted out.
  • isHardBounceSuppressed fails open (:164-178). A store error is treated as "not bounced", so transactional mail — a welcome, an erasure confirmation — still goes out.

A database outage therefore halts every campaign and every upsell while leaving transactional delivery running.

Nothing prunes suppression records

/api/admin/state/cleanup prunes only explicitly listed key families and calls this out in a comment (src/app/api/admin/state/cleanup/route.ts:18-24). There is no admin surface that lists, adds, or removes a suppression — the only two writers are the unsubscribe route and the Resend webhook, and there is no reader outside sendEmail. Inspecting or clearing a suppression means querying platform_state_hash directly, and you can only confirm a specific address by recomputing its token.

The unsubscribe route

src/app/unsubscribe/route.ts, allow-listed anonymous in src/proxy.ts:139 as /unsubscribe(.*). It sits outside [locale], so it has no locale prefix and its page copy is hardcoded English.

GET renders a confirmation page and records nothing (:84-103). This is not an oversight. A GET with a side effect gets fired by link prefetchers and mail-scanner sandboxes such as Outlook SafeLinks, which on a large campaign silently unsubscribes recipients who never clicked. The page title is Unsubscribe from ErmisAI emails and it posts back to /unsubscribe?token=…&via=form.

POST performs the suppression (:105-158):

  • Rate-limited on the email:unsubscribe key (10/60s per IP) before anything is persisted. The route is unauthenticated by design under RFC 8058, and any well-formed token writes a durable row, so the limit is bloat protection rather than access control.
  • The token must match ^[A-Za-z0-9_-]{32,128}$ (suppression.ts:78-82), otherwise 400.
  • ?via=form selects HTML responses and records source: 'link'. Without it — the RFC 8058 one-click path — the response is a bare 204 and the source is 'one-click'.
  • A store failure returns 500 with the fallback advice to use the reply-to address.

The Resend webhook

POST /api/webhooks/resend is public in middleware (src/proxy.ts:142) and verifies itself. Full receiver detail sits in Inbound webhooks; the email-specific behaviour is:

  • Verification is the Svix scheme implemented by hand, without the dependency (src/app/api/webhooks/resend/route.ts:53-88): HMAC-SHA256 over ${svix-id}.${svix-timestamp}.${rawBody} with the base64-decoded secret (the whsec_ prefix is stripped), timing-safe comparison across the space-delimited version,signature list, and a ±5 minute timestamp tolerance (:17).
  • Missing RESEND_WEBHOOK_SECRET503. Empty body → 400. Bad signature → 401. Unparseable payload → 400.
  • email.complained records source complaint. email.bounced records source bounce only when data.bounce.type is permanent — a missing type defaults to permanent, and a transient bounce is deliberately ignored because the address may recover (:98-110).
  • Any other event type is acknowledged with { received: true, suppressed: 0, eventType }.

Unlike the Clerk and Polar receivers, this route keeps no idempotency ledger. It does not need one: the suppression insert is insert-if-absent, so a redelivered event is a no-op.

Sender identities and DNS

src/lib/email/resend.ts resolves three values:

VariableDefaultUsed for
EMAIL_FROMErmisAI <noreply@ermisai.com>transactional sends
EMAIL_MARKETING_FROMfalls back to EMAIL_FROMmarketing sends
EMAIL_REPLY_TOhey@ermisai.comreplyTo, and the mailto: half of List-Unsubscribe

The reason for the split is reputation isolation. Mailbox providers track reputation per domain. A cold blast from @ermisai.com couples campaign bounce and complaint rates to the domain carrying Clerk verification links and billing receipts, so a bad send stops new users receiving their verification mail.

EMAIL_MARKETING_FROM falling back to EMAIL_FROM is silent. The campaign CLI runs happily with it unset — straight from the transactional domain, which is exactly what the split exists to prevent. Check it before a live send.

docs/ops/email-campaign-runbook.md specifies news.ermisai.com as a separate domain in Resend (EU region), with four DNS-only Cloudflare records on the ermisai.com zone:

TypeNameValue
MXsend.newsfeedback-smtp.<region>.amazonses.com, priority 10
TXTsend.newsv=spf1 include:amazonses.com ~all
TXTresend._domainkey.newsthe DKIM key, copied verbatim from Resend
TXT_dmarc.newsv=DMARC1; p=none; rua=mailto:dmarc@ermisai.com

The exact SES feedback host and DKIM key depend on the Resend account region — mirror the Add Domain screen rather than the table. Then set EMAIL_MARKETING_FROM="ErmisAI <news@news.ermisai.com>".

Sending a campaign

# Validate the list and see the plan, sending nothing
pnpm email:campaign --campaign waitlist --input press-list.csv --day 1 --dry-run

# Day 1: the ramp allows 50
pnpm email:campaign --campaign waitlist --input press-list.csv --day 1

# Day 2: same command, resumes from the progress file, allows 100 more
pnpm email:campaign --campaign waitlist --input press-list.csv --day 2

pnpm email:campaign runs as node --env-file=.env.production --import tsx scripts/email/send-campaign.ts (package.json:26). It loads .env.production, not .env, and the script loads no env file of its own. RESEND_API_KEY and EMAIL_POSTAL_ADDRESS missing from that specific file make every send fail.

Flags

All parsing is in scripts/email/send-campaign.ts:57-110.

FlagDefaultNotes
--campaign <launch|waitlist>waitlistSelects template, copy namespace, CTA, and Resend tag
--input <file>Required. JSON array of {email, firstName?, locale?}, or CSV with an email header (optional firstName/name, locale)
--promo <code>Required for --campaign launch only; the waitlist invite renders no code panel
--cta <url>per campaignOverrides the default CTA
--day <n>1Warm-up day index; resolves the ceiling from the ramp
--cap <n>Explicit ceiling, overrides --day
--throttle <ms>1500Delay between sends
--locale <loc>elLocale for recipients whose row has none
--progress <file><input>.<campaign>.sent.txtResume file
--dry-runoffResolves and counts, sends nothing, writes no progress

The warm-up ramp

DEFAULT_WARMUP_RAMP is [50, 100, 250, 500, 1000, 2000, 3000] (src/lib/email/campaign.ts:102). resolveWarmupDailyCap clamps the day index to the last entry, so day 9 is still 3000 (:104-113).

Day1234567+
Ceiling50100250500100020003000

A fresh subdomain has no sending history; 3000 messages in one burst trips filters and can get the domain throttled. When a run hits the ceiling it prints the exact command to continue tomorrow.

What the runner does per recipient

sendCampaign (src/lib/email/campaign.ts:129-275) normalizes and lowercases each address, drops anything failing a basic format check, de-duplicates, then removes addresses already in the progress file — all before counting against the daily cap, so the ceiling counts real unique sends (:163-190). It then walks the queue serially, one provider call at a time, sleeping throttleMs between recipients.

  • A send that returns skipped is counted as suppressed, not sent.
  • A throw is caught, counted as failed, and recorded in report.failures — one bad address never aborts the run.
  • The onHandled callback fires for sent, suppressed, and dry-run outcomes, and never for a failure. The CLI writes only sent and suppressed to the progress file (send-campaign.ts:249-254), so transient failures are retried on the next run while successes and opt-outs are not re-sent.

The report printed via console.table carries totalInput, uniqueValid, attempted, sent, suppressed, failed, skippedAlreadyHandled, skippedInvalid, reachedDailyCap, and failures.

Campaign CTAs

Defaults come from resolveDefaultCtaUrl (campaign.ts:46-50): the waitlist invite points at ${origin}/${locale}/waitlist and the launch campaign at ${origin}/sign-up. The waitlist target is locale-prefixed on purpose — a bare /waitlist redirects through the proxy, and an extra hop on a cold-send link costs conversions. Note that /{locale}/waitlist itself redirects to /{locale}/sign-up whenever waitlist mode is off, so check the flag before sending that campaign.

The launch campaign's --promo value is a Polar 100%-off code. It is not wired into the application — it exists only in Polar and is redeemed at checkout. The app has no knowledge of it, so nothing validates that the code exists or is actually 100% off before you mail it to a press list.

Where the runbook drifts from the CLI

docs/ops/email-campaign-runbook.md predates the second campaign kind. Its examples omit --campaign entirely, and it describes the progress file as <input>.sent.txt. The script defaults --campaign to waitlist (send-campaign.ts:198) and writes <input>.<campaign>.sent.txt (:219) so that a recipient who received the waitlist invite is still reachable by the launch campaign later. Follow the CLI. The runbook's §5 lawful-basis assessment and §6 go/no-go checklist are still the authoritative process for a cold send, including the explicit residual-risk note about Greek Law 3471/2006 Art. 11.

Configuration

VariableWhen unset
RESEND_API_KEYsrc/lib/email/resend.ts throws at module load; any module that imports it eagerly fails to load
RESEND_WEBHOOK_SECRET/api/webhooks/resend returns 503; bounces and complaints are never suppressed
EMAIL_FROMDefaults to ErmisAI <noreply@ermisai.com>
EMAIL_MARKETING_FROMSilently falls back to EMAIL_FROM — marketing sends from the transactional domain
EMAIL_REPLY_TODefaults to hey@ermisai.com
EMAIL_POSTAL_ADDRESSMarketing sends throw; the footer omits the address block
EMAIL_UNSUBSCRIBE_SECRETThrows in production or whenever VERCEL_ENV is set; dev uses a literal fallback secret
EMAIL_UNSUBSCRIBE_SECRET_PREVIOUSOnly needed during rotation — see the rotation callout above
APP_URL / NEXT_PUBLIC_APP_URLUnsubscribe and CTA links fall back to https://ermisai.com

Three of these are launch-critical and are checked by collectLaunchConfigStatus (src/lib/platform/launch-readiness.ts:42-61) under the email category: RESEND_WEBHOOK_SECRET, EMAIL_POSTAL_ADDRESS, and EMAIL_UNSUBSCRIBE_SECRET. They appear in GET /api/health under config.entries and config.missing, and are printed once to stderr at boot in deployed environments. config.ready does not affect the health check's 200/503 — it is informational. See Deploying to production.

.env.example:62-76 is the fullest local reference for this block; README.md:60-63 names only five of the nine variables.

Adding a template

Add the copy under the emails namespace in messages/en.json, then propagate it to all ten catalogs. Because the email translator does not deep-merge English, a missing key is a rendering failure rather than an English fallback, and the parity gate will fail CI first.

Create src/emails/<name>.tsx wrapping EmailBase. Pass preview, locale, t, and unsubscribeUrl. Override footerText only if the default account-holder notice would be untrue for the recipient.

Add a typed helper in src/lib/email/send.ts that takes the locale explicitly with no default, builds the subject from the translator, and sets tags (existing sends use type plus locale).

Choose the category deliberately. marketing gets suppression checks and the postal-address requirement; transactional does not. Choose the locale source deliberately too: workspace-facing mail follows the UI locale, editorial-output-facing mail follows the content locale.

Import the helper lazily at the call site (await import('@/lib/email/send')), and decide explicitly whether a send failure should propagate. Every existing trigger swallows it.

Preview with pnpm email:preview. For a bulk send, extend CampaignKind in src/lib/email/campaign.ts:35 rather than writing a second runner — the warm-up, suppression, and resume logic is campaign-agnostic.

What does not exist

  • Email alert delivery is hard off. ALERT_EMAIL_DELIVERY_ENABLED is a module-level const … = false at src/lib/platform/alerts/index.ts:25 with no env var, flag, or toggle. resolveRuleChannels can therefore never yield the email channel, and the product says so on screen: "Email digests are not available yet. Digest-mode rules deliver in-app until email delivery ships." Alert rules deliver in-app, and to a webhook when one is configured.
  • There are no notification preferences. No per-type toggle, no digest cadence, no mute. The only opt-out a user has is the marketing unsubscribe link, which does not affect transactional mail.
  • No mail is sent at the 50% capacity threshold, and none for webhook failures, integration changes, or editorial events.
  • No suppression admin UI. No list, no manual add, no removal.
  • No delivery-status tracking in the product. Opens, clicks, and delivery state live in the Resend dashboard; ErmisAI stores only the two suppression lists.
  • No queue or retry for outbound mail. Every send is a direct provider call at request time. A transient Resend failure is lost for transactional sends, and retried only on the next campaign run for bulk sends.

In questa pagina