OctoWiki

Notifications & Email

How Octopus tells customers about their orders — the per-family notifiers, the terminal-only rule, the Jet email templates and design system, outbound HMAC-signed client webhooks with retry/backoff, and the event→notification matrix.

Every customer-facing notification fans out to two channels: transactional email and outbound client webhooks. There are three per-family notifiers (voucher / topup / eSIM), one shared outbound-webhook engine, and a strict terminal-only firing rule.

The single biggest gotcha

There are two generations of the voucher notification path. The newer unified OrderNotifier (services/order_notifier.go) is fully implemented but has zero production callers — the live voucher flow is the older inline code in create_voucher_order.go + order.go. Consequence: voucher customers get an email only on DELIVERED — no partial or cancelled emails are sent, even though the templates and builder support exist. See Dead & divergent code.

Architecture

NotifierFileChannelsWired in production?
OrderNotifier (voucher)services/order_notifier.goemail + webhookNo — zero callers
Inline voucher pathcreate_voucher_order.go, order.goemail (delivered only) + webhook (all 4)Yes
EsimNotifierservices/esim_notifier.goemail + webhook + lifecycle-webhookYes
RechargeNotifierservices/recharge_notifier.goemail + webhookYes

All notifier dispatch runs in separate fire-and-forget goroutines with a detached context.Background() so request cancellation can't abort a send.

The terminal-only rule (verified)

Notifications fire only at terminal (status[, sub_status]) pairs. Each notifier maps status→kind and no-ops otherwise; call sites re-load the row post-commit and gate on IsFinal() before notifying.

FamilyTerminal pairs that notifyClassifier
Voucher(Delivered, Completed), (PartiallyDelivered, Partial), (Cancelled, Refunded)classifyOrderTransition (order_notifier.go:119)
eSIMDelivered / Failed / CancelledEsimKindForStatus (esim_email.go:29)
RechargeDelivered / Failed / CancelledRechargeKindForStatus (recharge_email.go:34)

In-flight states (VENDOR_ORDER_CREATED, provisioning, recharge PENDING/RECHARGED) never notify. The only non-terminal events emitted at all are the eSIM post-delivery lifecycle webhooks (installed / activated / depleted) — genuine one-shot state transitions, webhook-only.

This matches the no-webhooks-in-flight-substatus and notification-scope-discipline rules. Note the stale comment in classifyOrderTransition claiming "FAILED is no longer a status" — OrderFailed / order.failed are still live in the inline path.

Email generation & sending

Transport — selected by EMAIL_PROVIDER env (default sendgrid):

ProviderFileEnvTimeout
SendGrid (v3 API)email/sendgrid.goSENDGRID_API_KEY, EMAIL_DEFAULT_SENDER10s
SMTP (TLS 465 / STARTTLS 587)email/smtp.goSMTP_HOST/PORT/USERNAME/PASSWORD/SECURE_MODE/INSECURE_CERT15s

Interface email.MessageDeliveryService.DeliverMessage(ctx, MessageContent); global singleton via InitialiseEmail / GetEmailProvider.

SMTP transport silently drops attachments. constructRawMessage (smtp.go:210) builds a single-part body and ignores FileReferences — only SendGrid handles attachments. Under EMAIL_PROVIDER=smtp, bulk-voucher XLSX files are not delivered. Prod uses SendGrid; keep it that way or fix SMTP multipart.

Templates — Jet (github.com/gofiber/template/jet/v2). Dev reads views/email from disk; prod uses an embedded FS. RenderEmailTemplates(base, data) always renders both base.txt and base.html, both attached to the message. Data is map[string]any; Jet requires capitalised field access.

Voucher email builder & the 6 permutations

BuildVoucherEmailData (services/voucher_email.go:61) reloads order+items+product, decrypts voucher fields (decryptOrPassthrough, degrades to empty on failure so one bad row can't fail the email), and returns a bundle with a Cleanup() for any temp attachment.

CaseAttachmentPayload
Single (qty==1) deliverednoneinline, sets hasCode/hasPIN/hasClaimURL
Bulk (>1) deliveredXLSX of all itemsnil
PartialXLSX of delivered-only items + refund mathnil
Cancellednonerefund amount

The 6 permutations are the single-voucher delivered layouts, driven by which of {Code, Pin, ClaimURL} are present (rendered in views/email/voucher/delivered.html.jet via hasCode/hasPIN/hasClaimURL): code-only; code+PIN; claim+PIN; claim-only; PIN-only; code+PIN+claim. Design mock: views/email/preview/voucher_permutations.html.

Excel (services/excel_generator.go, excelize v2): columns Product Name / Denomination / Code / Pin / Voucher Link / Reference / Expires At. Written to /tmp/octopus-orders/…xlsx, removed by CleanupExcelFile via the caller's defer content.Cleanup().

eSIM & recharge builders

  • eSIM (esim_email.go:68): delivered template gets activationCode, iccid, and a QR image URL (https://api.qrserver.com/...). Failed/cancelled include refundAmount.
  • Recharge (recharge_email.go:61): masks the destination — phone as +CC AA ***NNNN, IDs keep prefix+last4. RechargeClaimURL = https://claim.octopuscards.io for user-fixable retries.
  • ⚠️ No voucher/failed template exists (the voucher builder rejects any kind but Delivered/Partial/Cancelled), while recharge and eSIM both have failed templates. Asymmetric.

Email design system

Shared layout per family: views/email/{voucher,esim,recharge}/_layout.{html,txt}.jet (each family has its own copy of the same token set).

  • Palette / elevation (light inline, dark via prefers-color-scheme + Outlook [data-ogsc]): text text-ink/text-slate/text-mist; surfaces body-bgcard-bgsurface-bg/surface-bg-strong (3-tier elevation); accent brand blue #3BA8D9.
  • State→color via notice-* classes: notice-orange (warning), notice-red (failure), notice-amber (caution), notice-slate (neutral), each with a -strong foreground.
  • Fonts: DM Serif Display (headings), Nunito (body), DM Mono (codes), with MSO/Outlook fallbacks. Accordions via <details>.

No-vendor-info rule — and two leak vectors

Enforced by construction: email builders resolve only product/variant/country names, never vendor. No vendor string appears in any shipped .jet. Two latent risks, neither of which reaches customers today:

  1. The static mock views/email/preview/voucher_partial.html:251 contains customer-facing copy "Vendor inventory ran short of…" — reword it so it can't be copied into a live template.
  2. models.OrderItemWebhook has dead VendorID/VendorName fields — a leak vector if that struct is ever marshalled into an outbound payload.

See no-vendor-in-customer-facing.

Outbound client webhooks

Registration (client portal)

http/handler/client_webhooks.go, CRUD over web_hook_urls:

  • POST /client/api/webhooks — requires HTTPS; the signing key is generated server-side (GenerateWebhookSecret), never accepted from the client, returned once. Optional events validated against IsValidClientWebhookEvent.
  • POST /:id/rotate-secret — new key, old invalid immediately. POST /:id/test — synthetic type:"test" payload, not persisted.
  • WebHookURL.SubscribesToEvent treats an empty events list as "all events" (legacy rows subscribe to everything).

Payload & signing

Envelope models.WebhookPayload: { id: "evt_...", type, created_at, data{} } (webhook_service.go:91). SendWebhook (webhook_sender.go:41) POSTs JSON with headers X-Webhook-ID, X-Event-ID, X-Event-Type, X-Timestamp, and X-Signature = HMAC-SHA256(payload, token) hex. The signing key is never transmitted; success = HTTP 2xx.

The eSIM activation code is deliberately omitted from webhook payloads — it goes only to email. Webhooks carry the ICCID instead. Failure fields (failure_code/failure_reason/is_user_fixable) appear only on the *.failed events.

Queue → delivery → retry

  • Cron: WebhookTask (*/1 * * * *, batch 50) → ProcessPendingDeliveries. Bounded fan-out of 10 via errgroup; one bad delivery never cancels the batch.
  • Claim: FOR UPDATE SKIP LOCKED flips pending→processing and increments attempt_count in one UPDATE — prevents duplicate sends across overlapping ticks / instances.
  • Retry/backoff: exponential 1<<(attempt-1) minutes = 1, 2, 4, 8, 16; at attempt >= MaxAttemptsmarkFailed (terminal failed, no separate DLQ).
  • Crash recovery: ReapStuckProcessingDeliveries re-queues processing rows older than 3 min without resetting attempt_count (a poison endpoint can't loop forever).
  • Idempotency: stable event_id (evt_...) in body + X-Event-ID, constant across retries and across all URLs of one event. Gap: no dedup on enqueue — a double-fired trigger queues two deliveries with different event_ids; dedup relies on single-fire callers + the client honoring event_id.

Payout client webhooks are a separate task/service (scheduler/payout_webhook_task.go) — and are currently never enqueued (see Payouts).

Inbound vs outbound

Two entirely separate systems:

  • Inbound (vendor → Octopus): services/vendor_webhook_service.go receives async fulfilment callbacks, updates internal state, and on terminal transition triggers the outbound notifiers. This is where vendor topology lives — logged to vendor_webhook_logs, firewalled from customer output.
  • Outbound (Octopus → client): everything on this page. Never carries vendor identity.

The notifier layer is the boundary: inbound processors call notifiers; notifiers emit vendor-free payloads/emails.

Event → notification matrix

EventWebhook typeEmail?Trigger
Voucher deliveredorder.deliveredYescreate_voucher_order.go:1015 + order.go:525
Voucher partially deliveredorder.partially_deliveredNo (live path)create_voucher_order.go:1033
Voucher failedorder.failedNocreate_voucher_order.go:1049
Voucher cancelledorder.cancelledNo (live path)create_voucher_order.go:1065
Topup delivered / failed / cancelledtopup.*Yestopup.go:2063, admin_recharge_actions.go:154
eSIM delivered / failed / cancelledesim.*Yes (QR + activation code on delivered)vendor_webhook_service.go:544, admin_esim_actions.go:147
eSIM installed / activated / depletedesim.*No (webhook-only)esim_order.go:1147/1150, jobs/esim_expiry_job.go:113
Wallet credited / debitedwallet.*NoWebhookService.TriggerWalletEvent

Constants: database/models/webhook.go:85; subscribable set ClientWebhookEventTypes (:119).

Delivery guarantees

  • Webhooks: at-least-once, up to 5 attempts with backoff; terminal failed is the dead-letter state (no DLQ table); crash recovery via reaper; client-side idempotency via stable event_id.
  • Emails: best-effort fire-and-forget — no retry, no persistence. A failed send is logged and dropped; manual resend (ResendOrderEmailsendOrderDeliveryEmail) is the only recovery.

Dead & divergent code

  1. OrderNotifier fully implemented but unwired — live voucher path is the inline create_voucher_order.go/order.go code. Two generations coexist; pick one.
  2. Voucher partial/cancelled emails never sent in production despite templates + builder support.
  3. No voucher/failed template while recharge/eSIM have one.
  4. SMTP drops attachments (smtp.go:210).
  5. Stale comment "FAILED is no longer a status" (order_notifier.go:115) — it still is.
  6. Vendor-leak vectors: preview mock copy + dead OrderItemWebhook.VendorID/VendorName.
  7. No enqueue-side webhook dedup.
  8. factory.go NewEmailService is a parallel unused provider constructor (live path is email.go:createProvider).

Key files

  • Notifiers: services/{order_notifier,esim_notifier,recharge_notifier,esim_notification}.go
  • Email builders: services/{voucher_email,esim_email,recharge_email,excel_generator}.go
  • Transport: email/{email,sendgrid,smtp,templates}.go; templates views/email/
  • Outbound webhooks: services/{webhook_service,webhook_sender}.go; http/handler/client_webhooks.go; scheduler/webhook_task.go; database/repo/webhook_delivery.go
  • Inbound: services/vendor_webhook_service.go
  • Models: database/models/{webhook,web_hook_url}.go

On this page