# 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` — 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.