# 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` 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 }`) 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`. 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: }` 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: "" }` 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: "" }`. #### 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 bare string passes through unchanged. #### Scenario: extractEmail unwraps Name - **WHEN** `extractEmail("\"Alice\" ")` 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().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"`