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_SERVICEProviderConfig codes
SES (default)SesEmailProvider (AWS SES)AWS_SES_ACCESS_KEY_ID, AWS_SES_SECRET_ACCESS_KEY, AWS_REGION, AWS_SES_CONFIG_SET
sendgridSendgridEmailProviderSENDGRID_API_KEY, SENDGRID_SUBUSER
customSmtpEmailProviderMAIL_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: 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.
  • 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.

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 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):

BindingPurpose
MAIL_QUEUE / MAIL_DLQQueue for accepted mail; dead-letter queue for permanent failures
MAIL_STORER2 bucket for the JSON payload (<tenant>/<id>.json) and attachments, later served through /attachments/<key>?token=<hmac>
ERXES_ENDPOINT / ERXES_ENDPOINT_TEMPLATEFixed delivery URL, or a template containing a {tenant} placeholder
MAIL_ROUTESOptional KV per-tenant endpoint/secret overrides
WEBHOOK_SECRET, DEFAULT_TENANT, ATTACHMENT_BASE_URLMaster 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.

Was this helpful?