Initial Commit
This commit is contained in:
206
openspec/specs/emailer/spec.md
Normal file
206
openspec/specs/emailer/spec.md
Normal file
@@ -0,0 +1,206 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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 <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 -->
|
||||
Reference in New Issue
Block a user