Each application has Emails → History, Templates, Rules and Settings. The channel starts disabled. SMTP credentials remain on the server.
#First local delivery
- Start PostgreSQL, API, worker, console, checkout and Mailpit with Docker Compose.
- Open the application's templates and create the starter templates: nine events in Spanish and English, 18 published templates. Repeating this preserves edits.
- Edit a template's subject, preheader, color and blocks. Preview HTML and plain text at desktop and mobile widths, then save and publish.
- Select local capture and an allowed sender in Settings; enable the channel. The connection check asks the worker to verify SMTP without sending a message.
- Add an event rule, audience, published template and recipient source. Evaluate against a payment without creating messages.
- Complete a payment or send a template test. Inspect history and local Mailpit.
Mailpit captures SMTP on port 1025 without forwarding to the Internet. Compose
uses the hostname mailpit; local processes use localhost. Use a Compose
override with free ports when another local installation occupies these ports.
#Events and recipients
Supported events are payment.requires_action, payment.approved,
payment.authorized, payment.captured, payment.failed, payment.canceled,
payment.refund_created, payment.disputed and payment.settled. Every partial
refund produces its own notification with its exact amount. Gateway operations,
checkout, timers, chargebacks and settlement use the same scheduling service.
Reading a payment, updating metadata and replaying idempotent requests do not
generate another notification. Email rules operate independently of webhooks.
Buyer recipients come from the payment's private contact, then from the active
account identified by walletKey. Checkout accepts an optional email and uses
the buyer session for card payments as well. Operators can save a contact before
the payment finishes. Merchant rules use the owner or up to ten explicit addresses.
Provider wire contracts do not acquire emulator contact fields.
The selected template determines the language explicitly. Custom templates are not translated or replaced automatically. The contact's language preference is stored for future translation selection; it does not override the selected template.
#Delivery guarantees
Payment and email obligation commit in one PostgreSQL transaction. The obligation
freezes operation, recipient, template version, document, context, sender and
transport profile. The worker renders HTML/text with the same MJML renderer as
preview and performs SMTP after commit. Amounts use bigint minor units.
| Status | Meaning |
|---|---|
pending |
Waiting; may be canceled |
processing |
Claimed by a worker |
retry_scheduled |
Known temporary failure; another attempt is due |
captured |
Accepted by local capture SMTP |
accepted |
Accepted by external SMTP; inbox delivery is not confirmed |
failed |
Permanent failure or exhausted attempts; manual retry available |
unknown |
Transmission may have completed; requires operator review |
skipped |
Missing recipient/template or unavailable transport; reason recorded |
canceled |
Canceled manually or because the application became inactive |
Three automatic retries use 30 seconds, 2 minutes and 10 minutes. Workers claim
with SKIP LOCKED, a 60-second lease and a fencing token. SMTP has a 20-second
deadline. An expired claim without transmission becomes pending; a claim that
started transmission becomes unknown. SMTP cannot guarantee exactly one delivery;
unknown results are never repeated automatically.
Retry preserves the original message, payment operation and attempt history. Resend creates a linked message. Actions record actor, time and reason and use idempotent request identifiers. Manual sends are limited to 20 per hour per application and actor; connection checks to three per minute.
Disabling prevents new obligations. Pausing holds existing ones. Changing settings does not redirect queued messages. A changed host, port or TLS fingerprint skips the old message with a visible reason. Terminal content is retained for 30 days, then recipient, subject, HTML, text, document and context are redacted.
#Server configuration
Supply the same environment to API and worker. External SMTP requires explicit enablement and TLS with verified certificates.
| Variable | Default / purpose |
|---|---|
EMAIL_CAPTURE_HOST, EMAIL_CAPTURE_PORT |
localhost, 1025; Compose uses mailpit |
EMAIL_TRANSPORT |
disabled blocks every transport |
EMAIL_EXTERNAL_ENABLED |
Only true enables external SMTP |
EMAIL_SMTP_HOST, EMAIL_SMTP_PORT |
Authorized server, port 587 |
EMAIL_SMTP_TLS_MODE |
starttls or tls (usually port 465) |
EMAIL_SMTP_USER, EMAIL_SMTP_PASSWORD |
Server credentials when required |
EMAIL_ALLOWED_FROM |
Comma-separated senders; sandbox@laboratorio.test |
EMAIL_LINK_ORIGINS |
Allowed button URL origins; defaults to public checkout URL |
EMAIL_MANUAL_HOURLY_LIMIT |
20 |
EMAIL_RETENTION_DAYS |
30 |
Templates contain validated blocks, not arbitrary HTML. Only catalog variables and allowed HTTP(S) button origins are accepted. Preview uses a sandboxed iframe without scripts, forms or remote resources. Attachments, remote images and MJML includes are not supported.
The API root is /applications/:applicationId/emails, with settings, catalog,
templates and versions, rules/evaluation, tests, messages/attempts/actions and
connection checks. Lists use {data, meta}. Configuration mutations use revision
checks and return 409 for stale edits. Payment contact uses
PATCH /applications/:applicationId/payments/:intentId/contact. Administrators
and the owning merchant may access resources; cross-application IDs return 404.
#Local processes and deployment
On Windows, run-local.bat loads email settings from .env and .env.local
for API and worker. Start Mailpit separately and set EMAIL_CAPTURE_PORT if it
does not use 1025. Payments still work without SMTP; history records transport failures.
Native deployment initializes email variables in /etc/<project>/api.env, also
read by the worker, and preserves administrator edits during updates. Production
Compose shares email configuration between both processes. Both deployments start
with EMAIL_TRANSPORT=disabled. Set it to capture or smtp and configure the
server to enable delivery; external SMTP also requires EMAIL_EXTERNAL_ENABLED=true.
Restart API and worker after environment changes. Production does not install Mailpit.
npm run smoke:emails checks payment, worker, Mailpit and the captured multipart
MIME. npm run smoke:emails:browser checks ES/EN, light/dark themes and 1440/390 px
viewports. Both use the test account from smoke:stack and accept API_URL,
CONSOLE_URL, MAILPIT_URL, SMOKE_EMAIL and SMOKE_PASSWORD. Setting MAILPIT_URL
also includes email checks in npm run smoke:stack. Screenshots, accessibility
results and captured.eml are saved in .tmp/shots/emails/.
The automated audit covers console accessibility and dialog focus, excluding the
isolated email iframe. External email client compatibility needs separate testing;
browser screenshots do not establish it.
Implementation lives in apps/api/src/modules/emails and
apps/console/app/modules/integrations/emails. Tests are in
apps/api/test/email-renderer.spec.ts, apps/api/test/integration/emails.spec.ts,
apps/api/test/integration/emails-lifecycle.spec.ts
and scripts/emails-browser.mjs.
Engine references: Nodemailer SMTP, MJML and Mailpit.
#Imported HTML templates and sanitization
Templates with type: "html" are treated as untrusted content. The API normalizes and sanitizes HTML on the server before storing it as a draft, reapplies the same boundary before publishing a version, and sanitizes again after variable interpolation before producing the final message. Unsafe source HTML never becomes the authorized canonical document.
The H1 policy allows common email structure, tables, text, links, and presentation-oriented inline CSS through explicit allowlists. It rejects active content (script, iframe, forms, SVG/MathML and equivalents), on* attributes, src/srcset/poster/background resources, CSS capable of loading or executing content (url(), @import, expression(), javascript:, data:, file: and equivalent schemes), and documents larger than 200 KB. Images and other remote resources are outside H1.
Links may use HTTP/HTTPS only and must belong to an origin authorized by EMAIL_LINK_ORIGINS; when it is unset, CHECKOUT_PUBLIC_URL is used, with http://localhost:3001 as the local-development fallback. Embedded credentials and protocol-relative URLs are rejected. Accepted links are normalized with rel="noopener noreferrer".
Variables remain limited to the official Email catalogue. Their values are escaped before HTML insertion and the interpolated output is sanitized again, so data such as <script>...</script> is displayed as text rather than executed. If an HTML template does not define text, the backend derives a plain-text alternative from the already sanitized HTML.
Certification covers both the sanitizer unit and the HTTP/database contract: creating, previewing, and publishing a safe HTML template persists only the sanitized representation, while scripts, remote tracking, handlers, unsafe CSS, and external links receive 400 before they can become an authorized draft.