Files
iios/docs/smtp-provider-plan.md
2026-07-18 13:25:37 +05:30

140 lines
8.1 KiB
Markdown

# 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.<mail-host> # 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=<app password> # Workspace App Password, NOT the account password
IIOS_SMTP_FROM="LynkedUp Pro <accounts@lynkeduppro.com>" # defaults to USER
# optional fallback identity used only if the primary send FAILS
IIOS_SMTP_FALLBACK_USER=ceo@lynkeduppro.com
IIOS_SMTP_FALLBACK_PASS=<app password>
IIOS_SMTP_FALLBACK_FROM="Justin Johnson <ceo@lynkeduppro.com>"
```
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<ProviderResult>;
}
```
`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).