From 7d7c75915a943b75cb67d2c77c64fd140dbf82b9 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 13:25:37 +0530 Subject: [PATCH] docs(smtp): SMTP provider plan Co-Authored-By: Claude Opus 4.8 --- docs/smtp-provider-plan.md | 139 +++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/smtp-provider-plan.md diff --git a/docs/smtp-provider-plan.md b/docs/smtp-provider-plan.md new file mode 100644 index 0000000..298df07 --- /dev/null +++ b/docs/smtp-provider-plan.md @@ -0,0 +1,139 @@ +# SMTP Provider — Implementation Plan + +**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · **Purpose:** make external email *actually leave the building* (welcome / receipt), the critical path for Friday. + +## Goal + +Add an `SmtpProvider` so the `EMAIL` channel delivers via real SMTP (`accounts@lynkeduppro.com`, +fallback `ceo@lynkeduppro.com`) instead of the sandbox. The template module already renders and +queues to the `EMAIL` channel; this is the one piece between "queued (SENT via sandbox)" and "the +customer receives it." From the meeting: *"जो पहला जा रहा है, वो SMTP से जा रहा है, क्योंकि हमें तुरंत चाहिए."* + +## What already exists (do NOT rebuild) + +- `CapabilityProvider { name, channelTypes, capabilities, send(req) }` — the seam. +- `CapabilityProviderRegistry` binds a provider per channel: **sandbox by default**; `EmailProvider` + (HTTP) when `IIOS_PROVIDER_URL_EMAIL` is set. Unknown channels fail closed. +- `OutboundService.send` → `CapabilityBroker` (policy + obligations) → the bound provider. Idempotency, + rate limits, ledger, provenance all upstream — untouched. +- `req.payload` for EMAIL is `{ subject, text, html, inReplyTo }` (from `TemplatedSender`). + +## Design decisions (locked) + +| # | Decision | Why | +|---|---|---| +| 1 | New `SmtpProvider implements CapabilityProvider`, `channelTypes=['EMAIL']`, via **nodemailer** | The established provider pattern; nodemailer is the standard SMTP client | +| 2 | **Env-driven activation**, like `IIOS_PROVIDER_URL_EMAIL` | Off by default (sandbox); flip on by setting SMTP env — no code change to enable | +| 3 | **Registry precedence for EMAIL: SMTP > HTTP > sandbox** | SMTP is the intended prod path; HTTP relay stays available; sandbox is the safe default | +| 4 | **Transporter is injected** (constructor takes a `Transporter` or a factory) | SMTP is untestable against a live server in CI; inject a stub/`jsonTransport` to assert the envelope | +| 5 | **Optional fallback sender** (`accounts@` primary → `ceo@` on failure) | The meeting's fallback: if the primary mailbox send fails, retry once via the fallback identity | +| 6 | **Never throw** — a transport error returns `{ outcome: 'FAILED', errorCode }` | Adapter doctrine; the command is marked FAILED, the caller isn't broken | + +## Config (env) + +``` +IIOS_SMTP_HOST=smtp. # e.g. smtp.gmail.com (Google Workspace) +IIOS_SMTP_PORT=587 +IIOS_SMTP_SECURE=false # true for 465, false for 587/STARTTLS +IIOS_SMTP_USER=accounts@lynkeduppro.com +IIOS_SMTP_PASS= # Workspace App Password, NOT the account password +IIOS_SMTP_FROM="LynkedUp Pro " # defaults to USER +# optional fallback identity used only if the primary send FAILS +IIOS_SMTP_FALLBACK_USER=ceo@lynkeduppro.com +IIOS_SMTP_FALLBACK_PASS= +IIOS_SMTP_FALLBACK_FROM="Justin Johnson " +``` + +Activation rule: `SmtpProvider` is bound for `EMAIL` iff `IIOS_SMTP_HOST` + `IIOS_SMTP_USER` + +`IIOS_SMTP_PASS` are all set. Fallback transporter built only if the `_FALLBACK_*` trio is set. + +## Files + +``` +src/capability/smtp.provider.ts # new — the provider +src/capability/smtp.provider.spec.ts # new — injected-transport tests +src/capability/capability.registry.ts # modify — bind SMTP for EMAIL when configured (precedence) +package.json # add nodemailer + @types/nodemailer +``` + +## Contract + +```ts +interface SmtpIdentity { host: string; port: number; secure: boolean; user: string; pass: string; from: string } + +class SmtpProvider implements CapabilityProvider { + readonly name = 'smtp'; + readonly channelTypes = ['EMAIL']; + readonly capabilities = { canSend: true }; + // `makeTransport` is injectable so tests pass a stub / nodemailer jsonTransport. + constructor(primary: SmtpIdentity, fallback?: SmtpIdentity, makeTransport?: (id: SmtpIdentity) => Transporter) {} + async send(req: CapabilityRequest): Promise; +} +``` + +`send()` builds the mail from `req.target` (recipient) + `req.payload`: +``` +{ from, to: req.target, subject, text, html, + inReplyTo?, references?, // threading, from payload.inReplyTo + messageId } // generated; returned as providerRef so replies can thread +``` +Primary transporter sends; on throw, if a fallback identity exists, retry once via it; still failing +→ `FAILED`. Success → `{ outcome: 'SENT', providerRef: messageId, latencyMs }`. + +**Review findings folded in:** +- **`providerRef` = nodemailer's returned `info.messageId`**, not a hand-generated id — nodemailer + stamps the real `Message-ID` it sent, which is what a reply's `In-Reply-To` will actually match. +- **Fallback only on PRE-acceptance failures** (connection refused, auth failure, timeout) — NOT on + an error raised after the SMTP server already accepted the message. Retrying a post-acceptance + failure via the fallback identity would **double-deliver**. `send()` inspects the error (nodemailer + `err.responseCode` / code) and falls back only when the server never accepted. +- **Registry precedence is registration ORDER:** `register()` does `byChannel.set(ch, provider)`, so + the LAST registration for `EMAIL` wins. Bind sandbox first (all channels), then HTTP `EmailProvider` + if its URL is set, then `SmtpProvider` **last** if SMTP env is set → SMTP > HTTP > sandbox falls out. + +## Task-by-task (TDD) + +Each: failing test → red → implement → green → commit. Tests inject a stub transporter (no network). + +**T1 — provider skeleton + config parse** +- `smtpIdentityFromEnv()` reads the env trio; returns null if incomplete. +- Test: full env → identity; missing pass → null; fallback trio → fallback identity. + +**T2 — `send()` builds the correct envelope** +- Inject a recording stub transporter. Test: `from`/`to`/`subject`/`html`/`text` map from target+payload; + `inReplyTo` → header set when present; `messageId` generated and returned as `providerRef`; outcome `SENT`. + +**T3 — failure handling + fallback** +- Stub throws on primary. Test: with a fallback identity → retries via fallback, `SENT` via fallback + transporter; without fallback → `FAILED` with `errorCode`, **never throws**. + +**T4 — registry precedence** +- `capability.registry.spec` (or extend): with SMTP env set, `forChannel('EMAIL')` returns the SMTP + provider (not sandbox/HTTP); with only `IIOS_PROVIDER_URL_EMAIL` → HTTP; with neither → sandbox. +- The registry reads env in its constructor, so each case sets env, constructs a fresh + `CapabilityProviderRegistry`, asserts, then restores env (mirror the env save/restore other specs use). + +**T5 — gate + manual real send** +- `vitest run` (all), `boundary`, `build` green. +- **Manual (ops):** point env at a real mailbox (or nodemailer **Ethereal** test SMTP for a no-mailbox + end-to-end), boot, `POST /v1/templates/send` the `welcome` seed to your own address, confirm receipt + and that From = `accounts@lynkeduppro.com`. + +## Error handling +- Incomplete SMTP env → provider not bound → EMAIL falls back to sandbox (no accidental silent prod send). +- Transport failure → `FAILED` on the command (+ delivery attempt), never thrown. +- Fallback used → `providerRef` notes the fallback identity for audit. + +## Risks / prerequisites +- 🔴 **Rotate `Qwerty@a2` BEFORE enabling.** This is the switch that turns queued sends into real + emails to real addresses — a forgeable IIOS token now reaches customer inboxes under your brand. +- **Workspace App Password, not the account password** (2FA accounts reject the raw password over SMTP). +- **Deliverability:** SPF + DKIM + DMARC on `lynkeduppro.com` or mail lands in spam. Ops task, before real customers. +- **Sending limits:** Google Workspace SMTP ≈ 2000/day. Fine — instant welcome/receipt is low volume; the + drip goes via Mailchimp, not SMTP. +- **Test safety:** never point CI/test env at a real mailbox; tests use an injected stub, the manual step + uses Ethereal or a throwaway inbox. + +## Out of scope (separate plans) +Attachments over SMTP (extends `EmailPayload` + this provider); the inbox mirror / INTERNAL delivery +(no SMTP dependency).