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/campaignsand/api/email/templateswere removed fromapps/api-email. Sending is now in-process, through@eventjuicer/emaildrivers called by Trigger.dev tasks — there is no service tocurl. The leftovers are gone too:packages/email/src/client.tsandapps/api-email/proxy.tswere deleted, andapps/api-email/app/page.tsxno 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().
| Driver | Config keys | Bulk strategy | Webhook verification |
|---|---|---|---|
mailgun | apiKey, domain, host, webhookSigningKey | native recipient-variables | HMAC-SHA256 |
resend | apiKey, webhookSecret | batch API (≤100) | Svix HMAC-SHA256 |
smtp2go | apiKey, region, webhookSecret | per-recipient, parallel | optional shared secret |
ses | accessKeyId, secretAccessKey, region, configurationSetName, tenantName, topicArn, maxSendRate | per-recipient, paced | SNS 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.
| Driver | AccessKey.name |
|---|---|
mailgun | MAILGUN_API_KEY |
resend | RESEND_API_KEY |
smtp2go | SMTP2GO_API_KEY |
ses | SES_ACCESS_KEY_ID and SES_SECRET_ACCESS_KEY (AWS API credentials, not SMTP credentials) |
| webhooks | RESEND_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:
| Task | Role |
|---|---|
dispatch-campaign | validates the campaign, locks it sending, resolves the AudienceGroup, hands off |
send-campaign-emails | snapshots campaign + template once, builds ICS and List-Unsubscribe once, fans out chunks |
send-email-chunk | the shared per-chunk sender for both lanes |
send-bulk-emails | the bulk (non-campaign) lane |
send-admin-message | one-off operator messages |
record-email-recipients | ledger 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:
| Driver | Chunk size | Concurrency | Delay between chunks |
|---|---|---|---|
mailgun | 1000 | 1 | 1 min |
resend | 100 | 2 | 1 min |
smtp2go | 1000 | 3 | none |
ses | 50 | 1 | none (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
| Route | Use |
|---|---|
POST /api/organizers/{organizer_id}/webhooks/resend | Preferred. Tenant comes from the URL |
POST /api/organizers/{organizer_id}/webhooks/ses | Preferred. SNS notifications |
POST /api/webhooks/{driver} | Legacy. driver ∈ mailgun, resend, smtp2go, ses |
GET /api/health | Service + 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.
⚠️
paramsis a Promise in Next 16. Not awaiting it is what silently killed this route once:driverwasundefinedon every request, soSUPPORTED_DRIVERS.includes(undefined)was false and every provider got400 Unsupported driver: undefinedbefore the body was read. No signature was ever checked, no tag ever parsed.next builddid not catch it becauseapps/api-emailis 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:
- Shape. Svix headers present (Resend); valid SNS envelope (SES).
- Freshness. Resend rejects a
svix-timestampmore than 300s from now. - Tenant.
organizer_idfrom the URL, elseresolveEmailSenderContext(tag1). Neither ⇒422telling you to use the scoped URL. - Secret.
RESEND_WEBHOOK_SECRETfromAccessKeyfor that organizer and environment (503if absent) /resolveSesSettingsfor the SES topic pin. - Signature, against the unchanged raw body.
- Cross-tenant guard. A resolved sender belonging to another organizer ⇒
403. - 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:
| Type | Meaning |
|---|---|
sent | Accepted by the provider |
delivered | Accepted by the recipient's mail server |
opened | Open pixel fired |
clicked | A tracked link was followed |
bounced | Hard bounce |
failed | Provider gave up (SES: transient Bounce) |
complained | Marked as spam |
unsubscribed | Unsubscribed |
delayed | Soft bounce / retrying (SES: DeliveryDelay) |
scheduled | Queued for future send |
suppressed | Blocked by the provider's suppression list |
ignored | Inbound 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
| Status | Meaning |
|---|---|
200 {received:true} | Recorded |
200 {received:true, ignored:"…"} | Understood but not a tracked outbound event, or no attribution |
400 | Malformed JSON / envelope / organizer id |
401 | Missing, expired or invalid signature |
403 | Sender belongs to another organizer |
422 | Unresolvable sender, or invalid recipient address |
503 | Missing webhook secret or SES settings; SNS confirmation failure — retryable |
500 | Write 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-rulesbucket viaresolve-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-nameroutes every campaign and bulk send (tests included) through the organizer's AWS SES tenant viaSendEmail.TenantName. No env fallback, no retry without the tenant. Blank means legacy non-tenant sending; an invalid name fails before sending. Enforceses:TenantNamein 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.
SesBulkSendErrorreports 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.