Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.1 KiB
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.CapabilityProviderRegistrybinds a provider per channel: sandbox by default;EmailProvider(HTTP) whenIIOS_PROVIDER_URL_EMAILis set. Unknown channels fail closed.OutboundService.send→CapabilityBroker(policy + obligations) → the bound provider. Idempotency, rate limits, ledger, provenance all upstream — untouched.req.payloadfor EMAIL is{ subject, text, html, inReplyTo }(fromTemplatedSender).
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
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 returnedinfo.messageId, not a hand-generated id — nodemailer stamps the realMessage-IDit sent, which is what a reply'sIn-Reply-Towill 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 (nodemailererr.responseCode/ code) and falls back only when the server never accepted. - Registry precedence is registration ORDER:
register()doesbyChannel.set(ch, provider), so the LAST registration forEMAILwins. Bind sandbox first (all channels), then HTTPEmailProviderif its URL is set, thenSmtpProviderlast 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/textmap from target+payload;inReplyTo→ header set when present;messageIdgenerated and returned asproviderRef; outcomeSENT.
T3 — failure handling + fallback
- Stub throws on primary. Test: with a fallback identity → retries via fallback,
SENTvia fallback transporter; without fallback →FAILEDwitherrorCode, never throws.
T4 — registry precedence
capability.registry.spec(or extend): with SMTP env set,forChannel('EMAIL')returns the SMTP provider (not sandbox/HTTP); with onlyIIOS_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,buildgreen.- Manual (ops): point env at a real mailbox (or nodemailer Ethereal test SMTP for a no-mailbox
end-to-end), boot,
POST /v1/templates/sendthewelcomeseed 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 →
FAILEDon the command (+ delivery attempt), never thrown. - Fallback used →
providerRefnotes the fallback identity for audit.
Risks / prerequisites
- 🔴 Rotate
Qwerty@a2BEFORE 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.comor 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).