# Email Delivery

Transactional email transports, templates, and the Cloudflare mail worker


How erxes delivers outbound mail and turns inbound mail into inbox conversations.

There is no standalone Node "mail worker" service: inbound mail is handled by a **Cloudflare Email Worker** (`cloudflare/mail-worker`) plus the Frontline mail-integration module (see [Channel Integrations](https://erxes.io/docs/frontline-integrations)), and outbound mail goes through the shared `erxes-api-shared` email pipeline called by Core API, Broadcast, and the automations service.

## Outbound path

`deliverEmail` in [erxes-api-shared/src/utils/email/service.ts](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/backend/erxes-api-shared/src/utils/email/service.ts) is the single send path. `resolveProvider` picks a transport from organization config (read via `loadEmailProviderConfig`, cached by config fingerprint):

| `DEFAULT_EMAIL_SERVICE` | Provider | Config codes |
| --- | --- | --- |
| `SES` (default) | `SesEmailProvider` (AWS SES) | `AWS_SES_ACCESS_KEY_ID`, `AWS_SES_SECRET_ACCESS_KEY`, `AWS_REGION`, `AWS_SES_CONFIG_SET` |
| `sendgrid` | `SendgridEmailProvider` | `SENDGRID_API_KEY`, `SENDGRID_SUBUSER` |
| `custom` | `SmtpEmailProvider` | `MAIL_SERVICE`, `MAIL_HOST`, `MAIL_PORT`, `MAIL_USER`, `MAIL_PASS` |

Every send optionally flows through two ports defined in [core-api/src/utils/email/ports.ts](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/backend/core-api/src/utils/email/ports.ts):

- **Delivery log**: `EmailDeliveries` records move `queued` → `sent`/`failed` via the `emailDeliveries.create`/`recordHandoff` tRPC procedures, capturing message id, provider response, and rejected recipients.
- **Suppression**: `emailSuppression.blocked` filters suppressed recipients before the provider call.

Callers:

- `sendEmail` (`core-api/src/utils/email/index.ts`): transactional mail; applies `COMPANY_EMAIL_TEMPLATE`/`TYPE` Handlebars templates, `COMPANY_EMAIL_FROM`, sender alignment (`resolveAlignedFrom`/`alignSender`), and attachments.
- Broadcast worker (`modules/broadcast/worker/email.ts`): bulk sends in chunks of 50 with a 2-second gap, unsubscribed-customer filtering, postal-address and unsubscribe links (`utils/email/links.ts`), and an 80%-failure abort. See [Broadcasts & Notifications](https://erxes.io/docs/core-broadcast-notifications).
- Automations (`services/automations/.../emailAction/sendEmails.ts`): the send-email workflow action uses the same `deliverEmail`.
- Auth (`modules/auth/utils.ts`): SaaS magic links go out through SendGrid directly.

Sender addresses are picked per tenant: SaaS uses the organization fallback email; OS uses `COMPANY_EMAIL_FROM` when set (`resolveDefaultSenderEmail`).

### Templates and recipient quality

- `sendEmail` renders `customHtml` through Handlebars with `customHtmlData`, falling back to the `COMPANY_EMAIL_TEMPLATE` org config, and post-processes the result through `modifier` hooks.
- Broadcast builds its body from the documents module's block content (`blocksToHtml`/`replaceContent`), appends the configured postal address and an unsubscribe link, and screens new addresses through the proven-address list before sending (`claim`/`listProven` in `utils/email/ramp.ts` and `screenNewAddresses.ts`); an 80%-failure threshold aborts the batch rather than burning sender reputation.

> [!WARNING]
> **Outbound mail silently skipped**
>
> `NODE_ENV=test` short-circuits `sendEmail`, and on SaaS a missing fallback sender also drops the message. Check `EmailDeliveries` for a `failed` row with the provider error.

## Inbound path

### 1. Cloudflare Email Worker receives the message

[cloudflare/mail-worker/src/index.ts](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/cloudflare/mail-worker/src/index.ts) accepts messages up to 25 MiB, derives the tenant from the recipient address (`tenantFromAddress`), stores the raw payload and attachments, and enqueues `MAIL_QUEUE`. A queue consumer then POSTs the payload to the deployment's erxes endpoint with `x-erxes-timestamp` and an `x-erxes-signature` HMAC derived per tenant. Transient statuses (401/403/408/425/429) retry; other failures dead-letter to `erxes-mail-dlq`. A `/verify` route completes inbound-address verification.

### 2. Frontline receives and threads it

`frontline_api` mounts `POST /mail/receive` (`src/modules/integrations/mail/routes.ts`) → `controller/receiveMessage.ts` verifies the signature, parses the tagged address (`parseTaggedAddress` → `replyTag`), and threads: `MailMessages.findByReplyTag` reopens the existing conversation when the tag matches, otherwise a new conversation starts. The message persists via `receiveInboxMessage`, then `pConversationClientMessageInserted` publishes the real-time event to widgets and inbox subscribers. Deliveries are keyed by the queued payload, and a `keepStored` response tells the worker to retain the stored blob, so replayed deliveries thread to the same conversation instead of duplicating it.

### 3. Integration lifecycle

`messageBroker.ts` in the mail module handles address provisioning, integration create/update (`mailCreateIntegration`, `mailUpdateIntegration`), indexes, and sendability checks. Mail integrations appear in the Frontline inbox like any other channel.

Worker bindings (`wrangler.toml` / the `Env` interface in `src/types.ts`):

| Binding | Purpose |
| --- | --- |
| `MAIL_QUEUE` / `MAIL_DLQ` | Queue for accepted mail; dead-letter queue for permanent failures |
| `MAIL_STORE` | R2 bucket for the JSON payload (`<tenant>/<id>.json`) and attachments, later served through `/attachments/<key>?token=<hmac>` |
| `ERXES_ENDPOINT` / `ERXES_ENDPOINT_TEMPLATE` | Fixed delivery URL, or a template containing a `{tenant}` placeholder |
| `MAIL_ROUTES` | Optional KV per-tenant endpoint/secret overrides |
| `WEBHOOK_SECRET`, `DEFAULT_TENANT`, `ATTACHMENT_BASE_URL` | Master HMAC secret, fallback tenant, public base for attachment URLs |

`routeFor` looks up the endpoint per tenant (KV override → fixed endpoint → template); `masterFor` uses the route's own secret or falls back to `WEBHOOK_SECRET`, which is then tenant-derived before signing, so each tenant's inbound HMAC is unique even when they share the master secret.

> [!WARNING]
> **Inbound mail never arrives**
>
> The worker route has no `endpoint` configured for the tenant (`routeFor`), or the HMAC secret drifted. Check the worker logs for `erxes responded` errors and the `erxes-mail-dlq` dead-letter queue.

## Delivery observability

Email deliveries and proven addresses are first-class records: `emailDeliveries` and `emailAddresses` are cursor-paginated GraphQL queries on Core API. The team-member record table also has an email activity log (the `EmailDeliveries` and `EmailAddresses` tables plus an activity-log sheet on each member), so you can audit what the platform sent on a member's behalf.

