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

7.6 KiB

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.