Appearance
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 headerAuthorization: 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_aliaswith amerge_infoobject (placeholders). Dynamic OTP/verify/notify values pass viamerge_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
A — ZeptoMail (transactional) + Zoho Campaigns (promotional), as two integrations · [recommended]
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:
Transactional → ZeptoMail (India DC), via the EF kit.
- EF
notifications-send-email(Type A / internal service): afetch-based call toapi.zeptomail.in/v1.1/email/template,Zoho-enczapikeysend token from a server secret (ZEPTOMAIL_SEND_TOKEN), base URL per-env config. Template ids come from anEMAIL_TEMPLATEconfig map (OTP,EMAIL_VERIFY,WELCOME,PAYMENT_RECEIPT,REFERRAL_REWARD, …) — never inline strings, mirroring theEDGE_FNmap rule. Dynamic values viamerge_info. Register the function in the featureEDGE_FNmap; 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.
- EF
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.
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.exampleand 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)
- 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-emailtemplate — prefer the latter for branded, India-DC delivery. - Consent model for Campaigns: where marketing consent + unsubscribe state lives (a
profilescolumn / 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.
Related
- Edge Function kit standards (
EDGE_FUNCTION_GUIDELINES.md) — Type A/B,EDGE_FNmap, webhook HMAC + idempotency. - ADR-0002 — Razorpay receipts/dunning emails ride this path.
- ADR-0005 — reward/referral notification emails ride this path.
- ZeptoMail docs: Email API · Templates · Batch · Webhooks · Bulk-email policy · ToS · Zoho Campaigns