113 lines
7.6 KiB
Markdown
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.
|