Email Delivery
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), 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 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:
- Delivery log:
EmailDeliveriesrecords movequeued→sent/failedvia theemailDeliveries.create/recordHandofftRPC procedures, capturing message id, provider response, and rejected recipients. - Suppression:
emailSuppression.blockedfilters suppressed recipients before the provider call.
Callers:
sendEmail(core-api/src/utils/email/index.ts): transactional mail; appliesCOMPANY_EMAIL_TEMPLATE/TYPEHandlebars 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. - Automations (
services/automations/.../emailAction/sendEmails.ts): the send-email workflow action uses the samedeliverEmail. - 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
sendEmailrenderscustomHtmlthrough Handlebars withcustomHtmlData, falling back to theCOMPANY_EMAIL_TEMPLATEorg config, and post-processes the result throughmodifierhooks.- 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/listProveninutils/email/ramp.tsandscreenNewAddresses.ts); an 80%-failure threshold aborts the batch rather than burning sender reputation.
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
Cloudflare Email Worker receives the message
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 enqueuesMAIL_QUEUE. A queue consumer then POSTs the payload to the deployment's erxes endpoint withx-erxes-timestampand anx-erxes-signatureHMAC derived per tenant. Transient statuses (401/403/408/425/429) retry; other failures dead-letter toerxes-mail-dlq. A/verifyroute completes inbound-address verification.Frontline receives and threads it
frontline_apimountsPOST /mail/receive(src/modules/integrations/mail/routes.ts) →controller/receiveMessage.tsverifies the signature, parses the tagged address (parseTaggedAddress→replyTag), and threads:MailMessages.findByReplyTagreopens the existing conversation when the tag matches, otherwise a new conversation starts. The message persists viareceiveInboxMessage, thenpConversationClientMessageInsertedpublishes the real-time event to widgets and inbox subscribers. Deliveries are keyed by the queued payload, and akeepStoredresponse tells the worker to retain the stored blob, so replayed deliveries thread to the same conversation instead of duplicating it.Integration lifecycle
messageBroker.tsin 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.
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.