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:
EmailTransportinterface,BaseEmailTransportabstract helper, andEmailTransportLifecycle(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 injectableTimersLike, deterministic message-id suffix via injectableRng. - ADDED:
SmtpTransport— universal SMTP transport that accepts an injectableSmtpClientLikeadapter (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 RESTusers.messages.sendendpoint over an injectableFetchLikewith 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_CODESviadefineCodes("EMAILER", [...])— 12 stable error codes, each thrown by ≥1 production path. - ADDED: Zod schemas at every options boundary (
EmailMessage,EmailerOptionsSchema, transport-specific schemas) — validated viasafeParse. - 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/googleapisdependencies — replaced with structuralSmtpClientLikeinjection and direct Gmail REST calls overFetchLike. - IMAP / POP3 / inbound parsing — outbound only.
- Address-book / template engines —
@suaveplan/templates,enterprise-email-templates, and@suaveplan/contactsown those concerns. - Suppression-list enforcement —
@suaveplan/suppressionis consulted via a caller-suppliedbeforeSendhook; 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 viaFakeSmtpClient; init / verify failure paths.src/transports/gmail-rest/gmail-rest-transport.test.ts— MIME build, base64url encoding, success + failure paths againstFakeFetch.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 viaexpectTypeOf.src/schemas/schemas.test.ts— each Zod schema safeParse happy + sad path.src/otel/metrics.test.ts— realMeterProvidercollects 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— novi.mock, noconsole.log, noexport *in feature barrels.
Migration impact
None — additive only. No consumer in genesis imports @suaveplan/emailer yet.