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

13 KiB

Capability Spec: emailer

Package: @suaveplan/emailer Status: ACTIVE

Every requirement below carries a **Verified by:** line at authoring time.

ADDED Requirements

Requirement: Universal-tier email service

The package SHALL ship a universal-tier EmailService<TTransport extends EmailTransport> that orchestrates message validation, retry, queueing, and OTel emission against a pluggable EmailTransport. The package SHALL NOT depend on nodemailer, googleapis, or any other Node-only transport library — every transport is structural and supplied by the caller.

Scenario: send happy path

  • WHEN the caller invokes service.send(message) with a valid message and a configured transport
  • THEN the transport's send() is invoked exactly once, stats.sent increments, stats.lastSendTime is updated, the email:sent event fires, and the returned EmailSendResult.success is true.

Scenario: send validation failure

  • WHEN the caller invokes service.send(message) with a message missing to, subject, or both html / text
  • THEN the transport's send() is NOT invoked, stats.failed increments, the email:failed event fires, and the returned result is { success: false, error: EmailerError }.

Verified by: src/core/email-service/email-service.test.ts::"sends successfully via configured transport" AND src/core/email-service/email-service.test.ts::"records failure for invalid message"


Requirement: Nine lifecycle hooks

The package SHALL expose nine hook arrays — beforeValidate, afterValidate, beforeSend, afterSend, onError, afterError, beforeRetry, beforeQueue, afterQueue — each holding ordered HookFunction callbacks. EmailService SHALL invoke them in declared order, await async hooks, and support addHook / removeHook / clearHooks for runtime composition.

Scenario: hooks fire in order

  • WHEN beforeValidate, afterValidate, beforeSend, afterSend are each configured with one async hook and service.send succeeds
  • THEN the four hooks fire in the listed order before the success result is returned.

Scenario: addHook / removeHook / clearHooks

  • WHEN the caller invokes service.addHook("beforeSend", fn) then service.removeHook("beforeSend", fn) then service.send(message)
  • THEN the removed hook is NOT invoked. After service.clearHooks("beforeSend") no beforeSend hook is invoked.

Verified by: src/core/email-service/email-service.test.ts::"runs lifecycle hooks in declared order" AND src/core/email-service/email-service.test.ts::"addHook / removeHook / clearHooks mutate the hook list"


Requirement: Retry with linear and exponential backoff

The package SHALL retry transport failures up to retry.maxAttempts times using either linear (constant delay) or exponential (delay * 2^(attempt-1)) backoff. Retries SHALL be driven by an injectable TimersLike so tests are deterministic. stats.retries SHALL increment on each retry. beforeRetry hooks SHALL fire before each retry attempt.

Scenario: exponential backoff schedules increasing delays

  • WHEN retry = { maxAttempts: 3, delay: 100, backoff: "exponential" } and the transport fails twice then succeeds
  • THEN the injected timer is scheduled with delays [100, 200] (no third delay), stats.retries === 2, and the final result is success.

Scenario: retry honours AbortSignal

  • WHEN the caller aborts the supplied AbortSignal between the first failure and the next attempt
  • THEN service.send rejects with EMAILER.ABORTED and no further transport call is made.

Verified by: src/core/retry/retry.test.ts::"applies exponential backoff schedule" AND src/core/retry/retry.test.ts::"aborts pending retry on AbortSignal"


Requirement: Queue dispatch

The package SHALL accept an optional structural queue ({ add(fn): Promise<void> }) and SHALL expose service.queue(message) which fires beforeQueue, enqueues a closure that invokes send, fires afterQueue, increments stats.queued, and emits email:queued. If no queue is configured, service.queue SHALL throw EmailerError(EMAILER.QUEUE_NOT_CONFIGURED).

Scenario: queue enqueues sender closure

  • WHEN a QueueLike is supplied and service.queue(message) is called
  • THEN the queue's add is invoked once with a closure, stats.queued increments, and email:queued fires.

Scenario: queue rejects without configured queue

  • WHEN no queue is supplied and service.queue(message) is called
  • THEN the call rejects with EmailerError carrying EMAILER.QUEUE_NOT_CONFIGURED.

Verified by: src/core/email-service/email-service.test.ts::"queue() enqueues a closure that sends" AND src/core/email-service/email-service.test.ts::"queue() throws QUEUE_NOT_CONFIGURED when no queue is wired"


Requirement: Stable error-code registry

The package SHALL export EMAILER_CODES = defineCodes("EMAILER", [...]) and a typed EmailerError extends SuaveplanError<EmailerErrorCode>. Every registered code SHALL be thrown from at least one production code path; spec-conformance tests SHALL pin this.

Scenario: every code is reachable

  • WHEN the spec-conformance suite enumerates EMAILER_CODES
  • THEN each code is matched against a production throw site (or a structured emission via EmailerError); the test fails if any code is dead.

Verified by: src/error-codes.test.ts::"every registered code is reachable from a production throw site" AND src/emailer.spec-conformance.test.ts::"defineCodes literal-type union is preserved"


Requirement: Zod boundary validation

The package SHALL validate every public option bundle (EmailerOptions, EmailMessage, DummyTransportOptions, SmtpTransportOptions, GmailRestTransportOptions) at the boundary via Zod safeParse and SHALL raise EmailerError(EMAILER.INVALID_CONFIG) on failure, embedding the ZodError as cause.

Scenario: invalid config rejected

  • WHEN new EmailService({ transport: dummy, retry: { maxAttempts: -1, delay: 100 } }) is constructed
  • THEN the constructor throws EmailerError(EMAILER.INVALID_CONFIG) whose cause is a ZodError.

Verified by: src/schemas/schemas.test.ts::"rejects invalid EmailerOptions with ZodError" AND src/core/email-service/email-service.test.ts::"constructor validates options via Zod"


Requirement: OTel metrics emitted from production paths

The package SHALL emit emailer.send.total{outcome,transport}, emailer.send.errors{code,transport}, emailer.send.latency_ms{outcome,transport}, emailer.queue.depth{transport}, and emailer.retry.total{transport} on every relevant production call. Tests SHALL register an in-memory MeterProvider, invoke production paths, and assert the recorded values — no vi.mock of @opentelemetry/api.

Scenario: metrics observed against in-memory provider

  • WHEN an in-memory MeterProvider is installed via metrics.setGlobalMeterProvider, refreshEmailerMeters() is called, and service.send succeeds
  • THEN the collected metrics contain emailer.send.total{outcome=ok,transport=dummy}=1 and one latency observation with the same attributes.

Verified by: src/otel/metrics.test.ts::"collects send / error / latency / retry / queue instruments" AND src/emailer.spec-conformance.test.ts::"production paths emit declared OTel instruments"


Requirement: DummyTransport for tests and development

The package SHALL ship DummyTransport implementing EmailTransport with deterministic behaviour: it records every sent message in getSentEmails(), exposes getSendCount(), forwards lifecycle events to an injectable EventBusLike, supports forced failure via shouldFail, simulated latency via injectable TimersLike, configurable message-id prefix, and verifySuccess toggle.

Scenario: dummy transport records and forwards

  • WHEN DummyTransport is initialised with { messageIdPrefix: "test", events: bus } and send(message) is called
  • THEN the message-id begins with "test-", getSentEmails() contains the message, getSendCount() === 1, and the bus received provider:beforeSend followed by provider:sendSuccess.

Scenario: forced failure path

  • WHEN DummyTransport is initialised with shouldFail: true
  • THEN send(message) returns { success: false, error: <Error> } and the bus received provider:sendError.

Verified by: src/transports/dummy/dummy-transport.test.ts::"records sent messages and forwards bus events" AND src/transports/dummy/dummy-transport.test.ts::"returns failure result when shouldFail is true"


Requirement: SmtpTransport via injectable SmtpClientLike

The package SHALL ship SmtpTransport that accepts a caller-supplied SmtpClientLike adapter (sendMail, verify, close) so consumers wire nodemailer (or any SMTP library) in their own app shell without forcing the emailer to depend on it. The package SHALL also export FakeSmtpClient so downstream tests can stub the adapter without vi.mock.

Scenario: smtp send delegates to adapter

  • WHEN SmtpTransport is initialised with a FakeSmtpClient returning { messageId: "<abc@host>" } and send(message) is called
  • THEN the adapter's sendMail is invoked with the formatted address bag (string-or-object handled), and the returned result is { success: true, messageId: "<abc@host>" }.

Scenario: smtp verify failure surfaces VERIFY_FAILED

  • WHEN the adapter's verify() rejects
  • THEN transport.verify() resolves to false and the spec-conformance test pins the EMAILER.VERIFY_FAILED code path.

Verified by: src/transports/smtp/smtp-transport.test.ts::"delegates send to injected SmtpClientLike" AND src/transports/smtp/smtp-transport.test.ts::"surfaces verify failure as false"


Requirement: GmailRestTransport via FetchLike

The package SHALL ship GmailRestTransport that builds an RFC 822 MIME envelope, base64url-encodes it, and POSTs to https://gmail.googleapis.com/gmail/v1/users/{userId}/messages/send over an injectable FetchLike with a caller-supplied bearer accessToken. Failures SHALL surface EMAILER.TRANSPORT_FAILED.

Scenario: gmail rest send happy path

  • WHEN the transport is initialised with { accessToken: "ya29.xyz", userId: "me", fetch: fake } where fake is scripted to return { status: 200, json: { id: "abc" } } and send(message) is called
  • THEN one POST is captured to the expected URL with Authorization: Bearer ya29.xyz, the body's raw field decodes to the original MIME envelope, and the result is { success: true, messageId: "abc" }.

Scenario: gmail rest send 4xx error

  • WHEN the scripted response is { status: 401, json: { error: { message: "unauthorized" } } }
  • THEN the result is { success: false, error: EmailerError(EMAILER.TRANSPORT_FAILED) } and emailer.send.errors{code=EMAILER.TRANSPORT_FAILED,transport=gmail-rest} increments by 1.

Verified by: src/transports/gmail-rest/gmail-rest-transport.test.ts::"sends base64url MIME envelope via Gmail REST" AND src/transports/gmail-rest/gmail-rest-transport.test.ts::"reports TRANSPORT_FAILED on non-2xx response"


Requirement: BaseEmailTransport address-formatting helpers

The package SHALL ship a BaseEmailTransport abstract class providing formatAddress, formatSingleAddress, extractEmail, toEmailArray, createSuccessResult, createErrorResult, and validateMessage helpers used by every concrete transport.

Scenario: formatSingleAddress quotes the name

  • WHEN formatSingleAddress({ email: "a@b.c", name: "Alice" }) is called
  • THEN the return is "\"Alice\" <a@b.c>". A bare string passes through unchanged.

Scenario: extractEmail unwraps Name

  • WHEN extractEmail("\"Alice\" <a@b.c>") is called
  • THEN the return is "a@b.c". A bare email passes through unchanged.

Verified by: src/core/validate/validate.test.ts::"formatSingleAddress quotes display name" AND src/core/validate/validate.test.ts::"extractEmail unwraps angle-bracket form"


Requirement: Spec-conformance auto-audit

The package SHALL ship src/emailer.spec-conformance.test.ts pinning generic threading, literal-type unions, OTel emission, AbortSignal propagation, and lifecycle dedup so spec drift is caught by the test gate, not by reviewers.

Scenario: literal-type unions preserved

  • WHEN the conformance test asserts expectTypeOf<EmailerErrorCode>().toEqualTypeOf<...literal union...>()
  • THEN the test passes only when defineCodes returned a frozen { KEY: "EMAILER.KEY" } record.

Verified by: src/emailer.spec-conformance.test.ts::"defineCodes literal-type union is preserved" AND src/emailer.spec-conformance.test.ts::"production paths emit declared OTel instruments"