Email loop
End-to-end transactional email pipeline. Three components, event-driven, with a full audit trail and webhook ingest for delivery status.
The flow
1. Order completed (or any other trigger)
↓
2. commerce-api inserts outbox_dispatches row
{ dispatch_type: 'task', target: { task_name: 'send_email', ... } }
↓ LISTEN/NOTIFY 'outbox_new'
3. outbox-dispatcher claims the row (SELECT FOR UPDATE SKIP LOCKED)
↓
4. Calls commerce_api.email_send.send_transactional_email(...)
↓
5. Resolves template (locale fallback)
Checks notification preference (should_notify)
Resolves settings (api_key, from_address)
↓
6. POST email-renderer:8011/render { mjml, ctx, locale }
↓ HTML + text + subject
7. POST api.resend.com/emails { from, to, subject, html, text }
↓ message_id
8. INSERT email_deliveries (status='sent', provider_message_id=...)
↓
9. Resend webhook → POST /webhooks/resend
email.delivered → status='delivered', delivered_at=now
email.opened → status='opened', opened_at=now
email.clicked → status='clicked', first_clicked_at=now
email.bounced → status='bounced', bounced_at=now
Trigger an email manually
curl -X POST "$API/email/send.json" \
-H "Authorization: Bearer $NEVIOS_KEY" -H "Content-Type: application/json" \
-d '{
"template_key": "order.confirmation",
"customer_id": 308...,
"ctx": {
"order": {"id":"308...","name":"ORD-000123","total_cents":34100,"currency":"CZK"},
"customer": {"first_name":"Anna"}
},
"locale": "cs",
"reference_type": "order",
"reference_id": 308...,
"idempotency_key": "order-confirmation-308..."
}'
The send service:
- Idempotency-checks via
idempotency_key(re-call returns same row) - Resolves customer → ensures not redacted, picks email/locale
- Notification preference check (skipped only with
skip_notification_check: true) - Resolves template via locale fallback
- Calls renderer + Resend
- Persists
email_deliveriesrow
{
"email_delivery": {
"id": "308...",
"template_key": "order.confirmation",
"to_email": "[email protected]",
"subject": "Děkujeme za objednávku ORD-000123",
"provider": "resend",
"provider_message_id": "abc123...",
"status": "sent",
"queued_at": "2026-05-01T10:30:00Z",
"sent_at": "2026-05-01T10:30:01Z"
}
}
List deliveries
curl "$API/email/deliveries.json?customer_id=308...&status=delivered&limit=50" \
-H "Authorization: Bearer $NEVIOS_KEY"
Filters: customer_id, status, template_key, reference_type,
reference_id. Cursor pagination via cursor_id.
Get a delivery (with body)
curl "$API/email/deliveries/{id}.json?include_body=true" \
-H "Authorization: Bearer $NEVIOS_KEY"
include_body=true returns the rendered HTML, text, and ctx — useful
for support staff debugging "did the email actually get sent and what
did it say".
Resend webhook ingest
POST /webhooks/resend
Content-Type: application/json
resend-signature: <hex sha256 hmac>
X-Account-Id: <account_id> (optional — falls back to message_id lookup)
{
"type": "email.delivered",
"data": {
"email_id": "abc123...",
"created_at": "2026-05-01T10:31:00Z"
}
}
The handler:
- Verifies HMAC if
commerce.email.resend_webhook_secretis set - Resolves account from
X-Account-Idheader / query / message_id lookup - Inserts
email_delivery_events(idempotent on unique key) - Updates the linked delivery's status (forward-only — never regresses)
Renderer (Node service)
POST email-renderer:8011/render:
{
"source_format": "mjml",
"body_source": "<mjml>...</mjml>",
"subject_template": "Hi {{name}}",
"ctx": {"name":"Anna"},
"preheader_template": null,
"body_text_template": null,
"locale": "cs"
}
Response:
{
"html": "<!doctype html>...",
"text": "Hi Anna\n...",
"subject": "Hi Anna",
"preheader": null,
"warnings": []
}
Warnings array carries non-fatal MJML compilation issues (line + tag).
Outbox dispatcher
The Python worker at services/outbox-dispatcher/:
- Processes
outbox_dispatchesrows wheredispatch_type='task'andtarget.task_name='send_email' - Claims with
SELECT FOR UPDATE SKIP LOCKED(multi-worker safe) - LISTEN/NOTIFY on
outbox_newfor low-latency wakeups - Polls every
DEFAULT_POLL_INTERVAL_S(default 5s) as fallback - Exponential backoff on failure: 30s → 60s → 5min → 30min → 2h →
status='dead' - Each retry increments
attempts;max_attempts(default 5) is per-dispatch
Run it as a separate Railway service / docker container next to
commerce-api.
Endpoints
POST /admin/2026-01/{handle}/email/send.json
GET /admin/2026-01/{handle}/email/deliveries.json
GET /admin/2026-01/{handle}/email/deliveries/{id}.json
POST /webhooks/resend (public, HMAC-verified)
Next: Events & outbox.