7d7c75915a
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
140 lines
8.1 KiB
Markdown
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).
|