Skip to content

ADR-0008 · Email & notifications (ZeptoMail + Zoho Campaigns) ​

Status: 🟢 Accepted — Option A confirmed 2026-07-20 (OTP = email; ZeptoMail transactional / Zoho Campaigns promotional split acknowledged) · Depends on: — · Related: ADR-0002 (payment receipts/dunning emails), ADR-0005 (referral/reward emails), Edge Function kit standards

Context ​

QRSETU needs email for two fundamentally different purposes:

  • Transactional — OTP, email verification, and notifications (payment receipts, referral rewards, booking/enquiry alerts, "your report is ready"). Must be reliable, fast, and high-deliverability.
  • Promotional — marketing campaigns, newsletters, feature announcements, seasonal offers.

Product has chosen the Zoho stack: ZeptoMail for transactional (and initially assumed also promotional). Research into ZeptoMail's API and Terms of Service (2026-07-20) surfaced a hard constraint that shapes the whole design.

The constraint that drives this ADR ​

ZeptoMail is transactional-ONLY, by enforced policy. Zoho's ToS states users "agree not to use ZeptoMail for sending mass emailing campaigns, including marketing emails, promotional emails and newsletters." The rationale is deliverability isolation — ZeptoMail keeps transactional traffic on high-reputation IPs, and mixing marketing would risk spam flags that poison OTP/verification delivery. Zoho's designated product for promotional email is Zoho Campaigns — a separate product with separate API, separate auth, and separate sending infrastructure (it does not share ZeptoMail's send token or "Agents"). So this cannot be one integration; it is two.

Relevant ZeptoMail API facts (for the transactional path) ​

  • Send: POST https://api.zeptomail.{region}/v1.1/email (and /email/template, /email/batch, /email/template/batch). Auth header Authorization: Zoho-enczapikey <SEND_MAIL_TOKEN> — a static per-Agent send token (distinct from the OAuth used by management APIs).
  • Templates: created/managed in ZeptoMail; sent by template_key/template_alias with a merge_info object ({{key}} placeholders). Dynamic OTP/verify/notify values pass via merge_info.
  • Batch: up to 500 recipients/call, each with its own merge_info — for transactional fan-out only.
  • Regions / data residency: independent DCs incl. India (api.zeptomail.in); the DC is fixed at account creation and the host must match it. Create the account in the India DC; store base URL as per-env config.
  • Webhooks (per Agent): soft/hard bounce, open, click, spam-complaint (feedback loop); plus a managed suppression list. Requires a tracking sub-domain CNAME for open/click.
  • Deliverability: domain verification (DKIM TXT + CNAME) mandatory; SPF + DKIM + DMARC on DNS.
  • Pricing: credits (1 credit = 10,000 emails, ~$2.50), credits expire 6 months; 10k free on signup.
  • Payload nesting is fiddly (to[].email_address.address) — wrap in a typed/Zod service.

What exists today ​

There is no email service in QRSETU yet — supabase/functions/ has no sender. Transactional mail (verification, password reset) currently relies on Supabase Auth's built-in email; there is no custom notification email and no promotional capability. So this is greenfield, which is the right time to set the split.

Decision drivers ​

  • ToS compliance + deliverability isolation — promo must never share ZeptoMail's transactional reputation.
  • India data residency — keep OTP/PII email in-region (India DC), consistent with the Supabase project locality.
  • Consistency with the EF kit — a sender belongs in an Edge Function (external HTTP + secret), behind a config map, with a typed client, per the [ENFORCED] standards; a webhook belongs in a Type B EF with HMAC + idempotency.
  • Portable core — a fetch-based typed client (no Node SDK) so it runs on Deno now and is reusable later.
  • Two surfaces, cleanly separated — separate services, secrets, config, and consent model from day one.

Options considered ​

Transactional through a ZeptoMail EF; promotional through a separate Zoho Campaigns integration with its own auth, list/subscriber management, consent, and unsubscribe.

  • For: ToS-compliant; protects transactional deliverability; each product used as designed; matches the chosen Zoho stack; contact data can still be shared/synced between them.
  • Against: two integrations to build/operate (two secrets, two mental models).

B — ZeptoMail for both transactional and promotional ​

Send campaigns through ZeptoMail's batch API.

  • For: one integration.
  • Against: violates ZeptoMail ToS (risk of account suspension) and mixes marketing onto the transactional reputation, degrading OTP/verification deliverability — the exact failure the split prevents. Rejected.

C — A generic ESP (SendGrid / Amazon SES / Resend) for everything ​

  • For: one vendor, mature APIs.
  • Against: product has chosen the Zoho stack; SES/SendGrid still separate transactional vs marketing streams anyway; no reason to diverge from the mandate. Not adopted (revisit only if Zoho proves limiting).

Recommendation ​

A. Two clean surfaces:

  1. Transactional → ZeptoMail (India DC), via the EF kit.

    • EF notifications-send-email (Type A / internal service): a fetch-based call to api.zeptomail.in/v1.1/email/template, Zoho-enczapikey send token from a server secret (ZEPTOMAIL_SEND_TOKEN), base URL per-env config. Template ids come from an EMAIL_TEMPLATE config map (OTP, EMAIL_VERIFY, WELCOME, PAYMENT_RECEIPT, REFERRAL_REWARD, …) — never inline strings, mirroring the EDGE_FN map rule. Dynamic values via merge_info. Register the function in the feature EDGE_FN map; validate payloads with Zod in the shared core (guards the fiddly nesting).
    • EF notifications-email-webhook (Type B / webhook, --no-verify-jwt): ingest bounce / spam-complaint / open / click; enforce HMAC/secret + idempotency (unique event id) per the webhook standard; update a suppression state + structured logs. Respect ZeptoMail's own suppression list too.
    • Supabase Auth emails: point Auth's SMTP/custom-email at ZeptoMail (or send verification/reset ourselves via the EF) so all transactional email travels one high-reputation path. Decide SMTP-relay vs custom-hook at implementation.
  2. Promotional → Zoho Campaigns, separate integration. Its own auth/secret, list + subscriber management, double-opt-in consent, and unsubscribe handling (legal requirement). Triggered from backend/CRM, syncing contact data from Supabase — but a fully independent sending path. Never routed through ZeptoMail. Details are a later ADR/spec once campaigns are actually scheduled; this ADR only fixes the split.

  3. Notifications architecture: treat "notification" as a channel-agnostic event; email is one channel (ZeptoMail). Keep a notifications/event concept so SMS/WhatsApp/push can be added later without reworking callers — the EF sender is the email adapter behind it.

Consequences ​

  • Two secrets, two integrations, two ADR-scoped concerns — ZEPTOMAIL_SEND_TOKEN (+ region base URL) now; Zoho Campaigns credentials later. Add both to .env.example and the promotion runbook when built.
  • DNS work is a prerequisite to any sending: verify the sending domain (DKIM TXT + CNAME), add SPF/DKIM/DMARC, and a tracking sub-domain for open/click. Gate first send on this.
  • India DC for ZeptoMail (and Campaigns) for residency alignment with the Supabase projects.
  • Deliverability protected: promo isolation keeps OTP/verification reputation clean — the whole point.
  • Payment (ADR-0002) and referral (ADR-0005) flows get their transactional emails through this same EF + template map (receipts, dunning, reward notifications).
  • Credit-expiry (6 months) is an ops budgeting note, not an architecture concern.

Decision (2026-07-20) — Option A confirmed ​

Product confirmed the two-surface split and answered the blocking questions:

  • OTP is delivered by email (ZeptoMail). No SMS/WhatsApp BSP is introduced for the first release — one fewer provider, one fewer secret, and it keeps OTP on the same high-reputation transactional path as verification and notifications. Consequence for onboarding: the prototype's "phone OTP" step must be re-specified as email OTP (the OTP is sent to the user's email, not their mobile) — flag to Claude Design in the onboarding screen review so the copy/affordance ("we sent a code to your email") matches. SMS/WhatsApp OTP remains a future channel the channel-agnostic notification concept can absorb later without reworking callers; not built now.
  • ZeptoMail (transactional) / Zoho Campaigns (promotional) split is acknowledged and adopted. Both use cases are real and planned; they are built when their respective modules are scheduled, not upfront. ZeptoMail comes first (it is on the critical path for onboarding OTP + verification); Zoho Campaigns is deferred until a promotional/CRM module is actually planned, at which point its own ADR/spec covers auth, list/subscriber management, consent, and unsubscribe. This ADR fixes the split so nothing is later retrofitted onto the wrong reputation.

Still to settle at implementation time (not blocking this ADR) ​

  1. Supabase Auth email: relay Auth's own emails (verification/reset) through ZeptoMail via SMTP, or send them ourselves via the EF and disable Auth's built-in? (Recommend SMTP relay first — least code; revisit if custom templating is needed.) Since OTP is now email, confirm whether OTP rides Supabase Auth's email-OTP or a QRSETU-owned notifications-send-email template — prefer the latter for branded, India-DC delivery.
  2. Consent model for Campaigns: where marketing consent + unsubscribe state lives (a profiles column / dedicated table) and how it syncs to Zoho Campaigns. Deferred with the Campaigns integration itself; relevant to GDPR/DPDP compliance when that module is planned.