# 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).