Skip to main content

Email

Email is sent through the EmailProvider port, implemented by the Resend adapter (packages/adapters/src/resend.ts). Invitation orchestration is the email branch of DispatchService.invite() (api/src/domains/dispatch.ts).

The Resend adapter

makeResendEmail(cfg) sends through the official Resend SDK (resend, Workers-native). prepare() resolves the verified sender before a durable caller checkpoints the provider request. send() posts the message via resend.emails.send(...), forwards the stable idempotency key and cancellation signal, and returns { messageId, status: 'queued' }; an error response throws so the caller can reconcile or retry safely. Invitations carry both kind: 'survey_invitation' and the opaque voc_delivery intent token used to correlate an early provider receipt.

const resend = new Resend(cfg.apiKey);
const { data, error } = await resend.emails.send(
{
from,
to: msg.to,
subject: msg.subject,
html: msg.html /* …text, tags… */,
},
{ idempotencyKey: msg.idempotencyKey, signal },
);
if (error) throw new Error(`resend send failed: ${error.name}: ${error.message}`);
return { messageId: data?.id ?? '', status: 'queued' };

parseWebhook() handles inbound delivery events — see Webhooks for the Svix signature verification and fail-closed behaviour.

Sending an invitation

When channel === 'email', invite():

  1. Reserves privacy and compliance. The whole operation runs behind the tenant's shared outbound-privacy lock. Suppression, unsubscribe, validation, send-window and frequency rules are checked before the contact reservation is committed.

  2. Records the intent. emailTemplates.recordSend(surveyId, { recipient, recipientHash, agentPublicId, customData, idempotencyKey }) returns a tracking token. Replaying the same key returns the same send row; the send carries the recipient and agent for the activity dashboard and attribution.

  3. Builds the links. Given a public base URL:

    • surveyUrl = ${base}/s/${surveyId}${agentQuery} — the direct runtime URL.
    • link = ${base}/public/track/c/${token} — the click-tracked redirect.
    • pixel = a hidden <img> pointing at ${base}/public/track/o/${token} — the open pixel.

    The agentQuery carries ?agent=<id> plus any cd.<key>=<value> custom data.

  4. Renders and checkpoints the exact request. The template (or default body) is rendered with {{link}}, {{surveyUrl}}, {{surveyId}} and mapped variables. For a durable caller, the provider-ready message is persisted first-write-wins before any external request, so a retry cannot render different bytes.

  5. Sends once and records acceptance. The provider call uses the stable idempotency key and a bounded cancellation signal. markSent(token, messageId) then binds the provider id, creates a PII-free survey_delivery, and transactionally stages any subscribed survey.sent notification/webhook work. Queue messages are wake-ups only; the 15-minute recovery sweep repairs lost wakes and the narrow accepted-before-checkpoint window.

const token = await ports.db.forTenant(ctx).transaction((r) =>
r.emailTemplates.recordSend(surveyId, {
recipient: to,
recipientHash: hash,
agentPublicId,
customData,
idempotencyKey,
}),
);
const link = base ? `${base}/public/track/c/${token}` : surveyUrl;
const pixel = base
? `<img src="${base}/public/track/o/${token}" width="1" height="1" alt="" style="display:none">`
: '';
const vars = { link, surveyUrl, surveyId: String(surveyId), ...(opts.vars ?? {}) };

Templates

Templates are CRUD-managed by EmailTemplateService (api/src/domains/email-template.ts) with fields name, subject, html, text, plus the bulk-email depth fields fromName and logoUrl (legacy BulkEMailBLL.BuildEmail parity). The substitution is a case-insensitive {{ key }} replacement:

function renderTemplate(tpl: string, vars: Record<string, string>): string {
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, k) => vars[k] ?? vars[k.toLowerCase()] ?? '');
}

The open pixel is appended to the rendered HTML. With no template, the default copy embeds {{link}} as a "start the survey" anchor. The service also exposes stats() (the per-recipient delivery breakdown), recipientActivity(), and recipientActivityCsv().

When a template sets fromName, the dispatch email branch passes it as EmailMessage.fromName; the Resend adapter keeps the verified sender address from config and only swaps the display name (so a template can rebrand the From without spoofing an unverified domain). logoUrl, when set, is rendered as a <img> header prepended to the HTML body.

Per-survey send toggle

A survey can be configured to NOT send invites via theme.sendEnabled (stored in the survey theme/config JSON — no migration). undefined/true keeps sending (back-compat); an explicit false makes the dispatch email branch skip the send, returning { status: 'suppressed', reason: 'sending disabled for this survey' }. Existing tracked links still resolve — only new invites are gated.

For one-click capture (legacy LINK1..LINK5), the renderer exposes {{LINK1}}..{{LINK5}} (and the lowercase {{link1}}… spelling). Each token is a survey link that pre-answers the first question — so an email can render a row of one-click rating buttons. Tokens are emitted only when the first question is a discrete-choice type (single / nps / rating); a non-seedable first question (text / matrix / recording) emits no tokens. LINK<n> carries the first question's Nth real option value (capped at its first 5 options), encoded as a1=<value> — deriving from the actual options, not an arbitrary 1..N, means a token can never carry an out-of-range value. The link format is the click-tracked redirect ${base}/public/track/c/${token}?a1=<value>; the click route forwards a whitelisted a1=<value> param onto the runtime (/s/:id?a1=<value>), and SurveyCompletionPage seeds the first question's answer from it.

Open & click tracking

Two public, unauthenticated endpoints in api/src/routes/badges.ts:

  • GET /public/track/o/:token — stamps opened_at (trackEmailOpen) and returns a 43-byte transparent GIF. Always 200, even for an unknown token.
  • GET /public/track/c/:token — stamps clicked_at (trackEmailClick), then 302-redirects to ${origin}/s/${survey} carrying the recorded ?agent= attribution and cd.<key>= custom data. An unknown token redirects to /.
const hit = await c.var.ports.db.trackEmailClick(c.req.param('token')).catch(() => null);
if (!hit) return c.redirect(`${origin}/`, 302);
const params = new URLSearchParams();
if (hit.agentId) params.set('agent', hit.agentId);
for (const [k, v] of Object.entries(hit.customData ?? {})) params.set(`cd.${k}`, v);
return c.redirect(`${origin}/s/${hit.survey}?${params}`, 302);

Delivery receipts → breakdown + suppression

POST /public/email/events receives Resend delivery events and feeds the per-recipient delivery taxonomy:

EventDB callEffect
bouncedhandleEmailBounce(id, reason, at, intentToken)stamps bounced_at/bounce_reason + suppresses the recipient
complainedhandleEmailComplaint(id, reason, at, intentToken)stamps complained_at/complaint_reason + suppresses the recipient (a spam complaint is a hard opt-out)
deliveredhandleEmailDelivered(id, at, intentToken)stamps delivered_at (no suppression) so sent-but-not-delivered is distinguishable from delivered

The signed callback is durably ingested before it is acknowledged. It can correlate by provider message id or by the opaque voc_delivery token, so a callback that beats markSent() is retained and reconciled once the binding exists. The system receipt ledger stores only the provider identity, intent token, canonical target digest and normalised status; it never stores the address or the provider's raw reason.

Bounce and complaint suppression can still be committed from the retained digest after respondent PII has been scrubbed. Source projection is deliberately withheld while a subject-erasure job is active, or if the live source digest/provider binding no longer matches. A bounded recovery pass rechecks those conditions behind the privacy lock before materialising survey_delivery; duplicate callbacks and retries are idempotent. Persistence failure returns 503 with Retry-After, allowing Resend's at-least-once webhook delivery to retry. See Webhooks.

Per-recipient delivery breakdown

stats() returns { sent, delivered, opened, clicked, bounced, complained, unsubscribed }, and recipientActivity() returns one row per send with each state's timestamp plus a folded-in unsubscribed flag and a headline status (severity order, last wins: unsubscribed > complained > bounced > clicked > opened > delivered > sent). Unsubscribes are joined in by the send's recipient_hash (written at send time) against compliance.unsubscribed — the opt-out list stores only the hash, so no PII leaves the compliance tables. GET /v1/email-activity.csv?survey=<id> exports the same breakdown as a tenant-scoped CSV (RLS via db.forTenant).