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

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.
  • CapabilityProviderRegistry binds a provider per channel: sandbox by default; EmailProvider (HTTP) when IIOS_PROVIDER_URL_EMAIL is set. Unknown channels fail closed.
  • OutboundService.sendCapabilityBroker (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

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