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 ( 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 concernsZEPTOMAIL_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.