Email

How EventJuicer sends mail, and how delivery events get back into the database.

Verified against the codebase on 2026-09-16.

⛔ The HTTP send API is gone. POST /api/email/send, /api/email/bulk, /api/email/campaigns and /api/email/templates were removed from apps/api-email. Sending is now in-process, through @eventjuicer/email drivers called by Trigger.dev tasks — there is no service to curl. The leftovers are gone too: packages/email/src/client.ts and apps/api-email/proxy.ts were deleted, and apps/api-email/app/page.tsx no longer advertises the send API.


The two halves

SENDING (in-process) TRACKING (inbound HTTP) ──────────────────── ─────────────────────── admin / storefront Resend ──┐ │ triggers SES ──┤ POST ▼ Mailgun ──┤ packages/trigger tasks/email/* ▼ │ createEmailSender(driver, …) apps/api-email ▼ │ verify signature packages/email drivers/{mailgun,resend, │ resolve tenant smtp2go,ses} ▼ │ EmailRecipient ledger ▼ + EmailSuppression provider API

apps/api-email is now a webhook receiver and nothing else, served at email.api.eventjuicer.com. Git deploys are off in its vercel.json (every branch, main included, since 2026-09-17); main ships through .github/workflows/scheduled-deploy.yml (daily) or an on-demand deploy.


Sending

Drivers

Four drivers, one contract (EmailDriver in packages/email/src/send-email.ts): sendEmail, sendBulkEmails, getDailySendLimit, getBulkSendLimit, processWebhook.

Build one with createEmailDriver(name, config) or the thin createEmailSender(name, config) wrapper — never new XDriver().

DriverConfig keysBulk strategyWebhook verification
mailgunapiKey, domain, host, webhookSigningKeynative recipient-variablesHMAC-SHA256
resendapiKey, webhookSecretbatch API (≤100)Svix HMAC-SHA256
smtp2goapiKey, region, webhookSecretper-recipient, paralleloptional shared secret
sesaccessKeyId, secretAccessKey, region, configurationSetName, tenantName, topicArn, maxSendRateper-recipient, pacedSNS message signature (certificate)

Transactional mail (magic links, receipts, notifications) goes through Resend. Campaign mail uses whatever EmailCampaign.metadata.driver names.

Credentials — AccessKey, never process.env

Per repo rule #19, a key that can send mail on an organizer's behalf is a credential. It lives in the AccessKey table, scoped per organizer and per environment.

DriverAccessKey.name
mailgunMAILGUN_API_KEY
resendRESEND_API_KEY
smtp2goSMTP2GO_API_KEY
sesSES_ACCESS_KEY_ID and SES_SECRET_ACCESS_KEY (AWS API credentials, not SMTP credentials)
webhooksRESEND_WEBHOOK_SECRET

Resolve through packages/email/src/server/resolve-email-api-key.ts:

import { requireEmailApiKey } from '@eventjuicer/email/server/resolve-email-api-key'; const apiKey = await requireEmailApiKey('resend', organizerId);

⛔ Check the development row exists. getKey falls back production → development, but never development → production. An organizer with only production rows resolves to null on a laptop, in trigger dev and on any sanitised branch, and the feature is simply dead with no error at the call site. That asymmetry is deliberate: it is what stops a dev machine mailing real attendees. requireEmailApiKey throws a message naming the environment for exactly this reason.

Drivers receive apiKey, they never resolve it. resolve-email-api-key.ts is server-only because getKey reaches @eventjuicer/db, which initialises a Neon client at import time. Keeping resolution at the call site is what lets packages/email be tested without a database.

The credential may also be passed as a thunk (ApiKeySource = string | (() => string | Promise<string>)). Storefronts build emailSender at module scope and ~35 call sites use it synchronously, so the AccessKey read is deferred to the first actual send rather than running during next build.

Orchestration — Trigger.dev

packages/trigger/src/tasks/email/ owns every multi-recipient send:

TaskRole
dispatch-campaignvalidates the campaign, locks it sending, resolves the AudienceGroup, hands off
send-campaign-emailssnapshots campaign + template once, builds ICS and List-Unsubscribe once, fans out chunks
send-email-chunkthe shared per-chunk sender for both lanes
send-bulk-emailsthe bulk (non-campaign) lane
send-admin-messageone-off operator messages
record-email-recipientsledger writes at send time

dispatch-campaign refuses to send when sent_at is set (unless resend), when status is already sending, when tested is false, and when the test fingerprint no longer matches the current subject/template — tested alone was wrong twice, because editing the subject (which lives in metadata, not a column) left it true.

send-campaign-emails snapshots at dispatch: queued chunks send the exact version captured when the campaign started. Template edits made afterwards are not picked up. To change a sending campaign, cancel and re-send.

Per-driver pipeline knobs live in one place, DRIVER_CONFIG in packages/trigger/src/lib/email/settings.ts:

DriverChunk sizeConcurrencyDelay between chunks
mailgun100011 min
resend10021 min
smtp2go10003none
ses501none (per-recipient pacing instead)

resolveDriver(driver) returns those knobs plus the queue name per lane; SES shares one serialized queue across both lanes.

Message tags — the ordering contract

packages/email/src/tag-codec.ts is the single source of truth, and the reason it exists is worth reading before touching a send path.

Senders pass an ordered array: [EMAIL_TAGS.bulkQueue, <sender_id>]. The webhook side reads slot 1 as the sender id. But providers do not preserve array order and two of them do not even return an array — Resend accepts Tag[] on send and returns Record<string, string> on the webhook; SES returns Record<string, string[]>. So the position is encoded in the name: tag0, tag1, …, and readOrderedTags walks those names back into the array.

Two implementations of that format is precisely what produced 971 EmailRecipient rows with zero non-null delivered_at / opened_at / clicked_at / bounced_at. There is now one.

Tag names and values are restricted to [A-Za-z0-9_-], 256 chars, 50 tags max. sanitizeTagPart substitutes rather than rejects — an unreadable tag is a cosmetic loss, a rejected send is not.


Webhooks

Endpoints

RouteUse
POST /api/organizers/{organizer_id}/webhooks/resendPreferred. Tenant comes from the URL
POST /api/organizers/{organizer_id}/webhooks/sesPreferred. SNS notifications
POST /api/webhooks/{driver}Legacy. driver ∈ mailgun, resend, smtp2go, ses
GET /api/healthService + configuration probe

Configure in the provider dashboard as, e.g.:

https://email.api.eventjuicer.com/api/organizers/1/webhooks/ses

Why the scoped URL is preferred: the legacy route derives the tenant from the message's own tags, which means it can only accept a tagged event. A scoped URL also accepts untagged notifications without guessing a tenant from a From domain. Either way nothing is written until that tenant's own secret verifies the unchanged raw body.

⚠️ params is a Promise in Next 16. Not awaiting it is what silently killed this route once: driver was undefined on every request, so SUPPORTED_DRIVERS.includes(undefined) was false and every provider got 400 Unsupported driver: undefined before the body was read. No signature was ever checked, no tag ever parsed. next build did not catch it because apps/api-email is not typechecked in CI.

Verification and tenancy

Both handlers live in packages/api-routes (resend-webhook.ts, ses-webhook.ts) and follow the same order:

  1. Shape. Svix headers present (Resend); valid SNS envelope (SES).
  2. Freshness. Resend rejects a svix-timestamp more than 300s from now.
  3. Tenant. organizer_id from the URL, else resolveEmailSenderContext(tag1). Neither ⇒ 422 telling you to use the scoped URL.
  4. Secret. RESEND_WEBHOOK_SECRET from AccessKey for that organizer and environment (503 if absent) / resolveSesSettings for the SES topic pin.
  5. Signature, against the unchanged raw body.
  6. Cross-tenant guard. A resolved sender belonging to another organizer ⇒ 403.
  7. Record, one ledger upsert per unique recipient address.

resolveEmailSenderContext maps the opaque sender id to { senderType: 'EmailCampaign' | 'AdminMessage', organizerId, eventId }. It matters more than it looks: the old code hardcoded sender_type: 'EmailCampaign' and looked the tenant up in EmailCampaign alone, so every admin-message webhook without a pre-existing row landed at organizer_id: 0. The suppression predicate is organizer-scoped, so a recipient who complained about an admin message would have been mailed again. Sender ids are UUID-shape-checked first — binding an arbitrary string to a uuid column aborts the transaction, which in a webhook is a 500 the provider retries forever.

Normalized event types

WebhookEventType in packages/email/src/types.ts:

TypeMeaning
sentAccepted by the provider
deliveredAccepted by the recipient's mail server
openedOpen pixel fired
clickedA tracked link was followed
bouncedHard bounce
failedProvider gave up (SES: transient Bounce)
complainedMarked as spam
unsubscribedUnsubscribed
delayedSoft bounce / retrying (SES: DeliveryDelay)
scheduledQueued for future send
suppressedBlocked by the provider's suppression list
ignoredInbound or lifecycle event — never count it as a failure

Normalized payload (WebhookEvent):

{ "id": "evt_123456", "type": "delivered", "timestamp": 1234567890000, "messageId": "msg-abc123", "recipient": "user@example.com", "sender": "noreply@example.com", "subject": "Welcome Email", "tags": ["bulk-queue-sent", "550e8400-e29b-41d4-a716-446655440000"], "metadata": {}, "raw": {} }

Recipient addresses are unwrapped from Name <addr@host> form, trimmed, lower-cased and validated before any write.

Responses

StatusMeaning
200 {received:true}Recorded
200 {received:true, ignored:"…"}Understood but not a tracked outbound event, or no attribution
400Malformed JSON / envelope / organizer id
401Missing, expired or invalid signature
403Sender belongs to another organizer
422Unresolvable sender, or invalid recipient address
503Missing webhook secret or SES settings; SNS confirmation failure — retryable
500Write failed; replay is idempotent and safely finishes it

What a recorded event does

recordResendEvent / recordSesEvent upsert into EmailRecipient, preserving the RID, sibling metadata, the earliest timestamp per event kind and the latest status. SES rows start pending; a verified Send stamps sent_at.

Permanent bounces, complaints and unsubscribes additionally project the canonical mailbox into EmailSuppression, which feeds the organizer-scoped suppression predicate used by every subsequent send.


SES specifics

Full setup runbook: docs/amazon-ses-campaign-setup.md.

  • Settings come from the organizer's business-rules bucket via resolve-ses-settings.ts: emails.ses.region, emails.ses.configuration-set, emails.ses.sns-topic-arn, emails.ses.max-send-rate. Missing or invalid configuration blocks campaign sends.
  • emails.ses.tenant-name routes every campaign and bulk send (tests included) through the organizer's AWS SES tenant via SendEmail.TenantName. No env fallback, no retry without the tenant. Blank means legacy non-tenant sending; an invalid name fails before sending. Enforce ses:TenantName in the IAM sending policy so blanking the setting cannot bypass tenant routing.
  • Raw MIME preserves personalization, CC/BCC, attachments, Unicode display names and one-click unsubscribe.
  • SesBulkSendError reports partial batches. Dedup claims are released only for explicit 4xx rejections and unattempted recipients; accepted and ambiguous claims are retained. Rate-limit failures may retry; permanent and ambiguous errors stop for inspection. The SDK does not retry non-idempotent sends.
  • The SNS route requires an exact topic pin and a valid AWS signature. HTTPS certificate fetches are bounded and reject redirects.

Templates

React Email components in packages/email/src/templates/ — not HTML strings.

One deliberate exception: src/server/render-company-activity-digest.ts builds escaped HTML strings. It runs in the multi-tenant Trigger.dev worker where @eventjuicer/email/components cannot load (it imports @eventjuicer/translate, which throws without a single-tenant env), and its copy arrives as data from the Translation table. Do not copy that pattern into anything running inside an app deployment.