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_deliveries row
{
  "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:

  1. Verifies HMAC if commerce.email.resend_webhook_secret is set
  2. Resolves account from X-Account-Id header / query / message_id lookup
  3. Inserts email_delivery_events (idempotent on unique key)
  4. 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_dispatches rows where dispatch_type='task' and target.task_name='send_email'
  • Claims with SELECT FOR UPDATE SKIP LOCKED (multi-worker safe)
  • LISTEN/NOTIFY on outbox_new for 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.