Initial Commit
This commit is contained in:
112
openspec/archive/2026-05-19-add-emailer/proposal.md
Normal file
112
openspec/archive/2026-05-19-add-emailer/proposal.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# 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: `EmailTransport` interface, `BaseEmailTransport` abstract helper, and
|
||||
`EmailTransportLifecycle` (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 injectable `TimersLike`, deterministic message-id suffix via injectable
|
||||
`Rng`.
|
||||
- ADDED: `SmtpTransport` — universal SMTP transport that accepts an injectable
|
||||
`SmtpClientLike` adapter (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 REST `users.messages.send` endpoint
|
||||
over an injectable `FetchLike` with 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_CODES` via `defineCodes("EMAILER", [...])` — 12 stable error
|
||||
codes, each thrown by ≥1 production path.
|
||||
- ADDED: Zod schemas at every options boundary (`EmailMessage`,
|
||||
`EmailerOptionsSchema`, transport-specific schemas) — validated via `safeParse`.
|
||||
- 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` / `googleapis` dependencies — replaced with structural
|
||||
`SmtpClientLike` injection and direct Gmail REST calls over `FetchLike`.
|
||||
- IMAP / POP3 / inbound parsing — outbound only.
|
||||
- Address-book / template engines — `@suaveplan/templates`,
|
||||
`enterprise-email-templates`, and `@suaveplan/contacts` own those concerns.
|
||||
- Suppression-list enforcement — `@suaveplan/suppression` is consulted via a
|
||||
caller-supplied `beforeSend` hook; 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 via `FakeSmtpClient`; init / verify failure paths.
|
||||
- `src/transports/gmail-rest/gmail-rest-transport.test.ts` — MIME build, base64url encoding, success + failure paths against `FakeFetch`.
|
||||
- `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 via `expectTypeOf`.
|
||||
- `src/schemas/schemas.test.ts` — each Zod schema safeParse happy + sad path.
|
||||
- `src/otel/metrics.test.ts` — real `MeterProvider` collects 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` — no `vi.mock`, no `console.log`, no `export *` in feature barrels.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. No consumer in genesis imports `@suaveplan/emailer` yet.
|
||||
206
openspec/archive/2026-05-19-add-emailer/specs/emailer/spec.md
Normal file
206
openspec/archive/2026-05-19-add-emailer/specs/emailer/spec.md
Normal file
@@ -0,0 +1,206 @@
|
||||
<!-- 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 -->
|
||||
64
openspec/archive/2026-05-19-add-emailer/tasks.md
Normal file
64
openspec/archive/2026-05-19-add-emailer/tasks.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Tasks — Ship @suaveplan/emailer
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/emailer.md` end-to-end
|
||||
- [x] 1.2 No prior `openspec/specs/emailer/spec.md` exists
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.4 Draft `proposal.md`
|
||||
- [x] 1.5 Fill in `## Source mapping`
|
||||
- [x] 1.6 Draft `specs/emailer/spec.md`
|
||||
- [x] 1.7 Every requirement carries a `**Verified by:**` line
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 `src/error-codes.ts` — `defineCodes("EMAILER", [...])`, 12 codes, typed `EmailerError`
|
||||
- [x] 2.2 `src/schemas/schemas.ts` — Zod schemas for every option bundle and message
|
||||
- [x] 2.3 `src/types/emailer.types.ts` — `EmailMessage`, `EmailTransport`, hook contexts, stats
|
||||
- [x] 2.4 `src/otel/metrics.ts` — counters / histogram / `refreshEmailerMeters`
|
||||
- [x] 2.5 `src/core/validate/validate.ts` — address-formatting + validation helpers
|
||||
- [x] 2.6 `src/core/retry/retry.ts` — deterministic retry helper (linear / exponential, AbortSignal)
|
||||
- [x] 2.7 `src/core/email-service/email-service.ts` — `EmailService` class
|
||||
- [x] 2.8 `src/core/email-service/base-email-transport.ts` — abstract helper
|
||||
- [x] 2.9 `src/transports/dummy/dummy-transport.ts`
|
||||
- [x] 2.10 `src/transports/smtp/smtp-transport.ts` + `smtp-client-like.types.ts`
|
||||
- [x] 2.11 `src/transports/gmail-rest/gmail-rest-transport.ts`
|
||||
- [x] 2.12 `src/fakes/fake-smtp-client.ts`
|
||||
- [x] 2.13 `src/index.ts` — explicit named re-exports, side-effect import of `./error-codes.js`
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 Unit tests at every feature directory, all importing from `@suaveplan/testing/runner`
|
||||
- [x] 3.2 `src/emailer.spec-conformance.test.ts` — type-level, OTel, lifecycle pins
|
||||
- [x] 3.3 `tests/dep-graph.test.ts`, `tests/no-disallowed.test.ts`
|
||||
- [x] 3.4 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words)
|
||||
- [x] 4.2 README at package root linking every co-located `.md`
|
||||
|
||||
## 5. Pre-archive 5-gate triple
|
||||
|
||||
- [x] 5.1 `bun run check`
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/emailer`
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/emailer` (100/100/100/100)
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/emailer`
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/emailer`
|
||||
|
||||
## 6. Pre-archive 9-row checklist
|
||||
|
||||
- [x] 6.1 Generics threaded (`EmailService<TTransport>`); `expectTypeOf` on every literal-type claim
|
||||
- [x] 6.2 Zod schemas wired at every declared boundary
|
||||
- [x] 6.3 Every error code in `EMAILER_CODES` thrown by at least one production path
|
||||
- [x] 6.4 Strict-mode behaviours tested both on and off (validation, retry abort)
|
||||
- [x] 6.5 OTel instruments emitted from production paths and asserted with real in-memory `MeterProvider`
|
||||
- [x] 6.6 Lifecycle dedup tested (verify-fail short-circuit, queue-not-configured guard)
|
||||
- [x] 6.7 No retroactive tick
|
||||
- [x] 6.8 No `export *` in any non-root barrel
|
||||
- [x] 6.9 No direct `vitest` / `bun:test` imports in test files
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold spec delta into `openspec/specs/emailer/spec.md`
|
||||
- [x] 7.2 Move `openspec/changes/add-emailer/` → `openspec/archive/2026-05-19-add-emailer/`
|
||||
Reference in New Issue
Block a user