Files
suaveplan/openspec/archive/2026-05-19-add-emailer/specs/emailer/spec.md
2026-05-27 23:29:53 +01:00

207 lines
13 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: emailer (add-emailer)
**Package**: `@suaveplan/emailer`
**Status**: PROPOSED
> 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 <addr>
- **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"`
<!-- markdownlint-enable MD024 -->