How Symbol's email subsystem classifies, routes, and delivers every outgoing message — and how to extend it with a new provider.
Symbol routes all outgoing email through a single funnel (sendEmail) that enforces classification, evaluates suppression rules, logs every decision, and delegates to a pluggable provider. This page describes the taxonomy, configuration, and how to add a new provider.
Category taxonomy
Every outgoing email is assigned exactly one category. The category determines the recipient audience, the suppression policy, and which provider handles delivery.
| Category | Audience | Critical | Examples |
|---|---|---|---|
transactional | End user | Yes | Password reset, email verification, MFA codes, billing receipts, team invitations |
marketing | End user (opt-in) | No | Newsletters, product launches |
operational | Admin / staff | No | New-signup ping, support-form submission, abuse reports, payment-failed alerts |
Critical means a suppression-rule match is overridden in production and the email is sent anyway (plus an audit entry is recorded). Non-critical emails respect suppression rules normally.
Not yet implemented:
lifecycle(behavior-driven user drip campaigns) andsystem(batchable ops digests) are intentionally deferred until they have real senders. Adding them later is a small, isolated change.
Configuration
Provider selection
Provider selection follows this precedence for each category:
EMAIL_PROVIDER_<CATEGORY> → EMAIL_PROVIDER → "resend"For example, to route all operational mail through Loops:
EMAIL_PROVIDER_OPERATIONAL=loopsTo set a global default other than Resend:
EMAIL_PROVIDER=console # dev / staging — logs to stdout, no real deliveryEnvironment variables
| Variable | Required | Description |
|---|---|---|
RESEND_API_KEY | Yes (prod, if using resend) | API key for the Resend provider. Missing in production → startup error. Missing in dev → falls back to the console provider. |
LOOPS_API_KEY | Yes (prod, if using loops) | API key for the Loops provider. Same missing-key behaviour as Resend. |
EMAIL_FROM | No | Sender address used by all providers. Defaults to Symbol <noreply@symbol.chat>. |
EMAIL_PROVIDER | No | Global provider fallback. Defaults to resend. |
EMAIL_PROVIDER_TRANSACTIONAL | No | Per-category override for transactional. |
EMAIL_PROVIDER_MARKETING | No | Per-category override for marketing. |
EMAIL_PROVIDER_OPERATIONAL | No | Per-category override for operational. Set to loops to route admin notifications through Loops. |
ADMIN_NOTIFICATION_EMAIL | No | Recipient for operational emails that have no explicit to (e.g. admin signup notifications). Unset → those notifications are silently skipped. |
Dev / preview behaviour
When the required API key for a provider is absent and NODE_ENV !== "production", the provider transparently falls back to the console provider, which logs the message to stdout without attempting real delivery. This means you can run locally or in preview without configuring any email credentials.
Loops provider
The Loops provider supports two send modes:
Raw send (default): posts subject + HTML to the Loops /api/v1/email endpoint. No Loops dashboard setup required — the PR ships without any hardcoded template IDs.
Template send: when a providerHints.loops.transactionalId is supplied, the provider calls /api/v1/transactional with the template ID and optional dataVariables. Providers that don't understand a hint ignore it.
await sendEmail(
"ops@example.com",
{
subject: "New signup",
html: "<p>Hello</p>",
providerHints: {
loops: {
transactionalId: "tmpl_abc123",
dataVariables: { name: "Alice" },
},
},
},
"operational",
);The Loops provider enforces a 5-second hard timeout on every request via AbortController. A timeout or non-2xx response throws a structured error that callers can catch.
How to add a provider
-
Implement the
EmailProvidercontract inpackages/server/src/lib/email/providers/<name>.ts:import type { EmailProvider, EmailMessage } from "../types"; export function createMyProvider(apiKey: string): EmailProvider { return { name: "myprovider", async send(message: EmailMessage): Promise<void> { // deliver message.html to message.to }, }; } -
Add
"myprovider"to theProviderNameunion intypes.ts:export type ProviderName = "resend" | "loops" | "console" | "myprovider"; -
Register one entry in
PROVIDER_REGISTRYinconfig.ts:myprovider: () => { const key = process.env.MYPROVIDER_API_KEY; if (!key) { if (process.env.NODE_ENV === "production") throw new Error("..."); return createConsoleProvider(); } return createMyProvider(key); },
That's all. No changes to the resolver, funnel, or suppression layer are needed. Point a category at the new provider via EMAIL_PROVIDER_<CATEGORY>=myprovider and it's live.
Suppression and admin addresses
The suppression layer evaluates every outgoing email before the provider is called. In production, only rules explicitly marked productionSafe can fire. The only productionSafe rule matches the symbol.local synthetic TLD — a domain that cannot belong to a real inbox.
Real admin addresses (e.g. ops@symbol.chat) don't match any production-active rule, so operational emails to them are never suppressed in production. No special allowlist or bypass is needed for staff addresses.
For complete suppression docs, see the source at packages/server/src/lib/email/suppression/.