Files
2026-05-27 23:29:53 +01:00

113 lines
7.6 KiB
Markdown

# Ship @suaveplan/emailer (Wave Emails)
**Package**: `@suaveplan/emailer`
**Status**: PROPOSED
**Type**: new-package
**Design**: ./design.md
**Spec delta**: ./specs/emailer/spec.md
## Why
The upstream `@suaveplan/emailer` (server-tier, 76.11% coverage, no spec, no OTel,
no defineCodes, no Zod boundaries, nodemailer + googleapis hard deps) is the only
remaining gap in the emails plugin family before `enterprise-email-templates`,
`notification-channel`, and `delivery-tracking` can light up email channels in
genesis. The genesis rewrite consolidates the upstream `EmailService` plus the
`DummyProvider` and `GmailProvider` plugins into a universal-tier package with
zero hard transport deps: every transport is structural (`EmailTransport`) and
test- or runtime-injectable, the SMTP path takes a caller-supplied `SmtpClientLike`
adapter (so consumers bundle nodemailer/etc. only when they need real SMTP), and
Gmail is reached through Gmail's REST API over an injectable `FetchLike` (no
`googleapis` dependency).
## What changes
- ADDED: `EmailService<TTransport>` — hook-driven sender with stats, retry,
AbortSignal, OTel instrumentation, optional asynchronous queue dispatch.
- ADDED: `EmailTransport` interface, `BaseEmailTransport` abstract helper, and
`EmailTransportLifecycle` (initialize / verify / close) — pluggable transport
contract every emitter (dummy, smtp, gmail-rest) implements.
- ADDED: `DummyTransport` — deterministic in-memory transport for tests and
development; records every send, supports forced failures, configurable delay
via injectable `TimersLike`, deterministic message-id suffix via injectable
`Rng`.
- ADDED: `SmtpTransport` — universal SMTP transport that accepts an injectable
`SmtpClientLike` adapter (caller supplies the concrete implementation, e.g.
nodemailer wired up in their own app shell), so the emailer package stays free
of Node-tier deps.
- ADDED: `GmailRestTransport` — calls Gmail's REST `users.messages.send` endpoint
over an injectable `FetchLike` with caller-supplied bearer access tokens; covers
every upstream auth mode by treating token acquisition as the caller's concern.
- ADDED: `EmailerHooks` — nine lifecycle hooks (`beforeValidate`, `afterValidate`,
`beforeSend`, `afterSend`, `onError`, `afterError`, `beforeRetry`, `beforeQueue`,
`afterQueue`) with typed contexts.
- ADDED: `EMAILER_CODES` via `defineCodes("EMAILER", [...])` — 12 stable error
codes, each thrown by ≥1 production path.
- ADDED: Zod schemas at every options boundary (`EmailMessage`,
`EmailerOptionsSchema`, transport-specific schemas) — validated via `safeParse`.
- ADDED: OTel metrics (`emailer.send.total`, `emailer.send.errors`,
`emailer.send.latency_ms`, `emailer.queue.depth`, `emailer.retry.total`).
- ADDED: Subpath exports for every public module (`./error-codes`,
`./schemas/schemas`, `./types/emailer`, `./core/email-service`,
`./transports/dummy`, `./transports/smtp`, `./transports/gmail-rest`,
`./fakes/fake-smtp-client`, `./otel/metrics`).
## Out of scope
- Hard `nodemailer` / `googleapis` dependencies — replaced with structural
`SmtpClientLike` injection and direct Gmail REST calls over `FetchLike`.
- IMAP / POP3 / inbound parsing — outbound only.
- Address-book / template engines — `@suaveplan/templates`,
`enterprise-email-templates`, and `@suaveplan/contacts` own those concerns.
- Suppression-list enforcement — `@suaveplan/suppression` is consulted via a
caller-supplied `beforeSend` hook; this package does not re-implement it.
## Source mapping
This change derives from `../suaveplan/enterprise/emailer.md` and the upstream
implementation under `../suaveplan/packages/server/emailer/src/`.
| Requirement | Source lines | Notes |
|---|---|---|
| EmailService lifecycle (initialize/send/queue/close) | `EmailService.ts` L36-L348 | reworded: AbortSignal added, OTel added, FSM state machine implicit |
| Nine lifecycle hooks (`beforeValidate` ... `afterQueue`) | `types/core.ts` L176-L195 + `EmailService.ts` L44-L54 | verbatim |
| Stats (`sent`, `failed`, `queued`, `retries`, `startTime`, `lastSendTime`) | `types/core.ts` L200-L213 + `EmailService.ts` L56-L63, L133, L153, L191, L252 | verbatim |
| Retry with linear / exponential backoff | `EmailService.ts` L171-L225 | extended: deterministic via `TimersLike` injection + AbortSignal |
| Asynchronous queue dispatch via injectable `add(fn)` shim | `EmailService.ts` L29-L31, L230-L255 | reworded as structural `QueueLike` |
| `EmailMessage` shape (to/from/cc/bcc/replyTo/subject/html/text/attachments/headers/priority/messageId) | `types/core.ts` L31-L58 | verbatim, plus Zod schema |
| `BaseEmailTransport` helpers (formatAddress, formatSingleAddress, createSuccessResult, createErrorResult, validateMessage, toEmailArray, extractEmail) | `providers/BaseEmailProvider.ts` L75-L188 | renamed Provider→Transport; the body is genesis-rewritten |
| `DummyTransport` — sentEmails, sendCount, simulated failure, simulated delay, message-id prefix, verify control, EventEmitter hooks | `providers/DummyProvider.ts` L23-L158 | extended: delay via injectable TimersLike; message-id suffix via injectable Rng |
| Gmail provider (OAuth2 / app-password / service-account) | `providers/GmailProvider.ts` L1-L316 | rewritten as `GmailRestTransport` — token acquisition is caller's concern; sends via REST over FetchLike |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| OTel metrics emitted from production paths and asserted with in-memory MeterProvider | Genesis-wide observability policy; upstream had none |
| Zod boundary validation on every option bundle | Genesis-wide schema-driven boundary policy |
| Universal-tier purity (no hard Node deps) | Genesis emails plugin is universal-tier; upstream was server-only |
| AbortSignal propagation through send / retry / queue paths | Genesis-wide cancellation policy |
| `defineCodes("EMAILER", [...])` typed error registry, every code thrown by ≥1 prod path | Genesis-wide error-code policy |
| `FakeSmtpClient` exported for downstream tests | Genesis-wide injectable-fake policy |
| 100% Istanbul coverage | Genesis-wide policy |
## Verification approach
- `src/core/email-service/email-service.test.ts` — lifecycle, hooks, stats, retry, queue, AbortSignal, error paths.
- `src/core/retry/retry.test.ts` — linear + exponential backoff, deterministic via injected timers + clock.
- `src/core/validate/validate.test.ts` — `validateMessage`, `formatAddress`, `formatSingleAddress`, `extractEmail`, `toEmailArray`.
- `src/transports/dummy/dummy-transport.test.ts` — initialise, send, verify, close, forced failure, simulated delay, message-id suffix, EventEmitter forwarding.
- `src/transports/smtp/smtp-transport.test.ts` — happy-path send + verify + close via `FakeSmtpClient`; init / verify failure paths.
- `src/transports/gmail-rest/gmail-rest-transport.test.ts` — MIME build, base64url encoding, success + failure paths against `FakeFetch`.
- `src/fakes/fake-smtp-client.test.ts` — scripted responses, FIFO consumption, abort, capture inspection.
- `src/error-codes.test.ts` — every code reachable; literal-type union assertion via `expectTypeOf`.
- `src/schemas/schemas.test.ts` — each Zod schema safeParse happy + sad path.
- `src/otel/metrics.test.ts` — real `MeterProvider` collects every instrument.
- `src/emailer.spec-conformance.test.ts` — observability, AbortSignal, generic threading, lifecycle dedup.
- `tests/dep-graph.test.ts` — package.json invariants (deps, sideEffects, exports).
- `tests/no-disallowed.test.ts` — no `vi.mock`, no `console.log`, no `export *` in feature barrels.
## Migration impact
None — additive only. No consumer in genesis imports `@suaveplan/emailer` yet.