Symbol Docs
Features

Email

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.

CategoryAudienceCriticalExamples
transactionalEnd userYesPassword reset, email verification, MFA codes, billing receipts, team invitations
marketingEnd user (opt-in)NoNewsletters, product launches
operationalAdmin / staffNoNew-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) and system (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=loops

To set a global default other than Resend:

EMAIL_PROVIDER=console   # dev / staging — logs to stdout, no real delivery

Environment variables

VariableRequiredDescription
RESEND_API_KEYYes (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_KEYYes (prod, if using loops)API key for the Loops provider. Same missing-key behaviour as Resend.
EMAIL_FROMNoSender address used by all providers. Defaults to Symbol <noreply@symbol.chat>.
EMAIL_PROVIDERNoGlobal provider fallback. Defaults to resend.
EMAIL_PROVIDER_TRANSACTIONALNoPer-category override for transactional.
EMAIL_PROVIDER_MARKETINGNoPer-category override for marketing.
EMAIL_PROVIDER_OPERATIONALNoPer-category override for operational. Set to loops to route admin notifications through Loops.
ADMIN_NOTIFICATION_EMAILNoRecipient 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

  1. Implement the EmailProvider contract in packages/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
        },
      };
    }
  2. Add "myprovider" to the ProviderName union in types.ts:

    export type ProviderName = "resend" | "loops" | "console" | "myprovider";
  3. Register one entry in PROVIDER_REGISTRY in config.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/.

On this page