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.sentincrements,stats.lastSendTimeis updated, theemail:sentevent fires, and the returnedEmailSendResult.successistrue.
Scenario: send validation failure
- WHEN the caller invokes
service.send(message)with a message missingto,subject, or bothhtml/text - THEN the transport's
send()is NOT invoked,stats.failedincrements, theemail:failedevent 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,afterSendare each configured with one async hook andservice.sendsucceeds - 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)thenservice.removeHook("beforeSend", fn)thenservice.send(message) - THEN the removed hook is NOT invoked. After
service.clearHooks("beforeSend")nobeforeSendhook 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
AbortSignalbetween the first failure and the next attempt - THEN
service.sendrejects withEMAILER.ABORTEDand 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
QueueLikeis supplied andservice.queue(message)is called - THEN the queue's
addis invoked once with a closure,stats.queuedincrements, andemail:queuedfires.
Scenario: queue rejects without configured queue
- WHEN no queue is supplied and
service.queue(message)is called - THEN the call rejects with
EmailerErrorcarryingEMAILER.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)whosecauseis aZodError.
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
MeterProvideris installed viametrics.setGlobalMeterProvider,refreshEmailerMeters()is called, andservice.sendsucceeds - THEN the collected metrics contain
emailer.send.total{outcome=ok,transport=dummy}=1and 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
DummyTransportis initialised with{ messageIdPrefix: "test", events: bus }andsend(message)is called - THEN the message-id begins with
"test-",getSentEmails()contains the message,getSendCount() === 1, and the bus receivedprovider:beforeSendfollowed byprovider:sendSuccess.
Scenario: forced failure path
- WHEN
DummyTransportis initialised withshouldFail: true - THEN
send(message)returns{ success: false, error: <Error> }and the bus receivedprovider: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
SmtpTransportis initialised with aFakeSmtpClientreturning{ messageId: "<abc@host>" }andsend(message)is called - THEN the adapter's
sendMailis 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 tofalseand the spec-conformance test pins theEMAILER.VERIFY_FAILEDcode 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 }wherefakeis scripted to return{ status: 200, json: { id: "abc" } }andsend(message)is called - THEN one POST is captured to the expected URL with
Authorization: Bearer ya29.xyz, the body'srawfield 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) }andemailer.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
defineCodesreturned 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"