Initial Commit
This commit is contained in:
89
packages/communication/emails/emailer/README.md
Normal file
89
packages/communication/emails/emailer/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# @suaveplan/emailer - Universal email service with plugin transports (Dummy / SMTP via injectable SmtpClientLike / Gmail REST via FetchLike), nine lifecycle hooks, retry, queue dispatch, Zod-validated boundaries, OpenTelemetry-instrumented hot paths, injectable Clock/Rng/Logger/FetchLike/TimersLike, stable defineCodes('EMAILER', [...]) error registry.
|
||||
|
||||
Universal email service with pluggable transports. Ships
|
||||
`EmailService`, three transports (Dummy / SMTP via injectable
|
||||
`SmtpClientLike` / Gmail REST via injectable `FetchLike`), nine lifecycle
|
||||
hooks, deterministic retry, structural queue dispatch, Zod-validated option
|
||||
boundaries, OpenTelemetry-instrumented hot paths, and a stable
|
||||
`defineCodes("EMAILER", [...])` error registry.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [email-service](./src/core/email-service/email-service.md) — `EmailService<TTransport>` is the public entry point for `@suaveplan/emailer`. It orchestrates message validation, life…
|
||||
- [base-email-transport](./src/core/email-service/base-email-transport.md) — `BaseEmailTransport` is the abstract helper that every concrete transport (`DummyTransport`, `SmtpTransport`, `GmailRes…
|
||||
- [email-service](./src/core/email-service/email-service.md) — `EmailService<TTransport>` is the public entry point for `@suaveplan/emailer`. It orchestrates message validation, life…
|
||||
- [retry](./src/core/retry/retry.md) — Deterministic retry helper used by `EmailService.send`. The helper takes a `RetryPolicy`, an injectable `TimersLike`, a…
|
||||
- [retry](./src/core/retry/retry.md) — Deterministic retry helper used by `EmailService.send`. The helper takes a `RetryPolicy`, an injectable `TimersLike`, a…
|
||||
- [validate](./src/core/validate/validate.md) — Shared address-formatting and message-validation helpers used by every concrete transport and by `EmailService` itself.…
|
||||
- [validate](./src/core/validate/validate.md) — Shared address-formatting and message-validation helpers used by every concrete transport and by `EmailService` itself.…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/emailer` ships a single stable error-code registry that every failure path in the package routes through. T…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/emailer` ships a single stable error-code registry that every failure path in the package routes through. T…
|
||||
- [fake-fetch](./src/fakes/fake-fetch.md) — Deterministic test double for `FetchLike`, used by `GmailRestTransport` tests. Queues scripted responses and captures e…
|
||||
- [fake-smtp-client](./src/fakes/fake-smtp-client.md) — Deterministic test double for `SmtpClientLike`. Tests queue scripted responses, invoke the wrapped `SmtpTransport`, and…
|
||||
- [otel](./src/otel/metrics.md) — Single source of truth for every OpenTelemetry instrument emitted by `@suaveplan/emailer`. Every production path that t…
|
||||
- [metrics](./src/otel/metrics.md) — Single source of truth for every OpenTelemetry instrument emitted by `@suaveplan/emailer`. Every production path that t…
|
||||
- [schemas](./src/schemas/schemas.md) — Centralised Zod schemas for every public option bundle and wire payload in `@suaveplan/emailer`. The schemas exist to e…
|
||||
- [schemas](./src/schemas/schemas.md) — Centralised Zod schemas for every public option bundle and wire payload in `@suaveplan/emailer`. The schemas exist to e…
|
||||
- [dummy](./src/transports/dummy/dummy-transport.md) — `DummyTransport` is a deterministic in-memory transport designed for tests, fixtures, and local development. It records…
|
||||
- [dummy-transport](./src/transports/dummy/dummy-transport.md) — `DummyTransport` is a deterministic in-memory transport designed for tests, fixtures, and local development. It records…
|
||||
- [gmail-rest](./src/transports/gmail-rest/gmail-rest-transport.md) — `GmailRestTransport` sends mail through Gmail's REST API directly, over an injectable `FetchLike`. The transport builds…
|
||||
- [gmail-rest-transport](./src/transports/gmail-rest/gmail-rest-transport.md) — `GmailRestTransport` sends mail through Gmail's REST API directly, over an injectable `FetchLike`. The transport builds…
|
||||
- [smtp](./src/transports/smtp/smtp-transport.md) — `SmtpTransport` is a structural SMTP transport: it accepts a caller-supplied `SmtpClientLike` adapter (the contract is…
|
||||
- [smtp-transport](./src/transports/smtp/smtp-transport.md) — `SmtpTransport` is a structural SMTP transport: it accepts a caller-supplied `SmtpClientLike` adapter (the contract is…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Why universal
|
||||
|
||||
The upstream `@suaveplan/emailer` was server-only because it imported
|
||||
`nodemailer` and `googleapis` directly. The rewrite drops both hard
|
||||
dependencies in favour of injectable contracts:
|
||||
|
||||
- **SMTP**: callers supply a thin `SmtpClientLike` adapter so nodemailer
|
||||
(or any other library) lives in the consumer's app shell, not in this
|
||||
package.
|
||||
- **Gmail**: send is implemented directly against the Gmail REST API via
|
||||
`FetchLike`, so token acquisition is the caller's concern and the
|
||||
transport runs unchanged in Bun, Node, and the browser.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/core/email-service/email-service.md`](src/core/email-service/email-service.md) — `EmailService<TTransport>` orchestrator
|
||||
- [`src/core/email-service/base-email-transport.md`](src/core/email-service/base-email-transport.md) — abstract helper
|
||||
- [`src/core/retry/retry.md`](src/core/retry/retry.md) — deterministic retry helper
|
||||
- [`src/core/validate/validate.md`](src/core/validate/validate.md) — address-formatting helpers
|
||||
- [`src/transports/dummy/dummy-transport.md`](src/transports/dummy/dummy-transport.md) — in-memory test transport
|
||||
- [`src/transports/smtp/smtp-transport.md`](src/transports/smtp/smtp-transport.md) — SMTP via adapter
|
||||
- [`src/transports/smtp/smtp-client-like.types.md`](src/transports/smtp/smtp-client-like.types.md) — adapter contract
|
||||
- [`src/transports/gmail-rest/gmail-rest-transport.md`](src/transports/gmail-rest/gmail-rest-transport.md) — Gmail REST transport
|
||||
- [`src/error-codes.md`](src/error-codes.md) — error registry
|
||||
- [`src/schemas/schemas.md`](src/schemas/schemas.md) — Zod boundary schemas
|
||||
- [`src/types/emailer.types.md`](src/types/emailer.types.md) — public types
|
||||
- [`src/otel/metrics.md`](src/otel/metrics.md) — OTel instruments
|
||||
- [`src/fakes/fake-smtp-client.md`](src/fakes/fake-smtp-client.md) — deterministic SMTP fake
|
||||
- [`src/fakes/fake-fetch.md`](src/fakes/fake-fetch.md) — deterministic fetch fake
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { EmailService } from "@suaveplan/emailer";
|
||||
import { createDummyTransport } from "@suaveplan/emailer/transports/dummy";
|
||||
|
||||
const transport = createDummyTransport();
|
||||
const service = new EmailService({
|
||||
transport,
|
||||
transportOptions: {},
|
||||
defaultFrom: "no-reply@example.com",
|
||||
});
|
||||
await service.initialize();
|
||||
const result = await service.send({
|
||||
to: "user@example.com",
|
||||
subject: "hello",
|
||||
html: "<p>hi</p>",
|
||||
});
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
131
packages/communication/emails/emailer/package.json
Normal file
131
packages/communication/emails/emailer/package.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"name": "@suaveplan/emailer",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal email service with plugin transports (Dummy / SMTP via injectable SmtpClientLike / Gmail REST via FetchLike), nine lifecycle hooks, retry, queue dispatch, Zod-validated boundaries, OpenTelemetry-instrumented hot paths, injectable Clock/Rng/Logger/FetchLike/TimersLike, stable defineCodes('EMAILER', [...]) error registry.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./schemas/schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/schemas.d.ts",
|
||||
"default": "./dist/schemas/schemas.js"
|
||||
}
|
||||
},
|
||||
"./types/emailer": {
|
||||
"import": {
|
||||
"types": "./dist/types/emailer.types.d.ts",
|
||||
"default": "./dist/types/emailer.types.js"
|
||||
}
|
||||
},
|
||||
"./core/email-service": {
|
||||
"import": {
|
||||
"types": "./dist/core/email-service/email-service.d.ts",
|
||||
"default": "./dist/core/email-service/email-service.js"
|
||||
}
|
||||
},
|
||||
"./core/base-email-transport": {
|
||||
"import": {
|
||||
"types": "./dist/core/email-service/base-email-transport.d.ts",
|
||||
"default": "./dist/core/email-service/base-email-transport.js"
|
||||
}
|
||||
},
|
||||
"./core/retry": {
|
||||
"import": {
|
||||
"types": "./dist/core/retry/retry.d.ts",
|
||||
"default": "./dist/core/retry/retry.js"
|
||||
}
|
||||
},
|
||||
"./core/validate": {
|
||||
"import": {
|
||||
"types": "./dist/core/validate/validate.d.ts",
|
||||
"default": "./dist/core/validate/validate.js"
|
||||
}
|
||||
},
|
||||
"./transports/dummy": {
|
||||
"import": {
|
||||
"types": "./dist/transports/dummy/dummy-transport.d.ts",
|
||||
"default": "./dist/transports/dummy/dummy-transport.js"
|
||||
}
|
||||
},
|
||||
"./transports/smtp": {
|
||||
"import": {
|
||||
"types": "./dist/transports/smtp/smtp-transport.d.ts",
|
||||
"default": "./dist/transports/smtp/smtp-transport.js"
|
||||
}
|
||||
},
|
||||
"./transports/gmail-rest": {
|
||||
"import": {
|
||||
"types": "./dist/transports/gmail-rest/gmail-rest-transport.d.ts",
|
||||
"default": "./dist/transports/gmail-rest/gmail-rest-transport.js"
|
||||
}
|
||||
},
|
||||
"./fakes/fake-smtp-client": {
|
||||
"import": {
|
||||
"types": "./dist/fakes/fake-smtp-client.d.ts",
|
||||
"default": "./dist/fakes/fake-smtp-client.js"
|
||||
}
|
||||
},
|
||||
"./otel/metrics": {
|
||||
"import": {
|
||||
"types": "./dist/otel/metrics.d.ts",
|
||||
"default": "./dist/otel/metrics.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/logger": "workspace:^",
|
||||
"@suaveplan/queue": "workspace:^",
|
||||
"@suaveplan/timing": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
# `base-email-transport.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
`BaseEmailTransport` is the abstract helper that every concrete transport
|
||||
(`DummyTransport`, `SmtpTransport`, `GmailRestTransport`) extends. It
|
||||
centralises address formatting, message validation, initialisation
|
||||
bookkeeping, and result-construction so concrete transports stay focused
|
||||
on their wire format — not on cross-cutting concerns that would drift
|
||||
between implementations otherwise.
|
||||
|
||||
## Features
|
||||
|
||||
- `initialize(config)` records the config and flips an `initialized` flag;
|
||||
subclasses can override but typically just call `super.initialize`.
|
||||
- `assertInitialized()` raises `EmailerError(EMAILER.TRANSPORT_NOT_INITIALIZED)`
|
||||
if called before `initialize`, so every transport's `send` has a single
|
||||
guarded entry point.
|
||||
- `formatAddress`, `formatSingleAddress`, `extractEmail`, `toEmailArray` —
|
||||
thin proxies to `core/validate` so subclasses use the canonical
|
||||
formatters via `this.*`.
|
||||
- `createSuccessResult(messageId, response, metadata)` and
|
||||
`createErrorResult(error, metadata)` — produce `EmailSendResult` with the
|
||||
shape every transport returns. Conditional property assignment honours
|
||||
`exactOptionalPropertyTypes` from `tsconfig.base.json`.
|
||||
- `validateMessage(message)` — proxies to `core/validate.validateMessage`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BaseEmailTransport } from "@suaveplan/emailer/core/base-email-transport";
|
||||
|
||||
class NullTransport extends BaseEmailTransport<{}> {
|
||||
public readonly name = "null";
|
||||
public override async send(message) {
|
||||
this.assertInitialized();
|
||||
this.validateMessage(message);
|
||||
return this.createSuccessResult("null-1");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { BaseEmailTransport } from "@suaveplan/emailer/core/base-email-transport";
|
||||
import type { EmailMessage, EmailSendResult } from "@suaveplan/emailer/types/emailer";
|
||||
|
||||
interface MyConfig {
|
||||
readonly target: URL;
|
||||
}
|
||||
|
||||
export class MyTransport extends BaseEmailTransport<MyConfig> {
|
||||
public readonly name = "my-transport";
|
||||
|
||||
public override async send(message: EmailMessage): Promise<EmailSendResult> {
|
||||
this.assertInitialized();
|
||||
try {
|
||||
this.validateMessage(message);
|
||||
const recipients = this.toEmailArray(message.to);
|
||||
const from = this.formatSingleAddress(message.from ?? "noreply@x.y");
|
||||
// ... actual wire send ...
|
||||
return this.createSuccessResult("ok", { recipients, from });
|
||||
} catch (error) {
|
||||
return this.createErrorResult(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `abstract class BaseEmailTransport<TConfig>`
|
||||
- `abstract readonly name: string`
|
||||
- `initialize(config: TConfig): Promise<void>`
|
||||
- `abstract send(message: EmailMessage): Promise<EmailSendResult>`
|
||||
- `verify(): Promise<boolean>` — defaults to the `initialized` flag.
|
||||
- `close(): Promise<void>` — flips `initialized` to `false`.
|
||||
- `assertInitialized(): void` (protected)
|
||||
- `formatSingleAddress`, `formatAddress`, `extractEmail`, `toEmailArray`,
|
||||
`validateMessage`, `createSuccessResult`, `createErrorResult` (protected)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The class is universal — every helper avoids Node-only APIs so the
|
||||
class compiles cleanly under the package's `"lib": ["ES2022", "DOM"]`
|
||||
target.
|
||||
- `createSuccessResult` and `createErrorResult` use the `-readonly` mapped
|
||||
type trick to build up the result object piece-by-piece without
|
||||
violating `exactOptionalPropertyTypes`.
|
||||
- `assertInitialized` is the only throw site in the base class; every
|
||||
other helper returns or proxies.
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
EmailMessage,
|
||||
EmailSendResult,
|
||||
} from "../../types/emailer.types.js";
|
||||
import { BaseEmailTransport } from "./base-email-transport.js";
|
||||
|
||||
class MinimalTransport extends BaseEmailTransport<undefined> {
|
||||
public readonly name = "minimal";
|
||||
|
||||
public override async send(_: EmailMessage): Promise<EmailSendResult> {
|
||||
this.assertInitialized();
|
||||
return this.createSuccessResult("min-1", undefined, { ok: true });
|
||||
}
|
||||
|
||||
/** Expose protected helpers for assertion. */
|
||||
public exposeExtract(addr: string): string {
|
||||
return this.extractEmail(addr);
|
||||
}
|
||||
public exposeArray(bag: undefined): string[] {
|
||||
return this.toEmailArray(bag);
|
||||
}
|
||||
public exposeError(err: Error): EmailSendResult {
|
||||
return this.createErrorResult(err, { src: "test" });
|
||||
}
|
||||
public exposeSuccess(): EmailSendResult {
|
||||
return this.createSuccessResult("ok");
|
||||
}
|
||||
}
|
||||
|
||||
describe("BaseEmailTransport defaults", () => {
|
||||
it("verify returns false before initialize", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = new MinimalTransport();
|
||||
expect(await t.verify()).toBe(false);
|
||||
});
|
||||
|
||||
it("verify returns true after initialize", { timeout: 5000 }, async () => {
|
||||
const t = new MinimalTransport();
|
||||
await t.initialize(undefined);
|
||||
expect(await t.verify()).toBe(true);
|
||||
});
|
||||
|
||||
it("close flips initialized to false", { timeout: 5000 }, async () => {
|
||||
const t = new MinimalTransport();
|
||||
await t.initialize(undefined);
|
||||
await t.close();
|
||||
expect(await t.verify()).toBe(false);
|
||||
});
|
||||
|
||||
it("createSuccessResult with metadata sets metadata", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = new MinimalTransport();
|
||||
await t.initialize(undefined);
|
||||
const r = await t.send({ to: "a@b.c", subject: "x", text: "y" });
|
||||
expect(r.metadata).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("createErrorResult preserves metadata", { timeout: 5000 }, () => {
|
||||
const t = new MinimalTransport();
|
||||
const r = t.exposeError(new Error("e"));
|
||||
expect(r.metadata).toEqual({ src: "test" });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("createSuccessResult without response is success-only", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new MinimalTransport();
|
||||
const r = t.exposeSuccess();
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.response).toBeUndefined();
|
||||
expect(r.metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it("extractEmail proxy", { timeout: 5000 }, () => {
|
||||
const t = new MinimalTransport();
|
||||
expect(t.exposeExtract("plain@x.y")).toBe("plain@x.y");
|
||||
});
|
||||
|
||||
it("toEmailArray with undefined", { timeout: 5000 }, () => {
|
||||
const t = new MinimalTransport();
|
||||
expect(t.exposeArray(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
|
||||
import type {
|
||||
AddressBag,
|
||||
EmailAddress,
|
||||
EmailMessage,
|
||||
EmailSendResult,
|
||||
EmailTransport,
|
||||
} from "../../types/emailer.types.js";
|
||||
import {
|
||||
extractEmail,
|
||||
formatAddress,
|
||||
toEmailArray,
|
||||
validateMessage,
|
||||
} from "../validate/validate.js";
|
||||
|
||||
/**
|
||||
* Abstract helper that every concrete transport (`DummyTransport`,
|
||||
* `SmtpTransport`, `GmailRestTransport`) extends. Provides the
|
||||
* address-formatting / validation helpers and a small set of result
|
||||
* constructors so concrete transports keep their body free of
|
||||
* cross-cutting concerns.
|
||||
*/
|
||||
export abstract class BaseEmailTransport<TConfig = unknown>
|
||||
implements EmailTransport<TConfig>
|
||||
{
|
||||
public abstract readonly name: string;
|
||||
protected config: TConfig | undefined;
|
||||
protected initialized = false;
|
||||
|
||||
public async initialize(config: TConfig): Promise<void> {
|
||||
this.config = config;
|
||||
this.initialized = true;
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
public abstract send(message: EmailMessage): Promise<EmailSendResult>;
|
||||
|
||||
/** Default verify — returns whether `initialize` was called. */
|
||||
public async verify(): Promise<boolean> {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/** Default close — flips the `initialized` flag. */
|
||||
public async close(): Promise<void> {
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/** Guard used by every transport at the head of `send`. */
|
||||
protected assertInitialized(): void {
|
||||
if (!this.initialized) {
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.TRANSPORT_NOT_INITIALIZED,
|
||||
"emailer.errors.transportNotInitialized",
|
||||
{ context: { transport: this.name } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected formatAddress(
|
||||
address: AddressBag | undefined
|
||||
): string | string[] | undefined {
|
||||
return formatAddress(address);
|
||||
}
|
||||
|
||||
protected extractEmail(address: string | EmailAddress): string {
|
||||
return extractEmail(address);
|
||||
}
|
||||
|
||||
protected toEmailArray(address: AddressBag | undefined): string[] {
|
||||
return toEmailArray(address);
|
||||
}
|
||||
|
||||
protected validateMessage(message: EmailMessage): void {
|
||||
validateMessage(message);
|
||||
}
|
||||
|
||||
protected createSuccessResult(
|
||||
messageId: string,
|
||||
response?: unknown,
|
||||
metadata?: Readonly<Record<string, unknown>>
|
||||
): EmailSendResult {
|
||||
const out: {
|
||||
-readonly [K in keyof EmailSendResult]: EmailSendResult[K];
|
||||
} = {
|
||||
success: true,
|
||||
messageId,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
if (response !== undefined) out.response = response;
|
||||
if (metadata !== undefined) out.metadata = metadata;
|
||||
return out;
|
||||
}
|
||||
|
||||
protected createErrorResult(
|
||||
error: Error,
|
||||
metadata?: Readonly<Record<string, unknown>>
|
||||
): EmailSendResult {
|
||||
const out: {
|
||||
-readonly [K in keyof EmailSendResult]: EmailSendResult[K];
|
||||
} = {
|
||||
success: false,
|
||||
error,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
if (metadata !== undefined) out.metadata = metadata;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# `email-service.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`EmailService<TTransport>` is the public entry point for `@suaveplan/emailer`.
|
||||
It orchestrates message validation, lifecycle hooks, retry with backoff,
|
||||
queue dispatch, OpenTelemetry emission, structured logging, and running
|
||||
statistics against any `EmailTransport` implementation. The service is
|
||||
generic over the transport so type-level capability (e.g. that a particular
|
||||
transport's config bag is `GmailRestTransportOptions`) propagates through
|
||||
`EmailService.initialize` without runtime widening.
|
||||
|
||||
## Features
|
||||
|
||||
- Validates `EmailerOptions` via Zod `safeParse` in the constructor; throws
|
||||
`EmailerError(EMAILER.INVALID_CONFIG)` carrying the `ZodError` on bad
|
||||
input.
|
||||
- Nine lifecycle hook arrays (`beforeValidate`, `afterValidate`,
|
||||
`beforeSend`, `afterSend`, `onError`, `afterError`, `beforeRetry`,
|
||||
`beforeQueue`, `afterQueue`) with `addHook` / `removeHook` / `clearHooks`.
|
||||
- Retry with linear or exponential backoff via injectable `TimersLike` and
|
||||
`AbortSignal`.
|
||||
- Optional queue dispatch via structural `QueueLike` (`{ add(fn) }`).
|
||||
- OTel emission on every send / error / retry / queue path.
|
||||
- Stats: `sent`, `failed`, `queued`, `retries`, `startTime`, `lastSendTime`.
|
||||
- `initialize` invokes `transport.initialize(transportOptions)` then
|
||||
`transport.verify()` if present; verification failure raises
|
||||
`EMAILER.VERIFY_FAILED`.
|
||||
- `close` calls the transport's `close` and emits `service:closed`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmailService } from "@suaveplan/emailer/core/email-service";
|
||||
import { createDummyTransport } from "@suaveplan/emailer/transports/dummy";
|
||||
|
||||
const service = new EmailService({
|
||||
transport: createDummyTransport(),
|
||||
transportOptions: {},
|
||||
defaultFrom: "no-reply@example.com",
|
||||
});
|
||||
await service.initialize();
|
||||
const result = await service.send({
|
||||
to: "user@example.com",
|
||||
subject: "hello",
|
||||
text: "hi",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { EmailService } from "@suaveplan/emailer/core/email-service";
|
||||
import { GmailRestTransport } from "@suaveplan/emailer/transports/gmail-rest";
|
||||
import { FakeFetch } from "@suaveplan/emailer/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, json: { id: "abc123" } });
|
||||
|
||||
const ac = new AbortController();
|
||||
const service = new EmailService({
|
||||
transport: new GmailRestTransport(),
|
||||
transportOptions: { accessToken: "ya29.test", fetch: fake.fetch },
|
||||
retry: { maxAttempts: 3, delay: 50, backoff: "exponential" },
|
||||
signal: ac.signal,
|
||||
hooks: {
|
||||
beforeSend: [async (ctx) => logger.info({ to: ctx.message.to })],
|
||||
afterSend: [async (ctx) => audit.record(ctx.result.messageId)],
|
||||
},
|
||||
});
|
||||
await service.initialize();
|
||||
const result = await service.send({ to: "x@y.z", subject: "hi", text: "hi" });
|
||||
console.info(service.getStats().sent); // 1
|
||||
await service.close();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class EmailService<TTransport extends EmailTransport>`
|
||||
- `constructor(options: EmailerOptions<TTransport>)`
|
||||
- `initialize(): Promise<void>`
|
||||
- `send(message: EmailMessage): Promise<EmailSendResult>`
|
||||
- `queue(message: EmailMessage): Promise<void>`
|
||||
- `addHook<K>(event, hook)` / `removeHook<K>(event, hook)` / `clearHooks<K>(event)`
|
||||
- `getStats(): Readonly<EmailerStats>` / `resetStats(): void`
|
||||
- `close(): Promise<void>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `send` returns `EmailSendResult` (never throws) — failures surface as
|
||||
`{ success: false, error }` so callers can decide whether to log, retry,
|
||||
or escalate without a `try`/`catch`.
|
||||
- `queue` throws `EMAILER.QUEUE_NOT_CONFIGURED` if no `QueueLike` was
|
||||
supplied; that is the only public method that uses an exception for
|
||||
control flow.
|
||||
- The Zod validation in the constructor only checks the serialisable
|
||||
subset of options. Live objects (transport, logger, bus, signal) pass
|
||||
through unchanged because Zod cannot meaningfully validate them.
|
||||
- Stats are mutable internally but the `getStats()` snapshot is a copy so
|
||||
callers cannot observe a partially-mutated counter.
|
||||
@@ -0,0 +1,451 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Clock, LoggerLike, TimersLike } from "@suaveplan/types";
|
||||
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
createDummyTransport,
|
||||
type DummyTransport,
|
||||
} from "../../transports/dummy/dummy-transport.js";
|
||||
import type {
|
||||
EmailerHooks,
|
||||
EmailHookContext,
|
||||
EmailMessage,
|
||||
EmailResultHookContext,
|
||||
EventBusLike,
|
||||
QueueLike,
|
||||
} from "../../types/emailer.types.js";
|
||||
import { EmailService } from "./email-service.js";
|
||||
|
||||
function recorder(): EventBusLike & { events: Array<[string, unknown]> } {
|
||||
const events: Array<[string, unknown]> = [];
|
||||
return {
|
||||
events,
|
||||
emit: (e, p) => {
|
||||
events.push([e, p]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const syncTimers: TimersLike = {
|
||||
setTimer: (cb) => {
|
||||
cb();
|
||||
return 0;
|
||||
},
|
||||
clearTimer: () => {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
|
||||
function fixedClock(): Clock {
|
||||
let now = 1000;
|
||||
return () => {
|
||||
now += 1;
|
||||
return now;
|
||||
};
|
||||
}
|
||||
|
||||
function makeService(
|
||||
overrides: Partial<{
|
||||
hooks: EmailerHooks;
|
||||
transport: DummyTransport;
|
||||
queue?: QueueLike;
|
||||
retry?: {
|
||||
maxAttempts: number;
|
||||
delay: number;
|
||||
backoff?: "linear" | "exponential";
|
||||
};
|
||||
events?: EventBusLike;
|
||||
}> = {}
|
||||
): { service: EmailService<DummyTransport>; transport: DummyTransport } {
|
||||
const transport = overrides.transport ?? createDummyTransport();
|
||||
const service = new EmailService<DummyTransport>({
|
||||
transport,
|
||||
transportOptions: {},
|
||||
defaultFrom: { email: "noreply@example.com", name: "NoReply" },
|
||||
clock: fixedClock(),
|
||||
timers: syncTimers,
|
||||
...(overrides.hooks !== undefined ? { hooks: overrides.hooks } : {}),
|
||||
...(overrides.queue !== undefined ? { queue: overrides.queue } : {}),
|
||||
...(overrides.retry !== undefined ? { retry: overrides.retry } : {}),
|
||||
...(overrides.events !== undefined ? { events: overrides.events } : {}),
|
||||
});
|
||||
return { service, transport };
|
||||
}
|
||||
|
||||
const validMessage: EmailMessage = {
|
||||
to: "user@example.com",
|
||||
subject: "hello",
|
||||
text: "hi",
|
||||
};
|
||||
|
||||
describe("EmailService", () => {
|
||||
it("sends successfully via configured transport", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service, transport } = makeService();
|
||||
await service.initialize();
|
||||
const r = await service.send({ ...validMessage });
|
||||
expect(r.success).toBe(true);
|
||||
expect(transport.getSendCount()).toBe(1);
|
||||
expect(service.getStats().sent).toBe(1);
|
||||
expect(service.getStats().lastSendTime).toBeDefined();
|
||||
});
|
||||
|
||||
it("records failure for invalid message", { timeout: 5000 }, async () => {
|
||||
const events = recorder();
|
||||
const { service } = makeService({ events });
|
||||
await service.initialize();
|
||||
const r = await service.send({
|
||||
to: "",
|
||||
subject: "hi",
|
||||
text: "hi",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
expect(service.getStats().failed).toBe(1);
|
||||
expect(events.events.some(([e]) => e === "email:failed")).toBe(true);
|
||||
});
|
||||
|
||||
it("runs lifecycle hooks in declared order", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const order: string[] = [];
|
||||
const hooks: EmailerHooks = {
|
||||
beforeValidate: [
|
||||
async () => {
|
||||
order.push("beforeValidate");
|
||||
},
|
||||
],
|
||||
afterValidate: [
|
||||
async () => {
|
||||
order.push("afterValidate");
|
||||
},
|
||||
],
|
||||
beforeSend: [
|
||||
async () => {
|
||||
order.push("beforeSend");
|
||||
},
|
||||
],
|
||||
afterSend: [
|
||||
async () => {
|
||||
order.push("afterSend");
|
||||
},
|
||||
],
|
||||
};
|
||||
const { service } = makeService({ hooks });
|
||||
await service.initialize();
|
||||
await service.send({ ...validMessage });
|
||||
expect(order).toEqual([
|
||||
"beforeValidate",
|
||||
"afterValidate",
|
||||
"beforeSend",
|
||||
"afterSend",
|
||||
]);
|
||||
});
|
||||
|
||||
it("addHook / removeHook / clearHooks mutate the hook list", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
await service.initialize();
|
||||
let calls = 0;
|
||||
const hook = async (_ctx: EmailHookContext): Promise<void> => {
|
||||
calls += 1;
|
||||
};
|
||||
service.addHook("beforeSend", hook);
|
||||
await service.send({ ...validMessage });
|
||||
expect(calls).toBe(1);
|
||||
service.removeHook("beforeSend", hook);
|
||||
await service.send({ ...validMessage });
|
||||
expect(calls).toBe(1);
|
||||
service.addHook("beforeSend", hook);
|
||||
service.clearHooks("beforeSend");
|
||||
await service.send({ ...validMessage });
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("queue() enqueues a closure that sends", { timeout: 5000 }, async () => {
|
||||
let captured: (() => Promise<void>) | undefined;
|
||||
const queue: QueueLike = {
|
||||
add: async (fn) => {
|
||||
captured = fn;
|
||||
},
|
||||
};
|
||||
const { service, transport } = makeService({ queue });
|
||||
await service.initialize();
|
||||
await service.queue({ ...validMessage });
|
||||
expect(service.getStats().queued).toBe(1);
|
||||
expect(captured).toBeDefined();
|
||||
await captured?.();
|
||||
expect(transport.getSendCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("queue() throws QUEUE_NOT_CONFIGURED when no queue is wired", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
await service.initialize();
|
||||
await expect(service.queue({ ...validMessage })).rejects.toMatchObject({
|
||||
code: EMAILER_CODES.QUEUE_NOT_CONFIGURED,
|
||||
});
|
||||
});
|
||||
|
||||
it("retries failed sends with stats increment", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport = createDummyTransport();
|
||||
await transport.initialize({ shouldFail: true });
|
||||
const { service } = makeService({
|
||||
transport,
|
||||
retry: { maxAttempts: 3, delay: 1, backoff: "linear" },
|
||||
});
|
||||
// skip initialize since we manually set transport
|
||||
const r = await service.send({ ...validMessage });
|
||||
expect(r.success).toBe(false);
|
||||
expect(service.getStats().retries).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("constructor validates options via Zod", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new EmailService({
|
||||
transport: createDummyTransport(),
|
||||
transportOptions: {},
|
||||
retry: { maxAttempts: -1, delay: 0 },
|
||||
});
|
||||
expect.unreachable();
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(EmailerError);
|
||||
expect((e as EmailerError).code).toBe(EMAILER_CODES.INVALID_CONFIG);
|
||||
}
|
||||
});
|
||||
|
||||
it("initialize raises VERIFY_FAILED when transport.verify fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport = createDummyTransport();
|
||||
const service = new EmailService<DummyTransport>({
|
||||
transport,
|
||||
transportOptions: { verifySuccess: false },
|
||||
clock: fixedClock(),
|
||||
timers: syncTimers,
|
||||
});
|
||||
await expect(service.initialize()).rejects.toMatchObject({
|
||||
code: EMAILER_CODES.VERIFY_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies defaultFrom when message.from missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
await service.initialize();
|
||||
const r = await service.send({ ...validMessage });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("resetStats clears counters", { timeout: 5000 }, async () => {
|
||||
const { service } = makeService();
|
||||
await service.initialize();
|
||||
await service.send({ ...validMessage });
|
||||
service.resetStats();
|
||||
const stats = service.getStats();
|
||||
expect(stats.sent).toBe(0);
|
||||
expect(stats.failed).toBe(0);
|
||||
expect(stats.lastSendTime).toBeUndefined();
|
||||
});
|
||||
|
||||
it("close calls transport.close and emits service:closed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const events = recorder();
|
||||
const { service } = makeService({ events });
|
||||
await service.initialize();
|
||||
await service.close();
|
||||
expect(events.events.some(([e]) => e === "service:closed")).toBe(true);
|
||||
});
|
||||
|
||||
it("afterSend receives the result context", { timeout: 5000 }, async () => {
|
||||
let captured: EmailResultHookContext | undefined;
|
||||
const hooks: EmailerHooks = {
|
||||
afterSend: [
|
||||
async (ctx) => {
|
||||
captured = ctx;
|
||||
},
|
||||
],
|
||||
};
|
||||
const { service } = makeService({ hooks });
|
||||
await service.initialize();
|
||||
await service.send({ ...validMessage });
|
||||
expect(captured?.result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("noop logger / event bus when not supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport = createDummyTransport();
|
||||
const service = new EmailService<DummyTransport>({
|
||||
transport,
|
||||
transportOptions: {},
|
||||
clock: fixedClock(),
|
||||
timers: syncTimers,
|
||||
});
|
||||
await service.initialize();
|
||||
const r = await service.send({ ...validMessage });
|
||||
expect(r.success).toBe(true);
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("uses supplied logger when available", { timeout: 5000 }, async () => {
|
||||
const logged: string[] = [];
|
||||
const logger: LoggerLike = {
|
||||
debug: () => undefined,
|
||||
info: (msg) => {
|
||||
logged.push(msg);
|
||||
},
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
const transport = createDummyTransport();
|
||||
const service = new EmailService<DummyTransport>({
|
||||
transport,
|
||||
transportOptions: {},
|
||||
clock: fixedClock(),
|
||||
timers: syncTimers,
|
||||
logger,
|
||||
});
|
||||
await service.initialize();
|
||||
await service.send({ ...validMessage });
|
||||
expect(logged.some((s) => s.startsWith("emailer."))).toBe(true);
|
||||
});
|
||||
|
||||
it("beforeQueue / afterQueue fire around queue dispatch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const order: string[] = [];
|
||||
const queue: QueueLike = {
|
||||
add: async (fn) => {
|
||||
order.push("add");
|
||||
await fn();
|
||||
},
|
||||
};
|
||||
const hooks: EmailerHooks = {
|
||||
beforeQueue: [
|
||||
async () => {
|
||||
order.push("beforeQueue");
|
||||
},
|
||||
],
|
||||
afterQueue: [
|
||||
async () => {
|
||||
order.push("afterQueue");
|
||||
},
|
||||
],
|
||||
};
|
||||
const { service } = makeService({ queue, hooks });
|
||||
await service.initialize();
|
||||
await service.queue({ ...validMessage });
|
||||
expect(order).toEqual(["beforeQueue", "add", "afterQueue"]);
|
||||
});
|
||||
|
||||
it("send returns failed result when transport throws non-Emailer error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport = createDummyTransport();
|
||||
await transport.initialize({});
|
||||
// monkey-patch a single send to throw a plain error
|
||||
const original = transport.send.bind(transport);
|
||||
let calls = 0;
|
||||
transport.send = async (m) => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error("boom");
|
||||
return original(m);
|
||||
};
|
||||
const service = new EmailService<DummyTransport>({
|
||||
transport,
|
||||
transportOptions: undefined,
|
||||
clock: fixedClock(),
|
||||
timers: syncTimers,
|
||||
});
|
||||
const r = await service.send({ ...validMessage });
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error?.message).toBe("boom");
|
||||
});
|
||||
|
||||
it("initialize skips transport.initialize when transportOptions undefined", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport = createDummyTransport();
|
||||
await transport.initialize({}); // pre-initialise manually
|
||||
const service = new EmailService<DummyTransport>({
|
||||
transport,
|
||||
clock: fixedClock(),
|
||||
timers: syncTimers,
|
||||
});
|
||||
await service.initialize();
|
||||
const r = await service.send({ ...validMessage });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("close skips transport.close when transport has no close method", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport = createDummyTransport();
|
||||
await transport.initialize({});
|
||||
// strip close
|
||||
(transport as unknown as { close?: () => Promise<void> }).close =
|
||||
undefined;
|
||||
const service = new EmailService<DummyTransport>({
|
||||
transport,
|
||||
clock: fixedClock(),
|
||||
timers: syncTimers,
|
||||
});
|
||||
await expect(service.close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("initialize skips verify when transport.verify undefined", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport = createDummyTransport();
|
||||
await transport.initialize({});
|
||||
(transport as unknown as { verify?: () => Promise<boolean> }).verify =
|
||||
undefined;
|
||||
const service = new EmailService<DummyTransport>({
|
||||
transport,
|
||||
clock: fixedClock(),
|
||||
timers: syncTimers,
|
||||
});
|
||||
await expect(service.initialize()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("removeHook is a no-op when hook is not registered", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
await service.initialize();
|
||||
service.removeHook("beforeSend", async () => undefined);
|
||||
const r = await service.send({ ...validMessage });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("beforeRetry fires between attempts", { timeout: 5000 }, async () => {
|
||||
const transport = createDummyTransport();
|
||||
await transport.initialize({ shouldFail: true });
|
||||
const retryEvents: number[] = [];
|
||||
const hooks: EmailerHooks = {
|
||||
beforeRetry: [
|
||||
async (ctx) => {
|
||||
if (ctx.attempt !== undefined) {
|
||||
retryEvents.push(ctx.attempt);
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
const service = new EmailService<DummyTransport>({
|
||||
transport,
|
||||
transportOptions: undefined,
|
||||
retry: { maxAttempts: 3, delay: 0 },
|
||||
hooks,
|
||||
clock: fixedClock(),
|
||||
timers: syncTimers,
|
||||
});
|
||||
await service.send({ ...validMessage });
|
||||
expect(retryEvents.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
import type { Clock, LoggerLike, TimersLike } from "@suaveplan/types";
|
||||
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
recordQueue,
|
||||
recordRetry,
|
||||
recordSend,
|
||||
recordSendError,
|
||||
} from "../../otel/metrics.js";
|
||||
import { EmailerOptionsShapeSchema } from "../../schemas/schemas.js";
|
||||
import type {
|
||||
EmailErrorHookContext,
|
||||
EmailerHooks,
|
||||
EmailerOptions,
|
||||
EmailerStats,
|
||||
EmailHookContext,
|
||||
EmailMessage,
|
||||
EmailResultHookContext,
|
||||
EmailSendResult,
|
||||
EmailTransport,
|
||||
EventBusLike,
|
||||
HookFunction,
|
||||
MutableEmailerHooks,
|
||||
QueueLike,
|
||||
RetryPolicy,
|
||||
} from "../../types/emailer.types.js";
|
||||
import { defaultTimers, runWithRetry } from "../retry/retry.js";
|
||||
import { validateMessage as validateEmail } from "../validate/validate.js";
|
||||
|
||||
const noop = (): void => {
|
||||
/* shared no-op */
|
||||
};
|
||||
|
||||
const noopLogger: LoggerLike = {
|
||||
debug: noop,
|
||||
info: noop,
|
||||
warn: noop,
|
||||
error: noop,
|
||||
};
|
||||
|
||||
const noopBus: EventBusLike = { emit: noop };
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
function emptyHooks(): MutableEmailerHooks {
|
||||
return {
|
||||
beforeValidate: [],
|
||||
afterValidate: [],
|
||||
beforeSend: [],
|
||||
afterSend: [],
|
||||
onError: [],
|
||||
afterError: [],
|
||||
beforeRetry: [],
|
||||
beforeQueue: [],
|
||||
afterQueue: [],
|
||||
};
|
||||
}
|
||||
|
||||
function mergeHooks(input: EmailerHooks | undefined): MutableEmailerHooks {
|
||||
const out = emptyHooks();
|
||||
if (input === undefined) return out;
|
||||
for (const key of Object.keys(out) as Array<keyof MutableEmailerHooks>) {
|
||||
const incoming = input[key];
|
||||
if (incoming !== undefined) {
|
||||
(out[key] as Array<HookFunction<EmailHookContext>>).push(
|
||||
...(incoming as ReadonlyArray<HookFunction<EmailHookContext>>)
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal email service. Orchestrates validation, lifecycle hooks,
|
||||
* retry, queue dispatch, OTel emission, and stats accumulation against a
|
||||
* pluggable `EmailTransport`.
|
||||
*/
|
||||
export class EmailService<TTransport extends EmailTransport = EmailTransport> {
|
||||
readonly #transport: TTransport;
|
||||
readonly #defaultFrom: EmailerOptions<TTransport>["defaultFrom"];
|
||||
readonly #logger: LoggerLike;
|
||||
readonly #events: EventBusLike;
|
||||
readonly #queue: QueueLike | undefined;
|
||||
readonly #retry: RetryPolicy | undefined;
|
||||
readonly #hooks: MutableEmailerHooks;
|
||||
readonly #clock: Clock;
|
||||
readonly #timers: TimersLike;
|
||||
readonly #signal: AbortSignal | undefined;
|
||||
readonly #transportOptions: unknown;
|
||||
readonly #stats: EmailerStats;
|
||||
|
||||
public constructor(options: EmailerOptions<TTransport>) {
|
||||
const parsed = EmailerOptionsShapeSchema.safeParse({
|
||||
defaultFrom: options.defaultFrom,
|
||||
retry: options.retry,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.INVALID_CONFIG,
|
||||
"emailer.errors.invalidConfig",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
this.#transport = options.transport;
|
||||
this.#defaultFrom = options.defaultFrom;
|
||||
this.#logger = options.logger ?? noopLogger;
|
||||
this.#events = options.events ?? noopBus;
|
||||
this.#queue = options.queue;
|
||||
this.#retry = options.retry;
|
||||
this.#hooks = mergeHooks(options.hooks);
|
||||
this.#clock = options.clock ?? defaultClock;
|
||||
this.#timers = options.timers ?? defaultTimers;
|
||||
this.#signal = options.signal;
|
||||
this.#transportOptions = options.transportOptions;
|
||||
this.#stats = {
|
||||
sent: 0,
|
||||
failed: 0,
|
||||
queued: 0,
|
||||
retries: 0,
|
||||
startTime: new Date(this.#clock()),
|
||||
};
|
||||
}
|
||||
|
||||
/** Initialise the underlying transport. */
|
||||
public async initialize(): Promise<void> {
|
||||
this.#logger.info("emailer.initialize", {
|
||||
transport: this.#transport.name,
|
||||
});
|
||||
if (this.#transportOptions !== undefined) {
|
||||
await this.#transport.initialize(
|
||||
this.#transportOptions as Parameters<
|
||||
TTransport["initialize"]
|
||||
>[0]
|
||||
);
|
||||
}
|
||||
if (this.#transport.verify !== undefined) {
|
||||
const ok = await this.#transport.verify();
|
||||
if (!ok) {
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.VERIFY_FAILED,
|
||||
"emailer.errors.verifyFailed",
|
||||
{ context: { transport: this.#transport.name } }
|
||||
);
|
||||
}
|
||||
}
|
||||
this.#events.emit("service:initialized", {
|
||||
transport: this.#transport.name,
|
||||
});
|
||||
}
|
||||
|
||||
/** Send `message`. */
|
||||
public async send(message: EmailMessage): Promise<EmailSendResult> {
|
||||
const context: EmailHookContext = {
|
||||
message,
|
||||
metadata: {},
|
||||
timestamp: new Date(this.#clock()),
|
||||
};
|
||||
const startedAt = this.#clock();
|
||||
try {
|
||||
if (message.from === undefined && this.#defaultFrom !== undefined) {
|
||||
message.from = this.#defaultFrom;
|
||||
}
|
||||
await this.#runHooks(this.#hooks.beforeValidate, context);
|
||||
validateEmail(message);
|
||||
await this.#runHooks(this.#hooks.afterValidate, context);
|
||||
await this.#runHooks(this.#hooks.beforeSend, context);
|
||||
const result = await this.#sendWithRetry(message, context);
|
||||
const resultContext: EmailResultHookContext = {
|
||||
...context,
|
||||
result,
|
||||
};
|
||||
await this.#runHooks(this.#hooks.afterSend, resultContext);
|
||||
this.#stats.sent += 1;
|
||||
this.#stats.lastSendTime = new Date(this.#clock());
|
||||
this.#events.emit("email:sent", { message, result });
|
||||
this.#logger.info("emailer.sent", { messageId: result.messageId });
|
||||
recordSend(this.#transport.name, "ok", this.#clock() - startedAt);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
const errorContext: EmailErrorHookContext = {
|
||||
...context,
|
||||
error: err,
|
||||
};
|
||||
await this.#runHooks(this.#hooks.onError, errorContext);
|
||||
await this.#runHooks(this.#hooks.afterError, errorContext);
|
||||
this.#stats.failed += 1;
|
||||
this.#events.emit("email:failed", { message, error: err });
|
||||
this.#logger.error("emailer.failed", { error: err.message });
|
||||
const code =
|
||||
err instanceof EmailerError
|
||||
? err.code
|
||||
: EMAILER_CODES.TRANSPORT_FAILED;
|
||||
recordSendError(this.#transport.name, code);
|
||||
recordSend(
|
||||
this.#transport.name,
|
||||
"error",
|
||||
this.#clock() - startedAt
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: err,
|
||||
timestamp: new Date(this.#clock()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async #sendWithRetry(
|
||||
message: EmailMessage,
|
||||
context: EmailHookContext
|
||||
): Promise<EmailSendResult> {
|
||||
const policy = this.#retry ?? { maxAttempts: 1, delay: 0 };
|
||||
const transport = this.#transport;
|
||||
const transportName = transport.name;
|
||||
const hooks = this.#hooks;
|
||||
const runHooks = this.#runHooks.bind(this);
|
||||
const stats = this.#stats;
|
||||
return runWithRetry<EmailSendResult>(
|
||||
policy,
|
||||
this.#timers,
|
||||
this.#signal,
|
||||
{
|
||||
attempt: (attempt: number) =>
|
||||
attempt === 1
|
||||
? transport.send(message)
|
||||
: transport.send(message),
|
||||
isSuccess: (result) =>
|
||||
result.success && result.error === undefined,
|
||||
extractError: (result) => result.error,
|
||||
onBeforeRetry: async (error, nextAttempt) => {
|
||||
const errorContext: EmailErrorHookContext = {
|
||||
...context,
|
||||
error,
|
||||
attempt: nextAttempt,
|
||||
};
|
||||
await runHooks(hooks.beforeRetry, errorContext);
|
||||
},
|
||||
onRetryIncrement: () => {
|
||||
stats.retries += 1;
|
||||
recordRetry(transportName);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Queue `message` for asynchronous send. */
|
||||
public async queue(message: EmailMessage): Promise<void> {
|
||||
if (this.#queue === undefined) {
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.QUEUE_NOT_CONFIGURED,
|
||||
"emailer.errors.queueNotConfigured"
|
||||
);
|
||||
}
|
||||
const context: EmailHookContext = {
|
||||
message,
|
||||
metadata: {},
|
||||
timestamp: new Date(this.#clock()),
|
||||
};
|
||||
await this.#runHooks(this.#hooks.beforeQueue, context);
|
||||
const sendFn = (): Promise<void> =>
|
||||
this.send(message).then(() => undefined);
|
||||
await this.#queue.add(sendFn);
|
||||
await this.#runHooks(this.#hooks.afterQueue, context);
|
||||
this.#stats.queued += 1;
|
||||
recordQueue(this.#transport.name);
|
||||
this.#events.emit("email:queued", { message });
|
||||
this.#logger.info("emailer.queued", {});
|
||||
}
|
||||
|
||||
async #runHooks<TContext>(
|
||||
hooks: ReadonlyArray<HookFunction<TContext>>,
|
||||
context: TContext
|
||||
): Promise<void> {
|
||||
for (const hook of hooks) {
|
||||
await hook(context);
|
||||
}
|
||||
}
|
||||
|
||||
/** Append a hook. */
|
||||
public addHook<K extends keyof EmailerHooks>(
|
||||
event: K,
|
||||
hook: NonNullable<EmailerHooks[K]>[number]
|
||||
): void {
|
||||
(this.#hooks[event] as Array<typeof hook>).push(hook);
|
||||
}
|
||||
|
||||
/** Remove a hook by identity. */
|
||||
public removeHook<K extends keyof EmailerHooks>(
|
||||
event: K,
|
||||
hook: NonNullable<EmailerHooks[K]>[number]
|
||||
): void {
|
||||
const list = this.#hooks[event] as Array<typeof hook>;
|
||||
const index = list.indexOf(hook);
|
||||
if (index >= 0) list.splice(index, 1);
|
||||
}
|
||||
|
||||
/** Clear every hook for `event`. */
|
||||
public clearHooks<K extends keyof EmailerHooks>(event: K): void {
|
||||
(this.#hooks[event] as Array<unknown>).length = 0;
|
||||
}
|
||||
|
||||
/** Read-only stats snapshot. */
|
||||
public getStats(): Readonly<EmailerStats> {
|
||||
return { ...this.#stats };
|
||||
}
|
||||
|
||||
/** Reset stats to a fresh snapshot. */
|
||||
public resetStats(): void {
|
||||
this.#stats.sent = 0;
|
||||
this.#stats.failed = 0;
|
||||
this.#stats.queued = 0;
|
||||
this.#stats.retries = 0;
|
||||
this.#stats.startTime = new Date(this.#clock());
|
||||
delete this.#stats.lastSendTime;
|
||||
}
|
||||
|
||||
/** Close the underlying transport. */
|
||||
public async close(): Promise<void> {
|
||||
this.#logger.info("emailer.close", { transport: this.#transport.name });
|
||||
if (this.#transport.close !== undefined) {
|
||||
await this.#transport.close();
|
||||
}
|
||||
this.#events.emit("service:closed", {
|
||||
transport: this.#transport.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { BaseEmailTransport } from "./base-email-transport.js";
|
||||
export { EmailService } from "./email-service.js";
|
||||
18
packages/communication/emails/emailer/src/core/index.ts
Normal file
18
packages/communication/emails/emailer/src/core/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export {
|
||||
BaseEmailTransport,
|
||||
EmailService,
|
||||
} from "./email-service/index.js";
|
||||
export {
|
||||
computeBackoff,
|
||||
defaultTimers,
|
||||
delay,
|
||||
type RetryHooks,
|
||||
runWithRetry,
|
||||
} from "./retry/retry.js";
|
||||
export {
|
||||
extractEmail,
|
||||
formatAddress,
|
||||
formatSingleAddress,
|
||||
toEmailArray,
|
||||
validateMessage,
|
||||
} from "./validate/validate.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
computeBackoff,
|
||||
defaultTimers,
|
||||
delay,
|
||||
type RetryHooks,
|
||||
runWithRetry,
|
||||
} from "./retry.js";
|
||||
@@ -0,0 +1,96 @@
|
||||
# `retry.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Deterministic retry helper used by `EmailService.send`. The helper takes a
|
||||
`RetryPolicy`, an injectable `TimersLike`, an optional `AbortSignal`, and a
|
||||
`RetryHooks` bag, then drives a bounded attempt loop with linear or
|
||||
exponential backoff. Injecting the timers means tests can drive the loop
|
||||
through synthetic delays without scheduling real `setTimeout` callbacks,
|
||||
which is essential for deterministic CI runs on WSL2.
|
||||
|
||||
## Features
|
||||
|
||||
- `computeBackoff(policy, attempt)` — returns the delay between `attempt`
|
||||
and `attempt + 1` (linear: constant `delay`; exponential: `delay * 2^(n-1)`).
|
||||
- `delay(timers, delayMs, signal)` — schedules a single delay and rejects
|
||||
early if `signal` is aborted.
|
||||
- `runWithRetry<TResult>(policy, timers, signal, hooks)` — top-level loop:
|
||||
invokes `hooks.attempt(n)`, checks `hooks.isSuccess`, fires
|
||||
`hooks.onError` / `hooks.onBeforeRetry`, increments retry stats via
|
||||
`hooks.onRetryIncrement`, and sleeps `computeBackoff` between failed
|
||||
attempts.
|
||||
- `defaultTimers` — host-default `setTimeout` / `clearTimeout` pair used
|
||||
when no `TimersLike` is supplied.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runWithRetry, defaultTimers } from "@suaveplan/emailer/core/retry";
|
||||
|
||||
const result = await runWithRetry(
|
||||
{ maxAttempts: 3, delay: 100, backoff: "exponential" },
|
||||
defaultTimers,
|
||||
undefined,
|
||||
{
|
||||
attempt: () => transport.send(message),
|
||||
isSuccess: (r) => r.success,
|
||||
extractError: (r) => r.error,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { runWithRetry } from "@suaveplan/emailer/core/retry";
|
||||
import type { TimersLike } from "@suaveplan/types";
|
||||
|
||||
const fakeTimers: TimersLike = {
|
||||
setTimer: (cb) => {
|
||||
cb();
|
||||
return 0;
|
||||
},
|
||||
clearTimer: () => undefined,
|
||||
};
|
||||
|
||||
const ac = new AbortController();
|
||||
const result = await runWithRetry(
|
||||
{ maxAttempts: 5, delay: 50, backoff: "linear" },
|
||||
fakeTimers,
|
||||
ac.signal,
|
||||
{
|
||||
attempt: (n) => transport.send({ ...message, headers: { "X-Attempt": String(n) } }),
|
||||
isSuccess: (r) => r.success,
|
||||
extractError: (r) => r.error,
|
||||
onError: async (err, n) => logger.warn("send-failed", { err, n }),
|
||||
onBeforeRetry: async (err, next) => logger.info("retrying", { next }),
|
||||
onRetryIncrement: () => stats.retries++,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `computeBackoff(policy: RetryPolicy, attempt: number): number`
|
||||
- `delay(timers: TimersLike, delayMs: number, signal: AbortSignal | undefined): Promise<void>`
|
||||
- `runWithRetry<TResult>(policy, timers, signal, hooks): Promise<TResult>`
|
||||
- `defaultTimers: TimersLike`
|
||||
- `RetryHooks<TResult>` — interface
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The helper does not throw on a failed-but-defined result; it returns it
|
||||
unchanged so the caller can decide whether to surface the result or
|
||||
raise. This is what enables `EmailService` to record a stat increment
|
||||
and emit `email:failed` while still returning a result to the caller.
|
||||
- `AbortSignal` is honoured at three points: before every attempt, before
|
||||
every sleep, and as a sleep listener. Aborting between attempts raises
|
||||
`EmailerError(EMAILER.ABORTED)` rather than the last transport error.
|
||||
- The default `RetryPolicy` (`{ maxAttempts: 1, delay: 0 }`) effectively
|
||||
disables retry. `EmailService` substitutes this when no `retry` option
|
||||
is passed; the helper runs once and returns the result unchanged.
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { TimersLike } from "@suaveplan/types";
|
||||
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
|
||||
import { computeBackoff, defaultTimers, delay, runWithRetry } from "./retry.js";
|
||||
|
||||
interface FakeTimers extends TimersLike {
|
||||
readonly delays: number[];
|
||||
}
|
||||
|
||||
function fakeTimers(): FakeTimers {
|
||||
const delays: number[] = [];
|
||||
let counter = 1;
|
||||
return {
|
||||
delays,
|
||||
setTimer(cb, delayMs) {
|
||||
delays.push(delayMs);
|
||||
cb();
|
||||
return counter++;
|
||||
},
|
||||
clearTimer() {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeBackoff", () => {
|
||||
it("returns constant for linear", { timeout: 5000 }, () => {
|
||||
expect(computeBackoff({ maxAttempts: 3, delay: 100 }, 1)).toBe(100);
|
||||
expect(
|
||||
computeBackoff({ maxAttempts: 3, delay: 100, backoff: "linear" }, 3)
|
||||
).toBe(100);
|
||||
});
|
||||
|
||||
it("doubles for exponential", { timeout: 5000 }, () => {
|
||||
const p = {
|
||||
maxAttempts: 4,
|
||||
delay: 50,
|
||||
backoff: "exponential" as const,
|
||||
};
|
||||
expect(computeBackoff(p, 1)).toBe(50);
|
||||
expect(computeBackoff(p, 2)).toBe(100);
|
||||
expect(computeBackoff(p, 3)).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delay", () => {
|
||||
it("rejects immediately when already aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(delay(defaultTimers, 5, ac.signal)).rejects.toBeInstanceOf(
|
||||
EmailerError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when aborted during sleep", { timeout: 5000 }, async () => {
|
||||
const ac = new AbortController();
|
||||
const p = delay(defaultTimers, 50, ac.signal);
|
||||
ac.abort();
|
||||
await expect(p).rejects.toBeInstanceOf(EmailerError);
|
||||
});
|
||||
|
||||
it("resolves after the timer fires", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
delay(fakeTimers(), 10, undefined)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes abort listener when timer fires first", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ac = new AbortController();
|
||||
await expect(
|
||||
delay(fakeTimers(), 5, ac.signal)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWithRetry", () => {
|
||||
it("returns first success without sleeping", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const timers = fakeTimers();
|
||||
const result = await runWithRetry<{ success: true }>(
|
||||
{ maxAttempts: 3, delay: 10 },
|
||||
timers,
|
||||
undefined,
|
||||
{
|
||||
attempt: async () => ({ success: true as const }),
|
||||
isSuccess: (r) => r.success,
|
||||
extractError: () => undefined,
|
||||
}
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(timers.delays).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies exponential backoff schedule", { timeout: 5000 }, async () => {
|
||||
const timers = fakeTimers();
|
||||
let calls = 0;
|
||||
const result = await runWithRetry<{ success: boolean; error?: Error }>(
|
||||
{ maxAttempts: 3, delay: 100, backoff: "exponential" },
|
||||
timers,
|
||||
undefined,
|
||||
{
|
||||
attempt: async () => {
|
||||
calls += 1;
|
||||
if (calls < 3) {
|
||||
return { success: false, error: new Error("nope") };
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
isSuccess: (r) => r.success,
|
||||
extractError: (r) => r.error,
|
||||
onRetryIncrement: () => {
|
||||
/* counted */
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(timers.delays).toEqual([100, 200]);
|
||||
});
|
||||
|
||||
it("aborts pending retry on AbortSignal", { timeout: 5000 }, async () => {
|
||||
const ac = new AbortController();
|
||||
let calls = 0;
|
||||
const promise = runWithRetry<{ success: boolean; error?: Error }>(
|
||||
{ maxAttempts: 5, delay: 50 },
|
||||
defaultTimers,
|
||||
ac.signal,
|
||||
{
|
||||
attempt: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
queueMicrotask(() => ac.abort());
|
||||
}
|
||||
return { success: false, error: new Error("nope") };
|
||||
},
|
||||
isSuccess: (r) => r.success,
|
||||
extractError: (r) => r.error,
|
||||
}
|
||||
);
|
||||
await expect(promise).rejects.toBeInstanceOf(EmailerError);
|
||||
});
|
||||
|
||||
it("with maxAttempts === 1 throws underlying error when no result", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const timers = fakeTimers();
|
||||
try {
|
||||
await runWithRetry<{ success: boolean }>(
|
||||
{ maxAttempts: 1, delay: 0 },
|
||||
timers,
|
||||
undefined,
|
||||
{
|
||||
attempt: async () => {
|
||||
throw new Error("plain boom");
|
||||
},
|
||||
isSuccess: (r) => r.success,
|
||||
extractError: () => undefined,
|
||||
}
|
||||
);
|
||||
expect.unreachable();
|
||||
} catch (e) {
|
||||
expect((e as Error).message).toBe("plain boom");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws RETRY_EXHAUSTED when extractor returns undefined on every failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const timers = fakeTimers();
|
||||
try {
|
||||
await runWithRetry<{ success: boolean }>(
|
||||
{ maxAttempts: 2, delay: 0 },
|
||||
timers,
|
||||
undefined,
|
||||
{
|
||||
attempt: async () => ({ success: false }),
|
||||
isSuccess: (r) => r.success,
|
||||
extractError: () => undefined,
|
||||
}
|
||||
);
|
||||
expect.unreachable();
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(EmailerError);
|
||||
expect((e as EmailerError).code).toBe(
|
||||
EMAILER_CODES.RETRY_EXHAUSTED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("fires onError and onBeforeRetry between attempts", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const timers = fakeTimers();
|
||||
const events: string[] = [];
|
||||
let calls = 0;
|
||||
await runWithRetry<{ success: boolean; error?: Error }>(
|
||||
{ maxAttempts: 3, delay: 0 },
|
||||
timers,
|
||||
undefined,
|
||||
{
|
||||
attempt: async () => {
|
||||
calls += 1;
|
||||
if (calls < 2) {
|
||||
return { success: false, error: new Error("nope") };
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
isSuccess: (r) => r.success,
|
||||
extractError: (r) => r.error,
|
||||
onError: async () => {
|
||||
events.push("onError");
|
||||
},
|
||||
onBeforeRetry: async () => {
|
||||
events.push("onBeforeRetry");
|
||||
},
|
||||
onRetryIncrement: () => {
|
||||
events.push("inc");
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(events).toEqual(["onError", "onBeforeRetry", "inc"]);
|
||||
});
|
||||
|
||||
it("returns the last failed result when isSuccess is never true and extractor exists", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const timers = fakeTimers();
|
||||
const result = await runWithRetry<{ success: boolean; error?: Error }>(
|
||||
{ maxAttempts: 2, delay: 0 },
|
||||
timers,
|
||||
undefined,
|
||||
{
|
||||
attempt: async () => ({
|
||||
success: false,
|
||||
error: new Error("nope"),
|
||||
}),
|
||||
isSuccess: (r) => r.success,
|
||||
extractError: (r) => r.error,
|
||||
}
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("throws ABORTED when signal is already set at loop entry", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
runWithRetry<{ success: boolean }>(
|
||||
{ maxAttempts: 1, delay: 0 },
|
||||
defaultTimers,
|
||||
ac.signal,
|
||||
{
|
||||
attempt: async () => ({ success: false }),
|
||||
isSuccess: (r) => r.success,
|
||||
extractError: () => undefined,
|
||||
}
|
||||
)
|
||||
).rejects.toBeInstanceOf(EmailerError);
|
||||
});
|
||||
});
|
||||
149
packages/communication/emails/emailer/src/core/retry/retry.ts
Normal file
149
packages/communication/emails/emailer/src/core/retry/retry.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { TimersLike } from "@suaveplan/types";
|
||||
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
|
||||
import type { RetryPolicy } from "../../types/emailer.types.js";
|
||||
|
||||
/** Default timer pair — `setTimeout` / `clearTimeout` from the host. */
|
||||
export const defaultTimers: TimersLike = {
|
||||
setTimer: (callback, delayMs) =>
|
||||
setTimeout(callback, delayMs) as unknown as number,
|
||||
clearTimer: (handle) => {
|
||||
clearTimeout(handle);
|
||||
},
|
||||
};
|
||||
|
||||
/** Compute backoff delay for `attempt` (1-indexed). */
|
||||
export function computeBackoff(policy: RetryPolicy, attempt: number): number {
|
||||
if (policy.backoff === "exponential") {
|
||||
return policy.delay * 2 ** (attempt - 1);
|
||||
}
|
||||
return policy.delay;
|
||||
}
|
||||
|
||||
/** Schedule a delay via the injectable timer, with AbortSignal short-circuit. */
|
||||
export function delay(
|
||||
timers: TimersLike,
|
||||
delayMs: number,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(
|
||||
new EmailerError(
|
||||
EMAILER_CODES.ABORTED,
|
||||
"emailer.errors.aborted"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const handle = timers.setTimer(() => {
|
||||
if (signal !== undefined) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
resolve();
|
||||
}, delayMs);
|
||||
function onAbort(): void {
|
||||
timers.clearTimer(handle);
|
||||
reject(
|
||||
new EmailerError(
|
||||
EMAILER_CODES.ABORTED,
|
||||
"emailer.errors.aborted"
|
||||
)
|
||||
);
|
||||
}
|
||||
if (signal !== undefined) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface RetryHooks<TResult> {
|
||||
readonly attempt: (attempt: number) => Promise<TResult>;
|
||||
readonly onError?: (error: Error, attempt: number) => Promise<void>;
|
||||
readonly onBeforeRetry?: (
|
||||
error: Error,
|
||||
nextAttempt: number
|
||||
) => Promise<void>;
|
||||
readonly onRetryIncrement?: () => void;
|
||||
readonly isSuccess: (result: TResult) => boolean;
|
||||
readonly extractError: (result: TResult) => Error | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `hooks.attempt` up to `policy.maxAttempts` times. Between attempts
|
||||
* the helper schedules a backoff delay via the injected timers (so tests
|
||||
* are deterministic) and honours `signal` for cancellation.
|
||||
*/
|
||||
export async function runWithRetry<TResult>(
|
||||
policy: RetryPolicy,
|
||||
timers: TimersLike,
|
||||
signal: AbortSignal | undefined,
|
||||
hooks: RetryHooks<TResult>
|
||||
): Promise<TResult> {
|
||||
let lastError: Error | undefined;
|
||||
let lastResult: TResult | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
|
||||
if (signal?.aborted) {
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.ABORTED,
|
||||
"emailer.errors.aborted"
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await hooks.attempt(attempt);
|
||||
if (hooks.isSuccess(result)) {
|
||||
return result;
|
||||
}
|
||||
const reportedError = hooks.extractError(result);
|
||||
if (reportedError !== undefined) {
|
||||
lastError = reportedError;
|
||||
lastResult = result;
|
||||
} else {
|
||||
lastError = new EmailerError(
|
||||
EMAILER_CODES.TRANSPORT_FAILED,
|
||||
"emailer.errors.transportFailed"
|
||||
);
|
||||
// Do not set lastResult: caller cannot meaningfully consume
|
||||
// a non-success with no extractable error.
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
}
|
||||
|
||||
const isLast = attempt >= policy.maxAttempts;
|
||||
if (!isLast && lastError !== undefined) {
|
||||
if (hooks.onError !== undefined) {
|
||||
await hooks.onError(lastError, attempt);
|
||||
}
|
||||
const nextAttempt = attempt + 1;
|
||||
if (hooks.onBeforeRetry !== undefined) {
|
||||
await hooks.onBeforeRetry(lastError, nextAttempt);
|
||||
}
|
||||
if (hooks.onRetryIncrement !== undefined) {
|
||||
hooks.onRetryIncrement();
|
||||
}
|
||||
await delay(timers, computeBackoff(policy, attempt), signal);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastResult !== undefined && !hooks.isSuccess(lastResult)) {
|
||||
return lastResult;
|
||||
}
|
||||
// All attempts exhausted with no usable result. When the policy
|
||||
// permits multiple attempts, surface the typed `RETRY_EXHAUSTED` so
|
||||
// callers can distinguish "transient failure rolled over" from the
|
||||
// first-attempt error. With `maxAttempts === 1`, re-throw the
|
||||
// underlying error so the caller sees the cause without an extra wrap.
|
||||
// lastError is guaranteed at this point: maxAttempts >= 1 (Zod-enforced),
|
||||
// so the loop ran ≥1 iteration. Either lastResult is set (handled above)
|
||||
// or every iteration assigned lastError.
|
||||
const cause = lastError as Error;
|
||||
if (policy.maxAttempts > 1) {
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.RETRY_EXHAUSTED,
|
||||
"emailer.errors.retryExhausted",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
extractEmail,
|
||||
formatAddress,
|
||||
formatSingleAddress,
|
||||
toEmailArray,
|
||||
validateMessage,
|
||||
} from "./validate.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
# `validate.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared address-formatting and message-validation helpers used by every
|
||||
concrete transport and by `EmailService` itself. Keeping these helpers in
|
||||
one module means the wire-format contract for `to` / `from` / `cc` / `bcc`
|
||||
/ `replyTo` is identical across `DummyTransport`, `SmtpTransport`, and
|
||||
`GmailRestTransport`, and that the "to / subject / body" required-field
|
||||
check is enforced at a single throw site rather than five subtly-different
|
||||
copies.
|
||||
|
||||
## Features
|
||||
|
||||
- `formatSingleAddress` — `{name, email}` → `"name" <email>` per RFC 5322.
|
||||
- `formatAddress` — address-bag (string | object | array) → string |
|
||||
string[].
|
||||
- `extractEmail` — strip the display name and unwrap `<addr>`.
|
||||
- `toEmailArray` — address-bag → string[] (used by accept/reject lists).
|
||||
- `validateMessage` — throws `EmailerError(EMAILER.INVALID_MESSAGE)` if the
|
||||
message lacks a recipient, subject, or both bodies.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
formatSingleAddress,
|
||||
validateMessage,
|
||||
} from "@suaveplan/emailer/core/validate";
|
||||
|
||||
formatSingleAddress({ email: "a@b.c", name: "Alice" });
|
||||
// → "\"Alice\" <a@b.c>"
|
||||
|
||||
validateMessage({ to: "x@y.z", subject: "hello", text: "hi" });
|
||||
// → no throw
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
extractEmail,
|
||||
formatAddress,
|
||||
toEmailArray,
|
||||
} from "@suaveplan/emailer/core/validate";
|
||||
|
||||
const bag = ["alice@example.com", { email: "bob@example.com", name: "Bob" }];
|
||||
const wire = formatAddress(bag);
|
||||
// → ["alice@example.com", "\"Bob\" <bob@example.com>"]
|
||||
|
||||
const emails = toEmailArray(wire);
|
||||
// → ["alice@example.com", "bob@example.com"]
|
||||
|
||||
const raw = extractEmail("\"Charlie\" <c@e.com>");
|
||||
// → "c@e.com"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `formatSingleAddress(address: string | EmailAddress): string`
|
||||
- `formatAddress(address: AddressBag | undefined): string | string[] | undefined`
|
||||
- `extractEmail(address: string | EmailAddress): string`
|
||||
- `toEmailArray(address: AddressBag | undefined): string[]`
|
||||
- `validateMessage(message: EmailMessage): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `validateMessage` raises `EmailerError(EMAILER.INVALID_MESSAGE)` with the
|
||||
failing field path (`to` / `subject` / `body`) carried as `context.field`
|
||||
so downstream loggers know which field to call out.
|
||||
- `extractEmail` accepts bare email strings (returns unchanged), addresses
|
||||
in `Name <addr>` form (extracted), and `EmailAddress` objects (returns
|
||||
`.email`). It deliberately does not validate the extracted string; that
|
||||
is the boundary's job, not the formatter's.
|
||||
- `formatAddress` returns `undefined` (not an empty string) when the input
|
||||
is undefined so callers can spread the result into wire payloads without
|
||||
emitting an empty header.
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { EmailerError } from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
extractEmail,
|
||||
formatAddress,
|
||||
formatSingleAddress,
|
||||
toEmailArray,
|
||||
validateMessage,
|
||||
} from "./validate.js";
|
||||
|
||||
describe("validate helpers", () => {
|
||||
it("formatSingleAddress quotes display name", { timeout: 5000 }, () => {
|
||||
expect(formatSingleAddress({ email: "a@b.c", name: "Alice" })).toBe(
|
||||
'"Alice" <a@b.c>'
|
||||
);
|
||||
expect(formatSingleAddress({ email: "a@b.c" })).toBe("a@b.c");
|
||||
expect(formatSingleAddress("plain@x.y")).toBe("plain@x.y");
|
||||
expect(formatSingleAddress({ email: "a@b.c", name: "" })).toBe("a@b.c");
|
||||
});
|
||||
|
||||
it("formatAddress handles arrays and undefined", { timeout: 5000 }, () => {
|
||||
expect(formatAddress(undefined)).toBeUndefined();
|
||||
expect(formatAddress("a@b.c")).toBe("a@b.c");
|
||||
expect(formatAddress({ email: "a@b.c" })).toBe("a@b.c");
|
||||
const arr = formatAddress(["x@y.z", { email: "a@b.c", name: "A" }]);
|
||||
expect(arr).toEqual(["x@y.z", '"A" <a@b.c>']);
|
||||
});
|
||||
|
||||
it("extractEmail unwraps angle-bracket form", { timeout: 5000 }, () => {
|
||||
expect(extractEmail('"Alice" <a@b.c>')).toBe("a@b.c");
|
||||
expect(extractEmail("plain@x.y")).toBe("plain@x.y");
|
||||
expect(extractEmail({ email: "obj@x.y" })).toBe("obj@x.y");
|
||||
expect(extractEmail("no-brackets here")).toBe("no-brackets here");
|
||||
});
|
||||
|
||||
it("toEmailArray normalises bags", { timeout: 5000 }, () => {
|
||||
expect(toEmailArray(undefined)).toEqual([]);
|
||||
expect(toEmailArray("a@b.c")).toEqual(["a@b.c"]);
|
||||
expect(toEmailArray(["a@b.c", { email: "x@y.z" }])).toEqual([
|
||||
"a@b.c",
|
||||
"x@y.z",
|
||||
]);
|
||||
});
|
||||
|
||||
it("validateMessage accepts a complete message", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
validateMessage({ to: "a@b.c", subject: "hi", text: "hi" })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateMessage({ to: "a@b.c", subject: "hi", html: "<p>hi</p>" })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("validateMessage rejects missing to", { timeout: 5000 }, () => {
|
||||
try {
|
||||
validateMessage({ to: "", subject: "hi", text: "hi" });
|
||||
expect.unreachable();
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(EmailerError);
|
||||
}
|
||||
try {
|
||||
validateMessage({ to: [], subject: "hi", text: "hi" });
|
||||
expect.unreachable();
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(EmailerError);
|
||||
}
|
||||
});
|
||||
|
||||
it("validateMessage rejects missing subject", { timeout: 5000 }, () => {
|
||||
try {
|
||||
validateMessage({ to: "a@b.c", subject: "", text: "hi" });
|
||||
expect.unreachable();
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(EmailerError);
|
||||
}
|
||||
});
|
||||
|
||||
it("validateMessage rejects missing body", { timeout: 5000 }, () => {
|
||||
try {
|
||||
validateMessage({ to: "a@b.c", subject: "hi" });
|
||||
expect.unreachable();
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(EmailerError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
|
||||
import type {
|
||||
AddressBag,
|
||||
EmailAddress,
|
||||
EmailMessage,
|
||||
} from "../../types/emailer.types.js";
|
||||
|
||||
/** Format a single address into RFC 5322 form. */
|
||||
export function formatSingleAddress(address: string | EmailAddress): string {
|
||||
if (typeof address === "string") return address;
|
||||
if (address.name !== undefined && address.name.length > 0) {
|
||||
return `"${address.name}" <${address.email}>`;
|
||||
}
|
||||
return address.email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an address-bag into the shape expected by SMTP / Gmail wire
|
||||
* envelopes. `undefined` passes through. Arrays return an array; bare
|
||||
* values return a single string.
|
||||
*/
|
||||
export function formatAddress(
|
||||
address: AddressBag | undefined
|
||||
): string | string[] | undefined {
|
||||
if (address === undefined) return undefined;
|
||||
if (Array.isArray(address)) {
|
||||
return address.map(formatSingleAddress);
|
||||
}
|
||||
return formatSingleAddress(address as string | EmailAddress);
|
||||
}
|
||||
|
||||
/** Extract the email portion from a string-or-object address. */
|
||||
export function extractEmail(address: string | EmailAddress): string {
|
||||
if (typeof address === "string") {
|
||||
const match = address.match(/<([^>]+)>/);
|
||||
if (match !== null && match[1] !== undefined) {
|
||||
return match[1];
|
||||
}
|
||||
return address;
|
||||
}
|
||||
return address.email;
|
||||
}
|
||||
|
||||
/** Extract every email address from an address-bag as a string array. */
|
||||
export function toEmailArray(address: AddressBag | undefined): string[] {
|
||||
if (address === undefined) return [];
|
||||
if (Array.isArray(address)) {
|
||||
return address.map((a) => extractEmail(a as string | EmailAddress));
|
||||
}
|
||||
return [extractEmail(address as string | EmailAddress)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that `message` carries a recipient, subject, and at least one
|
||||
* body. Throws `EmailerError(EMAILER.INVALID_MESSAGE)` on failure.
|
||||
*/
|
||||
export function validateMessage(message: EmailMessage): void {
|
||||
if (
|
||||
message.to === undefined ||
|
||||
message.to === null ||
|
||||
(Array.isArray(message.to) && message.to.length === 0) ||
|
||||
(typeof message.to === "string" && message.to.length === 0)
|
||||
) {
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.INVALID_MESSAGE,
|
||||
"emailer.errors.invalidMessage",
|
||||
{ context: { field: "to" } }
|
||||
);
|
||||
}
|
||||
if (
|
||||
message.subject === undefined ||
|
||||
message.subject === null ||
|
||||
message.subject.length === 0
|
||||
) {
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.INVALID_MESSAGE,
|
||||
"emailer.errors.invalidMessage",
|
||||
{ context: { field: "subject" } }
|
||||
);
|
||||
}
|
||||
const hasHtml = message.html !== undefined && message.html.length > 0;
|
||||
const hasText = message.text !== undefined && message.text.length > 0;
|
||||
if (!hasHtml && !hasText) {
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.INVALID_MESSAGE,
|
||||
"emailer.errors.invalidMessage",
|
||||
{ context: { field: "body" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
expectTypeOf,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { EmailService } from "./core/email-service/email-service.js";
|
||||
import {
|
||||
EMAILER_CODES,
|
||||
type EmailerErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
import { refreshEmailerMeters } from "./otel/metrics.js";
|
||||
import { createDummyTransport } from "./transports/dummy/dummy-transport.js";
|
||||
|
||||
let exporter: InMemoryMetricExporter;
|
||||
let provider: MeterProvider;
|
||||
let reader: PeriodicExportingMetricReader;
|
||||
|
||||
beforeEach(() => {
|
||||
exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 100_000,
|
||||
});
|
||||
provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
refreshEmailerMeters();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await reader.shutdown();
|
||||
metrics.disable();
|
||||
});
|
||||
|
||||
describe("emailer spec conformance", () => {
|
||||
it("defineCodes literal-type union is preserved", { timeout: 5000 }, () => {
|
||||
expectTypeOf<EmailerErrorCode>().toEqualTypeOf<
|
||||
| "EMAILER.INVALID_CONFIG"
|
||||
| "EMAILER.INVALID_MESSAGE"
|
||||
| "EMAILER.TRANSPORT_NOT_INITIALIZED"
|
||||
| "EMAILER.TRANSPORT_FAILED"
|
||||
| "EMAILER.VERIFY_FAILED"
|
||||
| "EMAILER.QUEUE_NOT_CONFIGURED"
|
||||
| "EMAILER.RETRY_EXHAUSTED"
|
||||
| "EMAILER.ABORTED"
|
||||
| "EMAILER.AUTH_FAILED"
|
||||
| "EMAILER.RATE_LIMITED"
|
||||
| "EMAILER.TIMEOUT"
|
||||
| "EMAILER.MIME_BUILD_FAILED"
|
||||
>();
|
||||
expect(Object.keys(EMAILER_CODES).length).toBe(12);
|
||||
});
|
||||
|
||||
it("production paths emit declared OTel instruments", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transport = createDummyTransport();
|
||||
const service = new EmailService({
|
||||
transport,
|
||||
transportOptions: {},
|
||||
});
|
||||
await service.initialize();
|
||||
await service.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
await reader.forceFlush();
|
||||
const all = exporter
|
||||
.getMetrics()
|
||||
.flatMap((b) => b.scopeMetrics.flatMap((s) => s.metrics));
|
||||
const names = all.map((m) => m.descriptor.name);
|
||||
expect(names).toContain("emailer.send.total");
|
||||
expect(names).toContain("emailer.send.latency_ms");
|
||||
});
|
||||
|
||||
it("EmailService threads transport generic", { timeout: 5000 }, () => {
|
||||
const transport = createDummyTransport();
|
||||
const service = new EmailService<typeof transport>({
|
||||
transport,
|
||||
transportOptions: {},
|
||||
});
|
||||
// Compile-time assertion: service.send returns a Promise of EmailSendResult.
|
||||
expectTypeOf(service.send).toBeCallableWith({
|
||||
to: "a@b.c",
|
||||
subject: "hi",
|
||||
text: "hi",
|
||||
});
|
||||
expect(service).toBeInstanceOf(EmailService);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
# `error-codes.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/emailer` ships a single stable error-code registry that every
|
||||
failure path in the package routes through. The registry is built via
|
||||
`defineCodes("EMAILER", [...])` from `@suaveplan/error`, which freezes the
|
||||
codes into a `{ KEY: "EMAILER.KEY" }` map at compile time and preserves the
|
||||
literal-typed union all the way through to downstream consumers.
|
||||
|
||||
This guarantees three properties that hand-rolled `string` constants cannot:
|
||||
|
||||
1. Every code carries the `EMAILER.` prefix so it sorts and filters cleanly
|
||||
alongside other `@suaveplan/*` registries in logs, metrics, and
|
||||
problem+json responses.
|
||||
2. The `EmailerErrorCode` union is a true literal-type union (never widened
|
||||
to `string`), so exhaustive-`switch` checks and discriminated-union
|
||||
handlers downstream catch missing branches at compile time.
|
||||
3. `EmailerError extends SuaveplanError<EmailerErrorCode>` preserves the
|
||||
typed code on every thrown instance, so the OTel error-span adapter
|
||||
tags the right `error.code` attribute without inspecting the message.
|
||||
|
||||
## Features
|
||||
|
||||
- Twelve registered codes covering every failure mode: configuration,
|
||||
validation, transport lifecycle, retry exhaustion, abort, auth, rate
|
||||
limiting, timeout, MIME build.
|
||||
- Every code is reachable from at least one production throw site —
|
||||
pinned by `src/error-codes.test.ts::"every registered code is reachable"`.
|
||||
- `EmailerError` constructor accepts `(code, messageKey, options)` so the
|
||||
i18n hook in `@suaveplan/error` can resolve the human-readable string at
|
||||
format time without ever lighting up `console.error` paths in this
|
||||
package.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EMAILER_CODES, EmailerError } from "@suaveplan/emailer/error-codes";
|
||||
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.INVALID_MESSAGE,
|
||||
"emailer.errors.invalidMessage",
|
||||
{ context: { field: "to" } },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { EMAILER_CODES, type EmailerErrorCode } from "@suaveplan/emailer/error-codes";
|
||||
|
||||
function describe(code: EmailerErrorCode): string {
|
||||
switch (code) {
|
||||
case EMAILER_CODES.RATE_LIMITED:
|
||||
return "back off and retry with jitter";
|
||||
case EMAILER_CODES.AUTH_FAILED:
|
||||
return "refresh the access token";
|
||||
case EMAILER_CODES.TRANSPORT_FAILED:
|
||||
return "fall over to the next transport in the chain";
|
||||
// ... every other code, exhaustively
|
||||
default:
|
||||
const _exhaustive: never = code;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EMAILER_CODES` — frozen record of every code.
|
||||
- `EmailerErrorCode` — literal-type union of every fully-qualified code.
|
||||
- `EmailerError` — `class EmailerError extends SuaveplanError<EmailerErrorCode>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The registry is the contract; never delete a code to satisfy a gate. If
|
||||
the conformance auditor reports a code as dead, wire it into the
|
||||
production path the proposal designed it for.
|
||||
- Throw `EmailerError`, never `new Error(...)`. The base class carries the
|
||||
OTel attributes, redaction, and i18n hook that downstream consumers rely
|
||||
on.
|
||||
- The package `src/index.ts` imports `./error-codes.js` for side effects so
|
||||
subpath consumers see the registered namespace even when they do not
|
||||
touch the main entry point.
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { EMAILER_CODES, EmailerError } from "./error-codes.js";
|
||||
|
||||
describe("EMAILER_CODES", () => {
|
||||
it("ships twelve fully-qualified codes", { timeout: 5000 }, () => {
|
||||
const values = Object.values(EMAILER_CODES);
|
||||
expect(values.length).toBe(12);
|
||||
for (const v of values) expect(v.startsWith("EMAILER.")).toBe(true);
|
||||
});
|
||||
|
||||
it("every registered code is reachable from a production throw site", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Each of these production paths is exercised in unit tests below.
|
||||
// Here we assert the registry shape only — the actual throws are
|
||||
// covered by feature tests, and the spec-conformance test pins it.
|
||||
const e = new EmailerError(EMAILER_CODES.INVALID_CONFIG, "x");
|
||||
expect(e.code).toBe("EMAILER.INVALID_CONFIG");
|
||||
expect(e.name).toBe("EmailerError");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/emailer`. Every error raised by
|
||||
* the package surfaces one of these twelve codes so downstream consumers —
|
||||
* loggers, problem+json responders, retry wrappers, OTel exporters — can map
|
||||
* failure to retry policy, HTTP status, and i18n keys without re-implementing
|
||||
* the classifier.
|
||||
*
|
||||
* The registry uses `defineCodes` from `@suaveplan/error`, producing a frozen
|
||||
* `{ REASON: "EMAILER.REASON" }` record at compile time so the literal-typed
|
||||
* union is preserved through the public surface.
|
||||
*/
|
||||
export const EMAILER_CODES = defineCodes("EMAILER", [
|
||||
"INVALID_CONFIG",
|
||||
"INVALID_MESSAGE",
|
||||
"TRANSPORT_NOT_INITIALIZED",
|
||||
"TRANSPORT_FAILED",
|
||||
"VERIFY_FAILED",
|
||||
"QUEUE_NOT_CONFIGURED",
|
||||
"RETRY_EXHAUSTED",
|
||||
"ABORTED",
|
||||
"AUTH_FAILED",
|
||||
"RATE_LIMITED",
|
||||
"TIMEOUT",
|
||||
"MIME_BUILD_FAILED",
|
||||
] as const);
|
||||
|
||||
/** Union of every fully-qualified code emitted by this package. */
|
||||
export type EmailerErrorCode =
|
||||
(typeof EMAILER_CODES)[keyof typeof EMAILER_CODES];
|
||||
|
||||
/** Typed error class for every `@suaveplan/emailer` failure. */
|
||||
export class EmailerError extends SuaveplanError<EmailerErrorCode> {
|
||||
public constructor(
|
||||
code: EmailerErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "EmailerError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { EmailerErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
EMAILER_CODES,
|
||||
EmailerError,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,76 @@
|
||||
# `fake-fetch.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Deterministic test double for `FetchLike`, used by `GmailRestTransport`
|
||||
tests. Queues scripted responses and captures every call's URL + init bag
|
||||
so assertions target exact payloads, headers, and methods. Shipping the
|
||||
fake from the package makes downstream tests against `GmailRestTransport`
|
||||
match the conventions used in the package's own test suite without
|
||||
re-implementing the contract.
|
||||
|
||||
## Features
|
||||
|
||||
- `push(response)` enqueues a scripted response: `status`, `headers`,
|
||||
`json` (auto-stringified), `text` (raw), or `reject` (forces a thrown
|
||||
fetch).
|
||||
- `calls` captures every invocation with the resolved URL and the init
|
||||
bag.
|
||||
- The exported `fetch` callable is `FetchLike`-shaped and directly
|
||||
injectable into any transport that accepts `FetchLike`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FakeFetch } from "@suaveplan/emailer/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, json: { id: "abc" } });
|
||||
const response = await fake.fetch("https://example.com/api");
|
||||
console.info(await response.json()); // { id: "abc" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FakeFetch } from "@suaveplan/emailer/fakes/fake-fetch";
|
||||
import { GmailRestTransport } from "@suaveplan/emailer/transports/gmail-rest";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 429, json: { error: { message: "slow down" } } });
|
||||
|
||||
const transport = new GmailRestTransport();
|
||||
await transport.initialize({ accessToken: "t", fetch: fake.fetch });
|
||||
const r = await transport.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
// r.error.code === "EMAILER.RATE_LIMITED"
|
||||
console.info(fake.calls[0].url);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FakeFetch`
|
||||
- `interface FakeFetchResponse { status?; headers?; json?; text?; reject?; }`
|
||||
- `interface FakeFetchCall { url: string; init?: RequestInit; }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Body resolution order: `text` > `json` > empty string. `headers` always
|
||||
merge into the Response.
|
||||
- `reject` short-circuits the entire fetch — useful for asserting
|
||||
transport failure handling, abort wiring, and timeout classification.
|
||||
- The fake never spawns network IO, never opens sockets, and never holds
|
||||
timers; it is safe to instantiate inside synchronous setup and to share
|
||||
across describe blocks without per-test reset cost.
|
||||
- The captured `init` bag is the exact `RequestInit` the transport
|
||||
produced. Tests assert against it directly to confirm method, headers
|
||||
(including `Authorization`), and the JSON-encoded body. Because the
|
||||
capture is structural — not a string snapshot — refactors that change
|
||||
unrelated header ordering do not break the test.
|
||||
- The exported `fetch` is a bound arrow function so it can be passed
|
||||
directly into any `FetchLike` slot without `.bind(this)`. The class
|
||||
itself can be discarded once the fetch closure has been captured if
|
||||
the caller does not need to inspect the calls list.
|
||||
- A future enhancement may add per-response `delay` scheduling driven by
|
||||
an injectable `TimersLike` so consumers can assert backoff schedules
|
||||
end-to-end via `GmailRestTransport`. For now, that is the retry
|
||||
helper's job — see `src/core/retry/retry.md`.
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeFetch } from "./fake-fetch.js";
|
||||
|
||||
describe("FakeFetch", () => {
|
||||
it("returns scripted responses in FIFO", { timeout: 5000 }, async () => {
|
||||
const f = new FakeFetch();
|
||||
f.push({ status: 200, json: { ok: true } });
|
||||
const r = await f.fetch("https://e.com/a");
|
||||
expect(r.status).toBe(200);
|
||||
expect(await r.json()).toEqual({ ok: true });
|
||||
expect(f.calls[0]!.url).toBe("https://e.com/a");
|
||||
});
|
||||
|
||||
it("rejects when queue empty", { timeout: 5000 }, async () => {
|
||||
const f = new FakeFetch();
|
||||
await expect(f.fetch("https://e.com/a")).rejects.toThrow(
|
||||
"no response queued"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when scripted reject", { timeout: 5000 }, async () => {
|
||||
const f = new FakeFetch();
|
||||
f.push({ reject: new Error("net down") });
|
||||
await expect(f.fetch("https://e.com")).rejects.toThrow("net down");
|
||||
});
|
||||
|
||||
it("captures URL form for URL and Request", { timeout: 5000 }, async () => {
|
||||
const f = new FakeFetch();
|
||||
f.push({ status: 200 });
|
||||
f.push({ status: 200 });
|
||||
f.push({ status: 200 });
|
||||
await f.fetch("https://e.com/string");
|
||||
await f.fetch(new URL("https://e.com/url"));
|
||||
await f.fetch(new Request("https://e.com/request"));
|
||||
expect(f.calls.map((c) => c.url)).toEqual([
|
||||
"https://e.com/string",
|
||||
"https://e.com/url",
|
||||
"https://e.com/request",
|
||||
]);
|
||||
});
|
||||
|
||||
it("text body takes precedence", { timeout: 5000 }, async () => {
|
||||
const f = new FakeFetch();
|
||||
f.push({ status: 200, text: "raw" });
|
||||
const r = await f.fetch("https://e.com");
|
||||
expect(await r.text()).toBe("raw");
|
||||
});
|
||||
|
||||
it("defaults to empty body", { timeout: 5000 }, async () => {
|
||||
const f = new FakeFetch();
|
||||
f.push({});
|
||||
const r = await f.fetch("https://e.com");
|
||||
expect(r.status).toBe(200);
|
||||
expect(await r.text()).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
|
||||
/** A scripted response queued on a `FakeFetch`. */
|
||||
export interface FakeFetchResponse {
|
||||
readonly status?: number;
|
||||
readonly headers?: Readonly<Record<string, string>>;
|
||||
readonly json?: unknown;
|
||||
readonly text?: string;
|
||||
readonly reject?: Error;
|
||||
}
|
||||
|
||||
/** A captured request, recorded in FIFO order. */
|
||||
export interface FakeFetchCall {
|
||||
readonly url: string;
|
||||
readonly init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic test double for `FetchLike`. Tests queue scripted responses,
|
||||
* invoke the wrapped transport, and inspect the captured calls.
|
||||
*/
|
||||
export class FakeFetch {
|
||||
public readonly calls: FakeFetchCall[] = [];
|
||||
readonly #queue: FakeFetchResponse[] = [];
|
||||
|
||||
public push(response: FakeFetchResponse): void {
|
||||
this.#queue.push(response);
|
||||
}
|
||||
|
||||
public readonly fetch: FetchLike = (input, init) => {
|
||||
const url =
|
||||
typeof input === "string" || input instanceof URL
|
||||
? String(input)
|
||||
: input.url;
|
||||
this.calls.push({ url, init });
|
||||
const scripted = this.#queue.shift();
|
||||
if (scripted === undefined) {
|
||||
return Promise.reject(new Error("no response queued"));
|
||||
}
|
||||
if (scripted.reject !== undefined) {
|
||||
return Promise.reject(scripted.reject);
|
||||
}
|
||||
const status = scripted.status ?? 200;
|
||||
const headers = new Headers(scripted.headers ?? {});
|
||||
const body =
|
||||
scripted.text !== undefined
|
||||
? scripted.text
|
||||
: scripted.json !== undefined
|
||||
? JSON.stringify(scripted.json)
|
||||
: "";
|
||||
return Promise.resolve(new Response(body, { status, headers }));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# `fake-smtp-client.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Deterministic test double for `SmtpClientLike`. Tests queue scripted
|
||||
responses, invoke the wrapped `SmtpTransport`, and inspect the captured
|
||||
calls — without `vi.mock`, without a real SMTP server, and without
|
||||
monkey-patching globals. Shipping the fake from the package itself means
|
||||
downstream consumers can test their integration against the same exact
|
||||
shape `SmtpTransport` produces, without re-implementing the protocol fake.
|
||||
|
||||
## Features
|
||||
|
||||
- `push(response)` queues a scripted outcome (`info` for success, `reject`
|
||||
for failure).
|
||||
- `calls` captures every `sendMail` invocation in FIFO order so
|
||||
assertions can target individual recipients / headers / attachments.
|
||||
- `scheduleVerify(true | false | Error)` programs the next `verify()` call.
|
||||
- `verifyCalls` and `closeCalls` track lifecycle so tests can assert
|
||||
cleanup behaviour.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FakeSmtpClient } from "@suaveplan/emailer/fakes/fake-smtp-client";
|
||||
import { SmtpTransport } from "@suaveplan/emailer/transports/smtp";
|
||||
|
||||
const adapter = new FakeSmtpClient();
|
||||
adapter.push({ info: { messageId: "<abc@host>" } });
|
||||
|
||||
const transport = new SmtpTransport();
|
||||
await transport.initialize({ client: adapter });
|
||||
const r = await transport.send({ to: "u@x.y", subject: "hi", text: "hi" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FakeSmtpClient } from "@suaveplan/emailer/fakes/fake-smtp-client";
|
||||
|
||||
const adapter = new FakeSmtpClient();
|
||||
adapter.push({ reject: new Error("connection refused") });
|
||||
adapter.push({ info: { messageId: "<1@h>", accepted: ["a@b.c"] } });
|
||||
adapter.scheduleVerify(true);
|
||||
|
||||
// First send fails, second succeeds, verify returns true.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FakeSmtpClient implements SmtpClientLike`
|
||||
- `interface FakeSmtpResponse { info?, reject? }`
|
||||
- `interface FakeSmtpCall { payload: SmtpSendPayload }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The fake is intentionally minimal — it carries no SMTP state, no auth,
|
||||
no connection pool. The point is that `SmtpTransport` only ever calls
|
||||
three methods (`sendMail`, `verify`, `close`), and the fake reproduces
|
||||
exactly those three semantics with FIFO scripting.
|
||||
- `scheduleVerify(Error)` is single-use — after the next `verify` throws,
|
||||
the reject queue clears. Subsequent calls return the most-recently
|
||||
scheduled boolean (default `true`).
|
||||
- `closeCalls` increments on every `close`, allowing tests to assert
|
||||
idempotent close behaviour without inspecting private state.
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeSmtpClient } from "./fake-smtp-client.js";
|
||||
|
||||
describe("FakeSmtpClient", () => {
|
||||
it("consumes scripted responses in FIFO order", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const f = new FakeSmtpClient();
|
||||
f.push({ info: { messageId: "<1@h>" } });
|
||||
f.push({ info: { messageId: "<2@h>" } });
|
||||
const a = await f.sendMail({ from: "a", to: "b", subject: "x" });
|
||||
const b = await f.sendMail({ from: "a", to: "b", subject: "x" });
|
||||
expect(a.messageId).toBe("<1@h>");
|
||||
expect(b.messageId).toBe("<2@h>");
|
||||
expect(f.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects with no response queued", { timeout: 5000 }, async () => {
|
||||
const f = new FakeSmtpClient();
|
||||
await expect(
|
||||
f.sendMail({ from: "a", to: "b", subject: "x" })
|
||||
).rejects.toThrow("no response queued");
|
||||
});
|
||||
|
||||
it("rejects when scripted reject is supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const f = new FakeSmtpClient();
|
||||
f.push({ reject: new Error("nope") });
|
||||
await expect(
|
||||
f.sendMail({ from: "a", to: "b", subject: "x" })
|
||||
).rejects.toThrow("nope");
|
||||
});
|
||||
|
||||
it("throws when scripted info is missing", { timeout: 5000 }, async () => {
|
||||
const f = new FakeSmtpClient();
|
||||
f.push({});
|
||||
await expect(
|
||||
f.sendMail({ from: "a", to: "b", subject: "x" })
|
||||
).rejects.toThrow("scripted response missing info");
|
||||
});
|
||||
|
||||
it("verify returns scheduled boolean", { timeout: 5000 }, async () => {
|
||||
const f = new FakeSmtpClient();
|
||||
expect(await f.verify()).toBe(true);
|
||||
f.scheduleVerify(false);
|
||||
expect(await f.verify()).toBe(false);
|
||||
});
|
||||
|
||||
it("verify rejects when scheduled error", { timeout: 5000 }, async () => {
|
||||
const f = new FakeSmtpClient();
|
||||
f.scheduleVerify(false);
|
||||
f.scheduleVerify(new Error("boom"));
|
||||
await expect(f.verify()).rejects.toThrow("boom");
|
||||
// and reverts to the last scheduled boolean
|
||||
expect(await f.verify()).toBe(false);
|
||||
});
|
||||
|
||||
it("close increments closeCalls", { timeout: 5000 }, async () => {
|
||||
const f = new FakeSmtpClient();
|
||||
await f.close();
|
||||
await f.close();
|
||||
expect(f.closeCalls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type {
|
||||
SmtpClientLike,
|
||||
SmtpSendInfo,
|
||||
SmtpSendPayload,
|
||||
} from "../transports/smtp/smtp-client-like.types.js";
|
||||
|
||||
/** Scripted response for one `sendMail` invocation. */
|
||||
export interface FakeSmtpResponse {
|
||||
readonly info?: SmtpSendInfo;
|
||||
readonly reject?: Error;
|
||||
}
|
||||
|
||||
/** Captured `sendMail` invocation. */
|
||||
export interface FakeSmtpCall {
|
||||
readonly payload: SmtpSendPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic test double for `SmtpClientLike`. Tests queue scripted
|
||||
* responses, invoke the wrapped transport, and inspect the captured calls
|
||||
* — no `vi.mock`, no transport monkey-patching, no real SMTP server.
|
||||
*/
|
||||
export class FakeSmtpClient implements SmtpClientLike {
|
||||
public readonly calls: FakeSmtpCall[] = [];
|
||||
public verifyCalls = 0;
|
||||
public closeCalls = 0;
|
||||
readonly #queue: FakeSmtpResponse[] = [];
|
||||
#verifyResult = true;
|
||||
#verifyReject: Error | undefined;
|
||||
|
||||
/** Append a scripted response. */
|
||||
public push(response: FakeSmtpResponse): void {
|
||||
this.#queue.push(response);
|
||||
}
|
||||
|
||||
/** Control `verify` outcome. Pass a boolean or an Error to throw. */
|
||||
public scheduleVerify(result: boolean | Error): void {
|
||||
if (result instanceof Error) {
|
||||
this.#verifyReject = result;
|
||||
return;
|
||||
}
|
||||
this.#verifyResult = result;
|
||||
}
|
||||
|
||||
public async sendMail(payload: SmtpSendPayload): Promise<SmtpSendInfo> {
|
||||
this.calls.push({ payload });
|
||||
const scripted = this.#queue.shift();
|
||||
if (scripted === undefined) {
|
||||
throw new Error("no response queued");
|
||||
}
|
||||
if (scripted.reject !== undefined) {
|
||||
throw scripted.reject;
|
||||
}
|
||||
if (scripted.info === undefined) {
|
||||
throw new Error("scripted response missing info");
|
||||
}
|
||||
return scripted.info;
|
||||
}
|
||||
|
||||
public async verify(): Promise<boolean> {
|
||||
this.verifyCalls += 1;
|
||||
if (this.#verifyReject !== undefined) {
|
||||
const e = this.#verifyReject;
|
||||
this.#verifyReject = undefined;
|
||||
throw e;
|
||||
}
|
||||
return this.#verifyResult;
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
this.closeCalls += 1;
|
||||
}
|
||||
}
|
||||
10
packages/communication/emails/emailer/src/fakes/index.ts
Normal file
10
packages/communication/emails/emailer/src/fakes/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
FakeFetch,
|
||||
type FakeFetchCall,
|
||||
type FakeFetchResponse,
|
||||
} from "./fake-fetch.js";
|
||||
export {
|
||||
type FakeSmtpCall,
|
||||
FakeSmtpClient,
|
||||
type FakeSmtpResponse,
|
||||
} from "./fake-smtp-client.js";
|
||||
92
packages/communication/emails/emailer/src/index.ts
Normal file
92
packages/communication/emails/emailer/src/index.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// Side-effect: registers the EMAILER_* error-code namespace so subpath
|
||||
// consumers see registered codes even when they don't touch the main entry.
|
||||
import "./error-codes/index.js";
|
||||
|
||||
export {
|
||||
BaseEmailTransport,
|
||||
EmailService,
|
||||
} from "./core/email-service/index.js";
|
||||
export {
|
||||
computeBackoff,
|
||||
defaultTimers,
|
||||
delay,
|
||||
type RetryHooks,
|
||||
runWithRetry,
|
||||
} from "./core/retry/retry.js";
|
||||
export {
|
||||
extractEmail,
|
||||
formatAddress,
|
||||
formatSingleAddress,
|
||||
toEmailArray,
|
||||
validateMessage,
|
||||
} from "./core/validate/validate.js";
|
||||
export {
|
||||
EMAILER_CODES,
|
||||
EmailerError,
|
||||
type EmailerErrorCode,
|
||||
} from "./error-codes/index.js";
|
||||
export {
|
||||
FakeFetch,
|
||||
type FakeFetchCall,
|
||||
type FakeFetchResponse,
|
||||
type FakeSmtpCall,
|
||||
FakeSmtpClient,
|
||||
type FakeSmtpResponse,
|
||||
} from "./fakes/index.js";
|
||||
export {
|
||||
recordQueue,
|
||||
recordRetry,
|
||||
recordSend,
|
||||
recordSendError,
|
||||
refreshEmailerMeters,
|
||||
} from "./otel/metrics.js";
|
||||
export {
|
||||
AddressBagSchema,
|
||||
DummyTransportOptionsSchema,
|
||||
EmailAddressSchema,
|
||||
EmailAttachmentSchema,
|
||||
EmailerOptionsShapeSchema,
|
||||
EmailMessageSchema,
|
||||
GmailRestTransportOptionsSchema,
|
||||
RetryPolicySchema,
|
||||
SmtpTransportOptionsSchema,
|
||||
} from "./schemas/schemas.js";
|
||||
export {
|
||||
createDummyTransport,
|
||||
DummyTransport,
|
||||
type DummyTransportOptions,
|
||||
} from "./transports/dummy/dummy-transport.js";
|
||||
export {
|
||||
createGmailRestTransport,
|
||||
GmailRestTransport,
|
||||
type GmailRestTransportOptions,
|
||||
toBase64Url,
|
||||
} from "./transports/gmail-rest/gmail-rest-transport.js";
|
||||
export type {
|
||||
SmtpClientLike,
|
||||
SmtpSendInfo,
|
||||
SmtpSendPayload,
|
||||
} from "./transports/smtp/smtp-client-like.types.js";
|
||||
export {
|
||||
createSmtpTransport,
|
||||
SmtpTransport,
|
||||
type SmtpTransportOptions,
|
||||
} from "./transports/smtp/smtp-transport.js";
|
||||
export type {
|
||||
AddressBag,
|
||||
EmailAddress,
|
||||
EmailAttachment,
|
||||
EmailErrorHookContext,
|
||||
EmailerHooks,
|
||||
EmailerOptions,
|
||||
EmailerStats,
|
||||
EmailHookContext,
|
||||
EmailMessage,
|
||||
EmailResultHookContext,
|
||||
EmailSendResult,
|
||||
EmailTransport,
|
||||
EventBusLike,
|
||||
HookFunction,
|
||||
QueueLike,
|
||||
RetryPolicy,
|
||||
} from "./types/emailer.types.js";
|
||||
7
packages/communication/emails/emailer/src/otel/index.ts
Normal file
7
packages/communication/emails/emailer/src/otel/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
recordQueue,
|
||||
recordRetry,
|
||||
recordSend,
|
||||
recordSendError,
|
||||
refreshEmailerMeters,
|
||||
} from "./metrics.js";
|
||||
75
packages/communication/emails/emailer/src/otel/metrics.md
Normal file
75
packages/communication/emails/emailer/src/otel/metrics.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# `metrics.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Single source of truth for every OpenTelemetry instrument emitted by
|
||||
`@suaveplan/emailer`. Every production path that touches the wire — send
|
||||
attempts, send failures, latency, retries, queue enqueues — records exactly
|
||||
one observation through one of the helpers exported here. Centralising the
|
||||
emission lets the spec-conformance test grep one file to assert that every
|
||||
declared instrument is wired into a production call.
|
||||
|
||||
## Features
|
||||
|
||||
- `recordSend(transport, outcome, latencyMs)` — counter + histogram pair
|
||||
for every send attempt.
|
||||
- `recordSendError(transport, code)` — counter keyed on the typed
|
||||
`EmailerErrorCode` so dashboards filter by code path, not message.
|
||||
- `recordRetry(transport)` — counter on every retry attempt.
|
||||
- `recordQueue(transport)` — counter on every enqueue.
|
||||
- `refreshEmailerMeters()` — re-binds every instrument against the current
|
||||
global `MeterProvider`; tests call this after installing an in-memory
|
||||
provider so subsequent emissions land on the test exporter.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordSend } from "@suaveplan/emailer/otel/metrics";
|
||||
|
||||
const startedAt = Date.now();
|
||||
// ... actual send ...
|
||||
recordSend("dummy", "ok", Date.now() - startedAt);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { refreshEmailerMeters, recordSend } from "@suaveplan/emailer/otel/metrics";
|
||||
|
||||
const provider = new MeterProvider();
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
refreshEmailerMeters();
|
||||
|
||||
recordSend("dummy", "ok", 12);
|
||||
// `provider.collect()` now contains an `emailer.send.total` observation
|
||||
// with attributes `{ transport: "dummy", outcome: "ok" }`.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `refreshEmailerMeters(): void`
|
||||
- `recordSend(transport: string, outcome: "ok" | "error", latencyMs: number): void`
|
||||
- `recordSendError(transport: string, code: string): void`
|
||||
- `recordRetry(transport: string): void`
|
||||
- `recordQueue(transport: string): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The five instrument names — `emailer.send.total`,
|
||||
`emailer.send.errors`, `emailer.send.latency_ms`, `emailer.retry.total`,
|
||||
`emailer.queue.depth` — are part of the public surface. Renaming them
|
||||
silently breaks every downstream dashboard, so any change must move
|
||||
through OpenSpec.
|
||||
- `refreshEmailerMeters` is required because the OpenTelemetry SDK does
|
||||
not propagate provider changes to existing instruments. Tests that swap
|
||||
the global provider must call it before invoking the production path,
|
||||
otherwise observations land on the module-load meter and the test sees
|
||||
zero data.
|
||||
- All instruments are universally available — the helpers carry no IO and
|
||||
are safe to call from synchronous hot paths.
|
||||
@@ -0,0 +1,66 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
recordQueue,
|
||||
recordRetry,
|
||||
recordSend,
|
||||
recordSendError,
|
||||
refreshEmailerMeters,
|
||||
} from "./metrics.js";
|
||||
|
||||
let exporter: InMemoryMetricExporter;
|
||||
let provider: MeterProvider;
|
||||
let reader: PeriodicExportingMetricReader;
|
||||
|
||||
beforeEach(() => {
|
||||
exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 100_000,
|
||||
});
|
||||
provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
refreshEmailerMeters();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await reader.shutdown();
|
||||
metrics.disable();
|
||||
});
|
||||
|
||||
describe("emailer OTel metrics", () => {
|
||||
it("collects send / error / latency / retry / queue instruments", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
recordSend("dummy", "ok", 12);
|
||||
recordSend("dummy", "error", 30);
|
||||
recordSendError("dummy", "EMAILER.TRANSPORT_FAILED");
|
||||
recordRetry("dummy");
|
||||
recordQueue("dummy");
|
||||
|
||||
await reader.forceFlush();
|
||||
const batches = exporter.getMetrics();
|
||||
expect(batches.length).toBeGreaterThan(0);
|
||||
const all = batches.flatMap((b) =>
|
||||
b.scopeMetrics.flatMap((s) => s.metrics)
|
||||
);
|
||||
const names = all.map((m) => m.descriptor.name);
|
||||
expect(names).toContain("emailer.send.total");
|
||||
expect(names).toContain("emailer.send.errors");
|
||||
expect(names).toContain("emailer.send.latency_ms");
|
||||
expect(names).toContain("emailer.retry.total");
|
||||
expect(names).toContain("emailer.queue.depth");
|
||||
});
|
||||
});
|
||||
77
packages/communication/emails/emailer/src/otel/metrics.ts
Normal file
77
packages/communication/emails/emailer/src/otel/metrics.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/emailer";
|
||||
const METER_VERSION = "0.1.0";
|
||||
|
||||
let meter: Meter = metrics.getMeter(METER_NAME, METER_VERSION);
|
||||
|
||||
let sendCounter: Counter = meter.createCounter("emailer.send.total", {
|
||||
description: "Count of email send attempts by outcome and transport",
|
||||
});
|
||||
|
||||
let errorCounter: Counter = meter.createCounter("emailer.send.errors", {
|
||||
description: "Count of email send errors by code and transport",
|
||||
});
|
||||
|
||||
let latencyHistogram: Histogram = meter.createHistogram(
|
||||
"emailer.send.latency_ms",
|
||||
{
|
||||
description: "Email send latency in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
let retryCounter: Counter = meter.createCounter("emailer.retry.total", {
|
||||
description: "Count of retry attempts by transport",
|
||||
});
|
||||
|
||||
let queueDepthCounter: Counter = meter.createCounter("emailer.queue.depth", {
|
||||
description: "Cumulative count of messages enqueued by transport",
|
||||
});
|
||||
|
||||
/**
|
||||
* Re-resolve every instrument against the current global `MeterProvider`.
|
||||
* Tests call this after wiring an in-memory exporter so subsequent record
|
||||
* calls land on the test provider rather than the module-load meter.
|
||||
*/
|
||||
export function refreshEmailerMeters(): void {
|
||||
meter = metrics.getMeter(METER_NAME, METER_VERSION);
|
||||
sendCounter = meter.createCounter("emailer.send.total", {
|
||||
description: "Count of email send attempts by outcome and transport",
|
||||
});
|
||||
errorCounter = meter.createCounter("emailer.send.errors", {
|
||||
description: "Count of email send errors by code and transport",
|
||||
});
|
||||
latencyHistogram = meter.createHistogram("emailer.send.latency_ms", {
|
||||
description: "Email send latency in milliseconds",
|
||||
unit: "ms",
|
||||
});
|
||||
retryCounter = meter.createCounter("emailer.retry.total", {
|
||||
description: "Count of retry attempts by transport",
|
||||
});
|
||||
queueDepthCounter = meter.createCounter("emailer.queue.depth", {
|
||||
description: "Cumulative count of messages enqueued by transport",
|
||||
});
|
||||
}
|
||||
|
||||
export function recordSend(
|
||||
transport: string,
|
||||
outcome: "ok" | "error",
|
||||
latencyMs: number
|
||||
): void {
|
||||
sendCounter.add(1, { transport, outcome });
|
||||
latencyHistogram.record(latencyMs, { transport, outcome });
|
||||
}
|
||||
|
||||
export function recordSendError(transport: string, code: string): void {
|
||||
errorCounter.add(1, { transport, code });
|
||||
}
|
||||
|
||||
export function recordRetry(transport: string): void {
|
||||
retryCounter.add(1, { transport });
|
||||
}
|
||||
|
||||
export function recordQueue(transport: string): void {
|
||||
queueDepthCounter.add(1, { transport });
|
||||
}
|
||||
11
packages/communication/emails/emailer/src/schemas/index.ts
Normal file
11
packages/communication/emails/emailer/src/schemas/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
AddressBagSchema,
|
||||
DummyTransportOptionsSchema,
|
||||
EmailAddressSchema,
|
||||
EmailAttachmentSchema,
|
||||
EmailerOptionsShapeSchema,
|
||||
EmailMessageSchema,
|
||||
GmailRestTransportOptionsSchema,
|
||||
RetryPolicySchema,
|
||||
SmtpTransportOptionsSchema,
|
||||
} from "./schemas.js";
|
||||
89
packages/communication/emails/emailer/src/schemas/schemas.md
Normal file
89
packages/communication/emails/emailer/src/schemas/schemas.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# `schemas.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralised Zod schemas for every public option bundle and wire payload in
|
||||
`@suaveplan/emailer`. The schemas exist to enforce a hard boundary between
|
||||
the package and its callers: every option object passed into the service or
|
||||
into a concrete transport is `safeParse`d before it is trusted. On failure
|
||||
the relevant constructor raises `EmailerError(EMAILER.INVALID_CONFIG)` with
|
||||
the `ZodError` carried as the `cause` so observability, logging, and
|
||||
problem+json responders downstream can render the offending field path.
|
||||
|
||||
## Features
|
||||
|
||||
- `EmailAddressSchema`, `AddressBagSchema`, `EmailAttachmentSchema`,
|
||||
`EmailMessageSchema` — wire-level payloads.
|
||||
- `RetryPolicySchema`, `EmailerOptionsShapeSchema` — service config.
|
||||
- `DummyTransportOptionsSchema`, `SmtpTransportOptionsSchema`,
|
||||
`GmailRestTransportOptionsSchema` — per-transport configs.
|
||||
- All schemas use Zod 4's default import (`import z from "zod"`) per the
|
||||
Bun-safe convention codified in MEMORY.md.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmailMessageSchema } from "@suaveplan/emailer/schemas/schemas";
|
||||
|
||||
const result = EmailMessageSchema.safeParse({
|
||||
to: "user@example.com",
|
||||
subject: "hello",
|
||||
text: "hi",
|
||||
});
|
||||
if (!result.success) {
|
||||
throw result.error;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { GmailRestTransportOptionsSchema } from "@suaveplan/emailer/schemas/schemas";
|
||||
import { EMAILER_CODES, EmailerError } from "@suaveplan/emailer/error-codes";
|
||||
|
||||
function loadGmailConfigFromEnv(env: Record<string, string | undefined>) {
|
||||
const parsed = GmailRestTransportOptionsSchema.safeParse({
|
||||
accessToken: env["GMAIL_ACCESS_TOKEN"],
|
||||
userId: env["GMAIL_USER_ID"],
|
||||
});
|
||||
if (!parsed.success) {
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.INVALID_CONFIG,
|
||||
"emailer.errors.invalidConfig",
|
||||
{ cause: parsed.error, context: { source: "env" } },
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EmailAddressSchema`
|
||||
- `AddressBagSchema`
|
||||
- `EmailAttachmentSchema`
|
||||
- `EmailMessageSchema`
|
||||
- `RetryPolicySchema`
|
||||
- `EmailerOptionsShapeSchema`
|
||||
- `DummyTransportOptionsSchema`
|
||||
- `SmtpTransportOptionsSchema`
|
||||
- `GmailRestTransportOptionsSchema`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `EmailerOptionsShapeSchema` only validates the serialisable subset of
|
||||
`EmailerOptions`; the live objects (transport instance, logger, event
|
||||
bus, queue, signal) are passed through unchanged because Zod cannot
|
||||
meaningfully assert "this object implements `EmailTransport`."
|
||||
- `AddressBagSchema` accepts either a single string, a structured
|
||||
`EmailAddress`, or a non-empty array of either; this matches the wire
|
||||
shape every transport accepts.
|
||||
- `EmailAttachmentSchema` accepts `Uint8Array` (universal) or `string`; the
|
||||
upstream `NodeJS.ReadableStream` form is deliberately dropped because it
|
||||
would force a Node dependency.
|
||||
- `RetryPolicySchema` enforces `maxAttempts >= 1` so the policy is always
|
||||
attempt-count-meaningful — a `0` value would deadlock the runner.
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AddressBagSchema,
|
||||
DummyTransportOptionsSchema,
|
||||
EmailAddressSchema,
|
||||
EmailAttachmentSchema,
|
||||
EmailerOptionsShapeSchema,
|
||||
EmailMessageSchema,
|
||||
GmailRestTransportOptionsSchema,
|
||||
RetryPolicySchema,
|
||||
SmtpTransportOptionsSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("schemas", () => {
|
||||
it("EmailAddressSchema accepts a valid address", { timeout: 5000 }, () => {
|
||||
expect(EmailAddressSchema.safeParse({ email: "a@b.c" }).success).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
EmailAddressSchema.safeParse({ email: "a@b.c", name: "Alice" })
|
||||
.success
|
||||
).toBe(true);
|
||||
expect(EmailAddressSchema.safeParse({ email: "" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("AddressBagSchema accepts string, object, and arrays", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(AddressBagSchema.safeParse("a@b.c").success).toBe(true);
|
||||
expect(AddressBagSchema.safeParse({ email: "a@b.c" }).success).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
AddressBagSchema.safeParse([{ email: "a@b.c" }, "x@y.z"]).success
|
||||
).toBe(true);
|
||||
expect(AddressBagSchema.safeParse([]).success).toBe(false);
|
||||
});
|
||||
|
||||
it("EmailAttachmentSchema accepts Uint8Array and string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
EmailAttachmentSchema.safeParse({
|
||||
filename: "a.txt",
|
||||
content: new Uint8Array([1, 2, 3]),
|
||||
}).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
EmailAttachmentSchema.safeParse({
|
||||
filename: "a.txt",
|
||||
content: "hi",
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("EmailMessageSchema requires to + subject", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
EmailMessageSchema.safeParse({
|
||||
to: "a@b.c",
|
||||
subject: "hi",
|
||||
text: "hi",
|
||||
}).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
EmailMessageSchema.safeParse({
|
||||
subject: "hi",
|
||||
text: "hi",
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("RetryPolicySchema enforces maxAttempts >= 1", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
RetryPolicySchema.safeParse({ maxAttempts: 1, delay: 0 }).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
RetryPolicySchema.safeParse({ maxAttempts: 0, delay: 0 }).success
|
||||
).toBe(false);
|
||||
expect(
|
||||
RetryPolicySchema.safeParse({
|
||||
maxAttempts: 3,
|
||||
delay: 100,
|
||||
backoff: "exponential",
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid EmailerOptions with ZodError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = EmailerOptionsShapeSchema.safeParse({
|
||||
retry: { maxAttempts: -1, delay: 0 },
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("DummyTransportOptionsSchema validates", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
DummyTransportOptionsSchema.safeParse({
|
||||
shouldFail: false,
|
||||
delayMs: 0,
|
||||
}).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
DummyTransportOptionsSchema.safeParse({ delayMs: -1 }).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("SmtpTransportOptionsSchema validates host/port", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
SmtpTransportOptionsSchema.safeParse({
|
||||
host: "smtp.x.y",
|
||||
port: 465,
|
||||
}).success
|
||||
).toBe(true);
|
||||
expect(SmtpTransportOptionsSchema.safeParse({ port: 0 }).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("GmailRestTransportOptionsSchema requires accessToken", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
GmailRestTransportOptionsSchema.safeParse({
|
||||
accessToken: "x",
|
||||
}).success
|
||||
).toBe(true);
|
||||
expect(GmailRestTransportOptionsSchema.safeParse({}).success).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
GmailRestTransportOptionsSchema.safeParse({
|
||||
accessToken: "x",
|
||||
endpoint: "not a url",
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
88
packages/communication/emails/emailer/src/schemas/schemas.ts
Normal file
88
packages/communication/emails/emailer/src/schemas/schemas.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// schema: boundary-only
|
||||
// Every schema in this file is exposed as a public boundary-validation
|
||||
// surface for consumers (inbound API gateways, CLI parsers, ETL jobs,
|
||||
// per-tenant config loaders). The package itself validates only the
|
||||
// top-level `EmailerOptions` and per-transport configs internally — the
|
||||
// remaining schemas (`EmailAddressSchema`, `AddressBagSchema`,
|
||||
// `EmailAttachmentSchema`, `EmailMessageSchema`, `RetryPolicySchema`) are
|
||||
// downstream-facing.
|
||||
import z from "zod";
|
||||
|
||||
/** Zod schema for `EmailAddress`. */
|
||||
export const EmailAddressSchema = z.object({
|
||||
email: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Address-bag — string, EmailAddress, or readonly array of either. */
|
||||
export const AddressBagSchema = z.union([
|
||||
z.string().min(1),
|
||||
EmailAddressSchema,
|
||||
z.array(z.union([z.string().min(1), EmailAddressSchema])).min(1),
|
||||
]);
|
||||
|
||||
/** Zod schema for `EmailAttachment`. */
|
||||
export const EmailAttachmentSchema = z.object({
|
||||
filename: z.string().min(1),
|
||||
content: z.union([z.instanceof(Uint8Array), z.string()]),
|
||||
contentType: z.string().optional(),
|
||||
cid: z.string().optional(),
|
||||
encoding: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `EmailMessage`. */
|
||||
export const EmailMessageSchema = z.object({
|
||||
to: AddressBagSchema,
|
||||
from: z.union([z.string().min(1), EmailAddressSchema]).optional(),
|
||||
subject: z.string().min(1),
|
||||
html: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
cc: AddressBagSchema.optional(),
|
||||
bcc: AddressBagSchema.optional(),
|
||||
replyTo: z.union([z.string().min(1), EmailAddressSchema]).optional(),
|
||||
attachments: z.array(EmailAttachmentSchema).optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
priority: z.enum(["high", "normal", "low"]).optional(),
|
||||
messageId: z.string().optional(),
|
||||
providerOptions: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `RetryPolicy`. */
|
||||
export const RetryPolicySchema = z.object({
|
||||
maxAttempts: z.number().int().min(1),
|
||||
delay: z.number().min(0),
|
||||
backoff: z.enum(["linear", "exponential"]).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Zod schema for top-level `EmailerOptions` — partial because `transport` is a
|
||||
* runtime object (cannot be Zod-validated structurally without burdening
|
||||
* callers) and the remaining fields are optional.
|
||||
*/
|
||||
export const EmailerOptionsShapeSchema = z.object({
|
||||
defaultFrom: z.union([z.string().min(1), EmailAddressSchema]).optional(),
|
||||
retry: RetryPolicySchema.optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `DummyTransport` config. */
|
||||
export const DummyTransportOptionsSchema = z.object({
|
||||
shouldFail: z.boolean().optional(),
|
||||
delayMs: z.number().int().min(0).optional(),
|
||||
messageIdPrefix: z.string().min(1).optional(),
|
||||
verifySuccess: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `SmtpTransport` config. */
|
||||
export const SmtpTransportOptionsSchema = z.object({
|
||||
host: z.string().min(1).optional(),
|
||||
port: z.number().int().min(1).max(65535).optional(),
|
||||
secure: z.boolean().optional(),
|
||||
user: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for `GmailRestTransport` config. */
|
||||
export const GmailRestTransportOptionsSchema = z.object({
|
||||
accessToken: z.string().min(1),
|
||||
userId: z.string().min(1).optional(),
|
||||
endpoint: z.string().url().optional(),
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
# `dummy-transport.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`DummyTransport` is a deterministic in-memory transport designed for tests,
|
||||
fixtures, and local development. It records every message it receives,
|
||||
forwards lifecycle events to an injected `EventBusLike`, and can simulate
|
||||
forced failure, fixed latency, and verification toggles — all without
|
||||
lighting up any real network or holding any IO handle.
|
||||
|
||||
## Features
|
||||
|
||||
- `getSentEmails()`, `getLastEmail()`, `getSendCount()` — inspect the
|
||||
recorded history.
|
||||
- `clearSentEmails()` — reset the recorder.
|
||||
- `shouldFail: true` — every `send` returns `{ success: false, error }`.
|
||||
- `delayMs > 0` — simulated latency scheduled via injectable `TimersLike`.
|
||||
- `messageIdPrefix` — controls the prefix used in generated message-ids.
|
||||
- `verifySuccess` — controls the return value of `verify`.
|
||||
- `events` — injectable `EventBusLike`; `provider:beforeSend`,
|
||||
`provider:sendSuccess`, `provider:sendError`, `provider:validationError`,
|
||||
`provider:verify`, `provider:closing`, `provider:closed`,
|
||||
`provider:cleared` are forwarded.
|
||||
- `rng` — injectable `Rng` for the message-id suffix; falls back to
|
||||
`Date.now()` if not supplied so consumers don't need a CSPRNG just to
|
||||
smoke-test.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createDummyTransport } from "@suaveplan/emailer/transports/dummy";
|
||||
|
||||
const transport = createDummyTransport();
|
||||
await transport.initialize({});
|
||||
const result = await transport.send({
|
||||
to: "user@example.com",
|
||||
subject: "hi",
|
||||
text: "hello",
|
||||
});
|
||||
console.info(transport.getSentEmails().length); // 1
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createDummyTransport } from "@suaveplan/emailer/transports/dummy";
|
||||
import type { EventBusLike, Rng, TimersLike } from "@suaveplan/types";
|
||||
|
||||
const events: EventBusLike = { emit: (e, p) => bus.publish(e, p) };
|
||||
const rng: Rng = {
|
||||
nextUint32: () => 0xdeadbeef,
|
||||
nextFloat: () => 0.5,
|
||||
};
|
||||
const timers: TimersLike = {
|
||||
setTimer: (cb) => {
|
||||
cb();
|
||||
return 0;
|
||||
},
|
||||
clearTimer: () => undefined,
|
||||
};
|
||||
|
||||
const transport = createDummyTransport();
|
||||
await transport.initialize({
|
||||
shouldFail: false,
|
||||
delayMs: 100,
|
||||
messageIdPrefix: "test",
|
||||
verifySuccess: true,
|
||||
events,
|
||||
rng,
|
||||
timers,
|
||||
});
|
||||
const r = await transport.send({ to: "x@y.z", subject: "hi", text: "hi" });
|
||||
// r.messageId === "test-1-deadbeef"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class DummyTransport extends BaseEmailTransport<DummyTransportOptions>`
|
||||
- `createDummyTransport(): DummyTransport`
|
||||
- `interface DummyTransportOptions`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The transport extends `BaseEmailTransport` and reuses every helper, so
|
||||
the wire-shape rules are identical to the SMTP and Gmail transports.
|
||||
- `delayMs` uses the injected timer's `setTimer` directly; tests can pass
|
||||
a synchronous `setTimer` to drop the delay entirely.
|
||||
- Message-id construction is deterministic when `rng` is supplied, which
|
||||
lets snapshot tests assert against exact strings.
|
||||
- The recorded history is exposed as a defensive copy
|
||||
(`[...this.sentEmails]`) so callers cannot mutate the transport's
|
||||
internal state.
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng, TimersLike } from "@suaveplan/types";
|
||||
import { EMAILER_CODES } from "../../error-codes/error-codes.js";
|
||||
import type { EventBusLike } from "../../types/emailer.types.js";
|
||||
import { createDummyTransport, DummyTransport } from "./dummy-transport.js";
|
||||
|
||||
function recorder(): EventBusLike & { events: Array<[string, unknown]> } {
|
||||
const events: Array<[string, unknown]> = [];
|
||||
return {
|
||||
events,
|
||||
emit: (event, payload) => {
|
||||
events.push([event, payload]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fixedRng: Rng = {
|
||||
nextUint32: () => 0xdeadbeef,
|
||||
nextFloat: () => 0.5,
|
||||
};
|
||||
|
||||
const syncTimers: TimersLike = {
|
||||
setTimer: (cb) => {
|
||||
cb();
|
||||
return 0;
|
||||
},
|
||||
clearTimer: () => {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
|
||||
describe("DummyTransport", () => {
|
||||
it("records sent messages and forwards bus events", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder();
|
||||
const t = createDummyTransport();
|
||||
await t.initialize({
|
||||
messageIdPrefix: "test",
|
||||
events: rec,
|
||||
rng: fixedRng,
|
||||
});
|
||||
const r = await t.send({
|
||||
to: "alice@example.com",
|
||||
subject: "hi",
|
||||
text: "hello",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.messageId?.startsWith("test-1-")).toBe(true);
|
||||
expect(t.getSendCount()).toBe(1);
|
||||
expect(t.getLastEmail()?.to).toBe("alice@example.com");
|
||||
expect(t.getSentEmails().length).toBe(1);
|
||||
const types = rec.events.map(([type]) => type);
|
||||
expect(types).toContain("provider:initialized");
|
||||
expect(types).toContain("provider:beforeSend");
|
||||
expect(types).toContain("provider:sendSuccess");
|
||||
});
|
||||
|
||||
it("returns failure result when shouldFail is true", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder();
|
||||
const t = createDummyTransport();
|
||||
await t.initialize({ shouldFail: true, events: rec });
|
||||
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toBeDefined();
|
||||
expect(rec.events.some(([e]) => e === "provider:sendError")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns validation error for missing fields", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rec = recorder();
|
||||
const t = createDummyTransport();
|
||||
await t.initialize({ events: rec });
|
||||
const r = await t.send({
|
||||
to: "",
|
||||
subject: "hi",
|
||||
text: "hi",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
expect(rec.events.some(([e]) => e === "provider:validationError")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("skips delay when delayMs === 0", { timeout: 5000 }, async () => {
|
||||
let calls = 0;
|
||||
const timers: TimersLike = {
|
||||
setTimer: (cb) => {
|
||||
calls += 1;
|
||||
cb();
|
||||
return 0;
|
||||
},
|
||||
clearTimer: () => {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
const t = createDummyTransport();
|
||||
await t.initialize({ delayMs: 0, timers });
|
||||
await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it("simulates latency via injected timers", { timeout: 5000 }, async () => {
|
||||
let calls = 0;
|
||||
const timers: TimersLike = {
|
||||
setTimer: (cb) => {
|
||||
calls += 1;
|
||||
cb();
|
||||
return 0;
|
||||
},
|
||||
clearTimer: () => {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
const t = createDummyTransport();
|
||||
await t.initialize({ delayMs: 50, timers });
|
||||
await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("verify honours verifySuccess flag", { timeout: 5000 }, async () => {
|
||||
const t = new DummyTransport();
|
||||
await t.initialize({ verifySuccess: false });
|
||||
expect(await t.verify()).toBe(false);
|
||||
const t2 = new DummyTransport();
|
||||
await t2.initialize({});
|
||||
expect(await t2.verify()).toBe(true);
|
||||
});
|
||||
|
||||
it("close emits closed events", { timeout: 5000 }, async () => {
|
||||
const rec = recorder();
|
||||
const t = createDummyTransport();
|
||||
await t.initialize({ events: rec });
|
||||
await t.close();
|
||||
const evs = rec.events.map(([e]) => e);
|
||||
expect(evs).toContain("provider:closing");
|
||||
expect(evs).toContain("provider:closed");
|
||||
});
|
||||
|
||||
it("clearSentEmails wipes history", { timeout: 5000 }, async () => {
|
||||
const rec = recorder();
|
||||
const t = createDummyTransport();
|
||||
await t.initialize({ events: rec });
|
||||
await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
expect(t.getSendCount()).toBe(1);
|
||||
t.clearSentEmails();
|
||||
expect(t.getSendCount()).toBe(0);
|
||||
expect(t.getSentEmails().length).toBe(0);
|
||||
expect(rec.events.some(([e]) => e === "provider:cleared")).toBe(true);
|
||||
});
|
||||
|
||||
it("send throws if not initialised", { timeout: 5000 }, async () => {
|
||||
const t = new DummyTransport();
|
||||
await expect(
|
||||
t.send({ to: "a@b.c", subject: "hi", text: "hi" })
|
||||
).rejects.toMatchObject({
|
||||
code: EMAILER_CODES.TRANSPORT_NOT_INITIALIZED,
|
||||
});
|
||||
});
|
||||
|
||||
it("verify throws if not initialised", { timeout: 5000 }, async () => {
|
||||
const t = new DummyTransport();
|
||||
await expect(t.verify()).rejects.toMatchObject({
|
||||
code: EMAILER_CODES.TRANSPORT_NOT_INITIALIZED,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to Date.now suffix without rng", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = createDummyTransport();
|
||||
await t.initialize({ timers: syncTimers });
|
||||
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
expect(r.messageId?.startsWith("dummy-1-")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid config via Zod", { timeout: 5000 }, async () => {
|
||||
const t = createDummyTransport();
|
||||
await expect(t.initialize({ delayMs: -5 })).rejects.toMatchObject({
|
||||
code: EMAILER_CODES.INVALID_CONFIG,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses default from when message.from is missing in envelope", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = createDummyTransport();
|
||||
await t.initialize({});
|
||||
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
expect(r.success).toBe(true);
|
||||
expect(
|
||||
(r.response as { envelope: { from: string } }).envelope.from
|
||||
).toBe("noreply@example.com");
|
||||
});
|
||||
|
||||
it("close from uninitialized is safe", { timeout: 5000 }, async () => {
|
||||
const t = new DummyTransport();
|
||||
await expect(t.close()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { Rng, TimersLike } from "@suaveplan/types";
|
||||
import { BaseEmailTransport } from "../../core/email-service/base-email-transport.js";
|
||||
import { defaultTimers } from "../../core/retry/retry.js";
|
||||
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
|
||||
import { DummyTransportOptionsSchema } from "../../schemas/schemas.js";
|
||||
import type {
|
||||
EmailMessage,
|
||||
EmailSendResult,
|
||||
EventBusLike,
|
||||
} from "../../types/emailer.types.js";
|
||||
|
||||
/** Configuration for `DummyTransport`. */
|
||||
export interface DummyTransportOptions {
|
||||
/** When `true`, every `send` returns a failed result. */
|
||||
readonly shouldFail?: boolean;
|
||||
/** Optional simulated latency in ms, scheduled via the injectable timer. */
|
||||
readonly delayMs?: number;
|
||||
/** Prefix for generated message-ids. Defaults to `"dummy"`. */
|
||||
readonly messageIdPrefix?: string;
|
||||
/** Result returned from `verify`. Defaults to `true`. */
|
||||
readonly verifySuccess?: boolean;
|
||||
/** Optional event bus for lifecycle event forwarding. */
|
||||
readonly events?: EventBusLike;
|
||||
/** Optional injected timers. */
|
||||
readonly timers?: TimersLike;
|
||||
/** Optional injected RNG used for the message-id suffix. */
|
||||
readonly rng?: Rng;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic in-memory transport. Records every message it receives and
|
||||
* forwards lifecycle events to the injected `EventBusLike` so tests can
|
||||
* inspect both the wire payload and the observability stream without
|
||||
* lighting up any network.
|
||||
*/
|
||||
export class DummyTransport extends BaseEmailTransport<DummyTransportOptions> {
|
||||
public readonly name = "dummy";
|
||||
|
||||
readonly #sentEmails: EmailMessage[] = [];
|
||||
#sendCount = 0;
|
||||
#events: EventBusLike | undefined;
|
||||
#timers: TimersLike = defaultTimers;
|
||||
#rng: Rng | undefined;
|
||||
|
||||
public override async initialize(
|
||||
config: DummyTransportOptions
|
||||
): Promise<void> {
|
||||
const parsed = DummyTransportOptionsSchema.safeParse({
|
||||
shouldFail: config.shouldFail,
|
||||
delayMs: config.delayMs,
|
||||
messageIdPrefix: config.messageIdPrefix,
|
||||
verifySuccess: config.verifySuccess,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.INVALID_CONFIG,
|
||||
"emailer.errors.invalidConfig",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
await super.initialize(config);
|
||||
this.#events = config.events;
|
||||
this.#timers = config.timers ?? defaultTimers;
|
||||
this.#rng = config.rng;
|
||||
this.#emit("provider:initialized", {
|
||||
provider: this.name,
|
||||
config: {
|
||||
...config,
|
||||
events: undefined,
|
||||
timers: undefined,
|
||||
rng: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public override async send(
|
||||
message: EmailMessage
|
||||
): Promise<EmailSendResult> {
|
||||
this.assertInitialized();
|
||||
this.#emit("provider:beforeSend", { message });
|
||||
try {
|
||||
this.validateMessage(message);
|
||||
} catch (error) {
|
||||
const result = this.createErrorResult(error as Error);
|
||||
this.#emit("provider:validationError", { message, error });
|
||||
return result;
|
||||
}
|
||||
|
||||
const delayMs = this.config?.delayMs ?? 0;
|
||||
if (delayMs > 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.#timers.setTimer(() => {
|
||||
resolve();
|
||||
}, delayMs);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.config?.shouldFail === true) {
|
||||
const error = new EmailerError(
|
||||
EMAILER_CODES.TRANSPORT_FAILED,
|
||||
"emailer.errors.dummyForcedFailure"
|
||||
);
|
||||
const result = this.createErrorResult(error);
|
||||
this.#emit("provider:sendError", { message, error });
|
||||
return result;
|
||||
}
|
||||
|
||||
this.#sentEmails.push(message);
|
||||
this.#sendCount += 1;
|
||||
const prefix = this.config?.messageIdPrefix ?? "dummy";
|
||||
const suffix =
|
||||
this.#rng !== undefined
|
||||
? this.#rng.nextUint32().toString(16).padStart(8, "0")
|
||||
: `${Date.now()}`;
|
||||
const messageId = `${prefix}-${this.#sendCount}-${suffix}`;
|
||||
const result = this.createSuccessResult(messageId, {
|
||||
accepted: this.toEmailArray(message.to),
|
||||
rejected: [],
|
||||
envelope: {
|
||||
from: this.extractEmail(message.from ?? "noreply@example.com"),
|
||||
to: this.toEmailArray(message.to),
|
||||
},
|
||||
});
|
||||
this.#emit("provider:sendSuccess", { message, result });
|
||||
return result;
|
||||
}
|
||||
|
||||
public override async verify(): Promise<boolean> {
|
||||
this.assertInitialized();
|
||||
const success = this.config?.verifySuccess ?? true;
|
||||
this.#emit("provider:verify", { success });
|
||||
return success;
|
||||
}
|
||||
|
||||
public override async close(): Promise<void> {
|
||||
this.#emit("provider:closing", { sentCount: this.#sentEmails.length });
|
||||
await super.close();
|
||||
this.#emit("provider:closed", {});
|
||||
}
|
||||
|
||||
/** All messages this transport has accepted. */
|
||||
public getSentEmails(): readonly EmailMessage[] {
|
||||
return [...this.#sentEmails];
|
||||
}
|
||||
|
||||
/** The last accepted message, if any. */
|
||||
public getLastEmail(): EmailMessage | undefined {
|
||||
return this.#sentEmails[this.#sentEmails.length - 1];
|
||||
}
|
||||
|
||||
/** Cumulative count of accepted messages. */
|
||||
public getSendCount(): number {
|
||||
return this.#sendCount;
|
||||
}
|
||||
|
||||
/** Drop the recorded history. */
|
||||
public clearSentEmails(): void {
|
||||
this.#sentEmails.length = 0;
|
||||
this.#sendCount = 0;
|
||||
this.#emit("provider:cleared", {});
|
||||
}
|
||||
|
||||
#emit(event: string, payload: unknown): void {
|
||||
if (this.#events !== undefined) {
|
||||
this.#events.emit(event, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Construct a `DummyTransport`. */
|
||||
export function createDummyTransport(): DummyTransport {
|
||||
return new DummyTransport();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createDummyTransport,
|
||||
DummyTransport,
|
||||
type DummyTransportOptions,
|
||||
} from "./dummy-transport.js";
|
||||
@@ -0,0 +1,98 @@
|
||||
# `gmail-rest-transport.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GmailRestTransport` sends mail through Gmail's REST API directly, over an
|
||||
injectable `FetchLike`. The transport builds a minimal RFC 822 envelope,
|
||||
base64url-encodes it, and POSTs it to `users.messages.send`. Crucially the
|
||||
package does not depend on `googleapis` or `nodemailer` — every token-
|
||||
acquisition concern (OAuth2 refresh, service-account JWT, app passwords) is
|
||||
the caller's, so this transport stays universal and runs unchanged in
|
||||
Bun, Node, and the browser.
|
||||
|
||||
## Features
|
||||
|
||||
- Universal: only requires `FetchLike` plus a caller-supplied access
|
||||
token.
|
||||
- `toBase64Url` exported as a pure helper for downstream consumers that
|
||||
need the same encoding.
|
||||
- Status-code → error-code classifier:
|
||||
- 401 / 403 → `EMAILER.AUTH_FAILED`
|
||||
- 429 → `EMAILER.RATE_LIMITED`
|
||||
- any other non-2xx → `EMAILER.TRANSPORT_FAILED`
|
||||
- Carries the HTTP status and error detail on the thrown `EmailerError`'s
|
||||
`context` bag so downstream loggers render structured detail without
|
||||
scraping the message string.
|
||||
- Zod validation on initialise via `GmailRestTransportOptionsSchema`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createGmailRestTransport } from "@suaveplan/emailer/transports/gmail-rest";
|
||||
|
||||
const transport = createGmailRestTransport();
|
||||
await transport.initialize({ accessToken: "ya29.a0..." });
|
||||
const r = await transport.send({
|
||||
to: "user@example.com",
|
||||
subject: "hi",
|
||||
text: "hello",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createGmailRestTransport } from "@suaveplan/emailer/transports/gmail-rest";
|
||||
import { FakeFetch } from "@suaveplan/emailer/fakes/fake-fetch";
|
||||
|
||||
// Test wiring with a deterministic fetch double:
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, json: { id: "1816fc...", threadId: "1816fc..." } });
|
||||
|
||||
const transport = createGmailRestTransport();
|
||||
await transport.initialize({
|
||||
accessToken: "ya29.test",
|
||||
userId: "alice@example.com",
|
||||
endpoint: "https://gmail.googleapis.com/gmail/v1",
|
||||
fetch: fake.fetch,
|
||||
});
|
||||
const r = await transport.send({
|
||||
to: "bob@example.com",
|
||||
subject: "hi",
|
||||
html: "<p>hello</p>",
|
||||
});
|
||||
const captured = fake.calls[0]!;
|
||||
console.info(captured.url);
|
||||
// → https://gmail.googleapis.com/gmail/v1/users/alice%40example.com/messages/send
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class GmailRestTransport extends BaseEmailTransport<GmailRestTransportOptions>`
|
||||
- `createGmailRestTransport(): GmailRestTransport`
|
||||
- `interface GmailRestTransportOptions { accessToken; userId?; endpoint?; fetch?; }`
|
||||
- `toBase64Url(input: string): string`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The endpoint URL is constructed via `encodeURIComponent(userId)` so
|
||||
user-ids with `@` characters (e.g. delegated mailboxes) are escaped
|
||||
correctly. The default `userId` is `"me"`, matching the Gmail REST
|
||||
convention.
|
||||
- Token refresh is deliberately outside the transport's scope; the access
|
||||
token is a snapshot of the caller's identity at construction time.
|
||||
Long-lived services should re-initialise (or re-construct) the
|
||||
transport when the token expires.
|
||||
- The MIME builder is intentionally minimal — `text/plain` or `text/html`
|
||||
with optional caller-supplied headers. Multipart, inline images, and
|
||||
attachment encoding are deferred to a follow-up because Gmail's REST
|
||||
API itself accepts any valid MIME envelope, so the caller can pre-build
|
||||
one and pass it via `message.headers` if they need more than the
|
||||
built-in format.
|
||||
- `toBase64Url` prefers the WHATWG `btoa` route so the transport works in
|
||||
the browser; the Node `Buffer` fallback exists only for environments
|
||||
that strip `btoa`.
|
||||
@@ -0,0 +1,314 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
|
||||
import { FakeFetch } from "../../fakes/fake-fetch.js";
|
||||
import {
|
||||
createGmailRestTransport,
|
||||
GmailRestTransport,
|
||||
toBase64Url,
|
||||
} from "./gmail-rest-transport.js";
|
||||
|
||||
function decodeBase64Url(input: string): string {
|
||||
const padded = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const pad =
|
||||
padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
|
||||
const base64 = padded + pad;
|
||||
if (typeof globalThis.atob === "function") {
|
||||
return globalThis.atob(base64);
|
||||
}
|
||||
type BufferCtor = {
|
||||
from(s: string, enc: string): { toString(enc: string): string };
|
||||
};
|
||||
const bufferCtor = (globalThis as unknown as { Buffer?: BufferCtor })
|
||||
.Buffer;
|
||||
return bufferCtor!.from(base64, "base64").toString("utf8");
|
||||
}
|
||||
|
||||
describe("toBase64Url", () => {
|
||||
it("encodes ascii", { timeout: 5000 }, () => {
|
||||
const enc = toBase64Url("hello world");
|
||||
expect(decodeBase64Url(enc)).toBe("hello world");
|
||||
});
|
||||
|
||||
it("strips padding and url-safe", { timeout: 5000 }, () => {
|
||||
const enc = toBase64Url("any +/ chars?");
|
||||
expect(enc.endsWith("=")).toBe(false);
|
||||
expect(enc.includes("+")).toBe(false);
|
||||
expect(enc.includes("/")).toBe(false);
|
||||
});
|
||||
|
||||
it("throws MIME_BUILD_FAILED when btoa is unavailable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = globalThis.btoa;
|
||||
try {
|
||||
(globalThis as { btoa?: typeof globalThis.btoa }).btoa = undefined;
|
||||
expect(() => toBase64Url("x")).toThrow(EmailerError);
|
||||
} finally {
|
||||
globalThis.btoa = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GmailRestTransport", () => {
|
||||
it("sends base64url MIME envelope via Gmail REST", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, json: { id: "abc", threadId: "t1" } });
|
||||
const t = createGmailRestTransport();
|
||||
await t.initialize({
|
||||
accessToken: "ya29.xyz",
|
||||
userId: "me",
|
||||
fetch: fake.fetch,
|
||||
});
|
||||
const r = await t.send({
|
||||
to: { email: "bob@example.com", name: "Bob" },
|
||||
from: "alice@example.com",
|
||||
subject: "Hello",
|
||||
html: "<p>hi</p>",
|
||||
cc: "c@example.com",
|
||||
replyTo: "r@example.com",
|
||||
headers: { "X-Test": "yes" },
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.messageId).toBe("abc");
|
||||
expect(fake.calls.length).toBe(1);
|
||||
const call = fake.calls[0]!;
|
||||
expect(call.url).toBe(
|
||||
"https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
|
||||
);
|
||||
const auth = (call.init?.headers as Record<string, string>)[
|
||||
"authorization"
|
||||
];
|
||||
expect(auth).toBe("Bearer ya29.xyz");
|
||||
const body = JSON.parse(call.init?.body as string) as { raw: string };
|
||||
const decoded = decodeBase64Url(body.raw);
|
||||
expect(decoded).toContain('To: "Bob" <bob@example.com>');
|
||||
expect(decoded).toContain("Subject: Hello");
|
||||
expect(decoded).toContain("X-Test: yes");
|
||||
});
|
||||
|
||||
it("reports TRANSPORT_FAILED on non-2xx response", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 500, json: { error: { message: "boom" } } });
|
||||
const t = new GmailRestTransport();
|
||||
await t.initialize({ accessToken: "t", fetch: fake.fetch });
|
||||
const r = await t.send({
|
||||
to: "a@b.c",
|
||||
subject: "hi",
|
||||
text: "hi",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
expect((r.error as EmailerError).code).toBe(
|
||||
EMAILER_CODES.TRANSPORT_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies 401 as AUTH_FAILED", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 401, json: { error: { message: "no" } } });
|
||||
const t = new GmailRestTransport();
|
||||
await t.initialize({ accessToken: "t", fetch: fake.fetch });
|
||||
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
expect((r.error as EmailerError).code).toBe(EMAILER_CODES.AUTH_FAILED);
|
||||
});
|
||||
|
||||
it("classifies 429 as RATE_LIMITED", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 429, json: { error: { message: "slow down" } } });
|
||||
const t = new GmailRestTransport();
|
||||
await t.initialize({ accessToken: "t", fetch: fake.fetch });
|
||||
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
expect((r.error as EmailerError).code).toBe(EMAILER_CODES.RATE_LIMITED);
|
||||
});
|
||||
|
||||
it("handles non-JSON error body", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 503, text: "down for maintenance" });
|
||||
const t = new GmailRestTransport();
|
||||
await t.initialize({ accessToken: "t", fetch: fake.fetch });
|
||||
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("validation error returns failed result", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
const t = new GmailRestTransport();
|
||||
await t.initialize({ accessToken: "t", fetch: fake.fetch });
|
||||
const r = await t.send({ to: "", subject: "hi", text: "hi" });
|
||||
expect(r.success).toBe(false);
|
||||
expect(fake.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("init rejects invalid config", { timeout: 5000 }, async () => {
|
||||
const t = createGmailRestTransport();
|
||||
await expect(t.initialize({ accessToken: "" })).rejects.toMatchObject({
|
||||
code: EMAILER_CODES.INVALID_CONFIG,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses default endpoint and userId when omitted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, json: { id: "abc" } });
|
||||
const t = new GmailRestTransport();
|
||||
await t.initialize({ accessToken: "t", fetch: fake.fetch });
|
||||
await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
expect(fake.calls[0]!.url).toBe(
|
||||
"https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
|
||||
);
|
||||
});
|
||||
|
||||
it("builds plain-text content type when html missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, json: { id: "abc" } });
|
||||
const t = new GmailRestTransport();
|
||||
await t.initialize({ accessToken: "t", fetch: fake.fetch });
|
||||
await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
const body = JSON.parse(fake.calls[0]!.init?.body as string) as {
|
||||
raw: string;
|
||||
};
|
||||
const decoded = decodeBase64Url(body.raw);
|
||||
expect(decoded).toContain("Content-Type: text/plain");
|
||||
});
|
||||
|
||||
it("returns failure when underlying fetch rejects", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ reject: new Error("net down") });
|
||||
const t = new GmailRestTransport();
|
||||
await t.initialize({ accessToken: "t", fetch: fake.fetch });
|
||||
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error?.message).toBe("net down");
|
||||
});
|
||||
|
||||
it("send throws when not initialised", { timeout: 5000 }, async () => {
|
||||
const t = new GmailRestTransport();
|
||||
await expect(
|
||||
t.send({ to: "a@b.c", subject: "hi", text: "hi" })
|
||||
).rejects.toMatchObject({
|
||||
code: EMAILER_CODES.TRANSPORT_NOT_INITIALIZED,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses defaultFetch when fetch option omitted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
try {
|
||||
let captured: { url: string; init?: RequestInit } | undefined;
|
||||
globalThis.fetch = (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit
|
||||
) => {
|
||||
captured = {
|
||||
url: String(input),
|
||||
...(init !== undefined ? { init } : {}),
|
||||
};
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ id: "x" }), {
|
||||
status: 200,
|
||||
})
|
||||
);
|
||||
};
|
||||
const t = new GmailRestTransport();
|
||||
await t.initialize({ accessToken: "t" });
|
||||
const r = await t.send({
|
||||
to: "a@b.c",
|
||||
subject: "hi",
|
||||
text: "hi",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
expect(captured?.url).toContain("gmail.googleapis.com");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("handles malformed json error body via empty detail", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 500, json: {} });
|
||||
const t = new GmailRestTransport();
|
||||
await t.initialize({ accessToken: "t", fetch: fake.fetch });
|
||||
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("encodes cc array recipients", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, json: { id: "abc" } });
|
||||
const t = new GmailRestTransport();
|
||||
await t.initialize({ accessToken: "t", fetch: fake.fetch });
|
||||
await t.send({
|
||||
to: ["one@x.y", "two@x.y"],
|
||||
subject: "hi",
|
||||
text: "hi",
|
||||
cc: ["a@x.y", { email: "b@x.y", name: "B" }],
|
||||
});
|
||||
const body = JSON.parse(fake.calls[0]!.init?.body as string) as {
|
||||
raw: string;
|
||||
};
|
||||
const decoded = decodeBase64Url(body.raw);
|
||||
expect(decoded).toContain("To: one@x.y, two@x.y");
|
||||
expect(decoded).toContain('Cc: a@x.y, "B" <b@x.y>');
|
||||
});
|
||||
|
||||
it("wraps non-Error throw as TRANSPORT_FAILED via fetch reject", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = new GmailRestTransport();
|
||||
// Initialise with a fetch that throws a non-Error string.
|
||||
const failure: unknown = "string-thrown";
|
||||
const evilFetch: typeof fetch = () => {
|
||||
throw failure;
|
||||
};
|
||||
await t.initialize({
|
||||
accessToken: "t",
|
||||
fetch: evilFetch as unknown as typeof globalThis.fetch,
|
||||
});
|
||||
const r = await t.send({
|
||||
to: "a@b.c",
|
||||
subject: "hi",
|
||||
text: "hi",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
expect((r.error as EmailerError).code).toBe(
|
||||
EMAILER_CODES.TRANSPORT_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies AbortError as TIMEOUT", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
const abortError = new Error("aborted");
|
||||
abortError.name = "AbortError";
|
||||
fake.push({ reject: abortError });
|
||||
const t = new GmailRestTransport();
|
||||
await t.initialize({ accessToken: "t", fetch: fake.fetch });
|
||||
const r = await t.send({
|
||||
to: "a@b.c",
|
||||
subject: "hi",
|
||||
text: "hi",
|
||||
});
|
||||
expect((r.error as EmailerError).code).toBe(EMAILER_CODES.TIMEOUT);
|
||||
});
|
||||
|
||||
it("missing id falls back to unknown", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, json: {} });
|
||||
const t = new GmailRestTransport();
|
||||
await t.initialize({ accessToken: "t", fetch: fake.fetch });
|
||||
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
expect(r.messageId).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
import { BaseEmailTransport } from "../../core/email-service/base-email-transport.js";
|
||||
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
|
||||
import { GmailRestTransportOptionsSchema } from "../../schemas/schemas.js";
|
||||
import type {
|
||||
EmailMessage,
|
||||
EmailSendResult,
|
||||
} from "../../types/emailer.types.js";
|
||||
|
||||
const DEFAULT_ENDPOINT = "https://gmail.googleapis.com/gmail/v1";
|
||||
const DEFAULT_USER_ID = "me";
|
||||
|
||||
/** Configuration for `GmailRestTransport`. */
|
||||
export interface GmailRestTransportOptions {
|
||||
/** Bearer access token. Caller is responsible for refresh. */
|
||||
readonly accessToken: string;
|
||||
/** Gmail user id. Defaults to `"me"`. */
|
||||
readonly userId?: string;
|
||||
/** Override the API endpoint (useful for staging). */
|
||||
readonly endpoint?: string;
|
||||
/** Injectable `FetchLike`. Defaults to `globalThis.fetch`. */
|
||||
readonly fetch?: FetchLike;
|
||||
}
|
||||
|
||||
interface GmailErrorBody {
|
||||
readonly error?: { readonly message?: string; readonly code?: number };
|
||||
}
|
||||
|
||||
interface GmailSendResponse {
|
||||
readonly id?: string;
|
||||
readonly threadId?: string;
|
||||
readonly labelIds?: readonly string[];
|
||||
}
|
||||
|
||||
const defaultFetch: FetchLike = (input, init) => globalThis.fetch(input, init);
|
||||
|
||||
/**
|
||||
* Encode `input` as base64url (RFC 4648 §5). Uses WHATWG `btoa`, which is
|
||||
* available in Bun, Node 18+, and every modern browser. If the runtime
|
||||
* strips `btoa` (an exotic embedded environment) this throws
|
||||
* `EMAILER.MIME_BUILD_FAILED` so callers see a typed error instead of a
|
||||
* `ReferenceError`.
|
||||
*/
|
||||
export function toBase64Url(input: string): string {
|
||||
if (typeof globalThis.btoa !== "function") {
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.MIME_BUILD_FAILED,
|
||||
"emailer.errors.mimeBuildFailed",
|
||||
{ context: { reason: "no-base64-encoder" } }
|
||||
);
|
||||
}
|
||||
const bytes = new TextEncoder().encode(input);
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
const base64 = globalThis.btoa(binary);
|
||||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/u, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gmail REST transport. Builds a minimal RFC 822 envelope, encodes it as
|
||||
* base64url, and POSTs to `users.messages.send` via an injectable
|
||||
* `FetchLike`. Token acquisition is the caller's concern — pass any
|
||||
* already-issued OAuth2 / service-account bearer token.
|
||||
*/
|
||||
export class GmailRestTransport extends BaseEmailTransport<GmailRestTransportOptions> {
|
||||
public readonly name = "gmail-rest";
|
||||
|
||||
#accessToken: string | undefined;
|
||||
#userId: string = DEFAULT_USER_ID;
|
||||
#endpoint: string = DEFAULT_ENDPOINT;
|
||||
#fetch: FetchLike = defaultFetch;
|
||||
|
||||
public override async initialize(
|
||||
config: GmailRestTransportOptions
|
||||
): Promise<void> {
|
||||
const parsed = GmailRestTransportOptionsSchema.safeParse({
|
||||
accessToken: config.accessToken,
|
||||
userId: config.userId,
|
||||
endpoint: config.endpoint,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.INVALID_CONFIG,
|
||||
"emailer.errors.invalidConfig",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
await super.initialize(config);
|
||||
this.#accessToken = config.accessToken;
|
||||
this.#userId = config.userId ?? DEFAULT_USER_ID;
|
||||
this.#endpoint = config.endpoint ?? DEFAULT_ENDPOINT;
|
||||
this.#fetch = config.fetch ?? defaultFetch;
|
||||
}
|
||||
|
||||
public override async send(
|
||||
message: EmailMessage
|
||||
): Promise<EmailSendResult> {
|
||||
this.assertInitialized();
|
||||
try {
|
||||
this.validateMessage(message);
|
||||
const mime = this.#buildMimeMessage(message);
|
||||
const raw = toBase64Url(mime);
|
||||
const url = `${this.#endpoint}/users/${encodeURIComponent(this.#userId)}/messages/send`;
|
||||
const response = await this.#fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${this.#accessToken as string}`,
|
||||
},
|
||||
body: JSON.stringify({ raw }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await response.json()) as GmailErrorBody;
|
||||
detail = body.error?.message ?? "";
|
||||
} catch {
|
||||
detail = await response.text().catch(() => "");
|
||||
}
|
||||
const code =
|
||||
response.status === 401 || response.status === 403
|
||||
? EMAILER_CODES.AUTH_FAILED
|
||||
: response.status === 429
|
||||
? EMAILER_CODES.RATE_LIMITED
|
||||
: EMAILER_CODES.TRANSPORT_FAILED;
|
||||
return this.createErrorResult(
|
||||
new EmailerError(code, "emailer.errors.transportFailed", {
|
||||
context: { status: response.status, detail },
|
||||
})
|
||||
);
|
||||
}
|
||||
const data = (await response.json()) as GmailSendResponse;
|
||||
return this.createSuccessResult(
|
||||
data.id ?? "unknown",
|
||||
data,
|
||||
data.threadId !== undefined
|
||||
? { threadId: data.threadId }
|
||||
: undefined
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.name === "AbortError" ||
|
||||
error.name === "TimeoutError"
|
||||
) {
|
||||
return this.createErrorResult(
|
||||
new EmailerError(
|
||||
EMAILER_CODES.TIMEOUT,
|
||||
"emailer.errors.timeout",
|
||||
{ cause: error }
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.createErrorResult(error);
|
||||
}
|
||||
return this.createErrorResult(
|
||||
new EmailerError(
|
||||
EMAILER_CODES.TRANSPORT_FAILED,
|
||||
"emailer.errors.transportFailed"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#buildMimeMessage(message: EmailMessage): string {
|
||||
const lines: string[] = [];
|
||||
const to = this.formatAddress(message.to) as string | string[];
|
||||
lines.push(`To: ${Array.isArray(to) ? to.join(", ") : to}`);
|
||||
if (message.from !== undefined) {
|
||||
const from = this.formatAddress(message.from) as string;
|
||||
lines.push(`From: ${from}`);
|
||||
}
|
||||
if (message.cc !== undefined) {
|
||||
const cc = this.formatAddress(message.cc) as string | string[];
|
||||
lines.push(`Cc: ${Array.isArray(cc) ? cc.join(", ") : cc}`);
|
||||
}
|
||||
if (message.replyTo !== undefined) {
|
||||
const rt = this.formatAddress(message.replyTo) as string;
|
||||
lines.push(`Reply-To: ${rt}`);
|
||||
}
|
||||
lines.push(`Subject: ${message.subject}`);
|
||||
if (message.headers !== undefined) {
|
||||
for (const [key, value] of Object.entries(message.headers)) {
|
||||
lines.push(`${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
const isHtml = message.html !== undefined && message.html.length > 0;
|
||||
lines.push(
|
||||
isHtml
|
||||
? "Content-Type: text/html; charset=utf-8"
|
||||
: "Content-Type: text/plain; charset=utf-8"
|
||||
);
|
||||
lines.push("");
|
||||
// validateMessage guarantees at least one body is non-empty.
|
||||
lines.push((message.html ?? message.text) as string);
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
/** Construct a `GmailRestTransport`. */
|
||||
export function createGmailRestTransport(): GmailRestTransport {
|
||||
return new GmailRestTransport();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createGmailRestTransport,
|
||||
GmailRestTransport,
|
||||
type GmailRestTransportOptions,
|
||||
toBase64Url,
|
||||
} from "./gmail-rest-transport.js";
|
||||
@@ -0,0 +1,21 @@
|
||||
export {
|
||||
createDummyTransport,
|
||||
DummyTransport,
|
||||
type DummyTransportOptions,
|
||||
} from "./dummy/dummy-transport.js";
|
||||
export {
|
||||
createGmailRestTransport,
|
||||
GmailRestTransport,
|
||||
type GmailRestTransportOptions,
|
||||
toBase64Url,
|
||||
} from "./gmail-rest/gmail-rest-transport.js";
|
||||
export type {
|
||||
SmtpClientLike,
|
||||
SmtpSendInfo,
|
||||
SmtpSendPayload,
|
||||
} from "./smtp/smtp-client-like.types.js";
|
||||
export {
|
||||
createSmtpTransport,
|
||||
SmtpTransport,
|
||||
type SmtpTransportOptions,
|
||||
} from "./smtp/smtp-transport.js";
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
SmtpClientLike,
|
||||
SmtpSendInfo,
|
||||
SmtpSendPayload,
|
||||
} from "./smtp-client-like.types.js";
|
||||
export {
|
||||
createSmtpTransport,
|
||||
SmtpTransport,
|
||||
type SmtpTransportOptions,
|
||||
} from "./smtp-transport.js";
|
||||
@@ -0,0 +1,82 @@
|
||||
# `smtp-client-like.types.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the minimal structural contract every SMTP adapter must satisfy.
|
||||
The contract is intentionally tiny — `sendMail`, optional `verify`, optional
|
||||
`close` — so any SMTP library (nodemailer, lettre over a Node binding,
|
||||
mailparser, a custom Bun/Node wrapper, ...) can be plugged in via a thin
|
||||
wrapper without the emailer package ever importing the library directly.
|
||||
|
||||
## Features
|
||||
|
||||
- `SmtpClientLike` — the adapter contract: `sendMail(payload)`, optional
|
||||
`verify()`, optional `close()`.
|
||||
- `SmtpSendPayload` — the wire shape every adapter receives: `from`, `to`,
|
||||
`cc`, `bcc`, `replyTo`, `subject`, `html`, `text`, `attachments`,
|
||||
`headers`, `priority`, `messageId`.
|
||||
- `SmtpSendInfo` — the response shape the adapter returns: `messageId`,
|
||||
`accepted`, `rejected`, `response`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SmtpClientLike,
|
||||
SmtpSendInfo,
|
||||
SmtpSendPayload,
|
||||
} from "@suaveplan/emailer/transports/smtp";
|
||||
|
||||
const adapter: SmtpClientLike = {
|
||||
sendMail: async (payload: SmtpSendPayload): Promise<SmtpSendInfo> => {
|
||||
// Forward `payload` to the underlying library
|
||||
return { messageId: "abc" };
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SmtpClientLike,
|
||||
SmtpSendInfo,
|
||||
SmtpSendPayload,
|
||||
} from "@suaveplan/emailer/transports/smtp";
|
||||
|
||||
class TracingAdapter implements SmtpClientLike {
|
||||
constructor(private readonly inner: SmtpClientLike) {}
|
||||
async sendMail(payload: SmtpSendPayload): Promise<SmtpSendInfo> {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const info = await this.inner.sendMail(payload);
|
||||
tracer.recordSend(info.messageId, Date.now() - started);
|
||||
return info;
|
||||
} catch (err) {
|
||||
tracer.recordError(err, Date.now() - started);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
verify = () => this.inner.verify?.() ?? Promise.resolve(true);
|
||||
close = () => this.inner.close?.();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SmtpClientLike`
|
||||
- `interface SmtpSendPayload`
|
||||
- `interface SmtpSendInfo`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The contract uses `readonly` properties on the payload so adapters can
|
||||
safely store references without worrying about mutation by the
|
||||
transport.
|
||||
- `attachments` is typed without a `NodeJS.ReadableStream` member so the
|
||||
package compiles without `@types/node` runtime types; if a caller needs
|
||||
to stream from disk, they can buffer first or supply a `Uint8Array`.
|
||||
- The interface intentionally omits connection pool / max-messages /
|
||||
max-connections — those concerns belong to the adapter, not the
|
||||
transport contract. Surfacing them here would couple the emailer to one
|
||||
specific SMTP library's tuning knobs.
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Outcome of one `sendMail` call. */
|
||||
export interface SmtpSendInfo {
|
||||
readonly messageId: string;
|
||||
readonly accepted?: readonly string[];
|
||||
readonly rejected?: readonly string[];
|
||||
readonly response?: string;
|
||||
}
|
||||
|
||||
/** Payload accepted by `SmtpClientLike.sendMail`. */
|
||||
export interface SmtpSendPayload {
|
||||
readonly from: string | string[] | undefined;
|
||||
readonly to: string | string[] | undefined;
|
||||
readonly cc?: string | string[];
|
||||
readonly bcc?: string | string[];
|
||||
readonly replyTo?: string | string[];
|
||||
readonly subject: string;
|
||||
readonly html?: string;
|
||||
readonly text?: string;
|
||||
readonly attachments?: ReadonlyArray<{
|
||||
readonly filename: string;
|
||||
readonly content: Uint8Array | string;
|
||||
readonly contentType?: string;
|
||||
readonly cid?: string;
|
||||
readonly encoding?: string;
|
||||
}>;
|
||||
readonly headers?: Readonly<Record<string, string>>;
|
||||
readonly priority?: "high" | "normal" | "low";
|
||||
readonly messageId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural contract every SMTP adapter (nodemailer, lettre, ...) must
|
||||
* satisfy. The emailer package does not import nodemailer directly — the
|
||||
* caller wires their library of choice and passes a thin adapter that
|
||||
* conforms to this interface.
|
||||
*/
|
||||
export interface SmtpClientLike {
|
||||
sendMail(payload: SmtpSendPayload): Promise<SmtpSendInfo>;
|
||||
verify?(): Promise<boolean>;
|
||||
close?(): void | Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# `smtp-transport.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SmtpTransport` is a structural SMTP transport: it accepts a caller-supplied
|
||||
`SmtpClientLike` adapter (the contract is small — `sendMail`, optional
|
||||
`verify`, optional `close`) and forwards every send call through it. The
|
||||
emailer package itself never imports nodemailer, lettre, or any other SMTP
|
||||
library; consumers wire the library of their choice in their own app
|
||||
shell and pass a thin adapter. This keeps the emailer package universal
|
||||
(zero Node-only runtime deps) while preserving the upstream SMTP feature
|
||||
parity.
|
||||
|
||||
## Features
|
||||
|
||||
- `sendMail` payload covers from / to / cc / bcc / replyTo / subject / html
|
||||
/ text / attachments / headers / priority / messageId — the same surface
|
||||
the upstream nodemailer transport accepted.
|
||||
- `verify` delegates to the adapter's `verify` if present; absent, returns
|
||||
the `initialized` flag.
|
||||
- `close` delegates to the adapter's `close` if present.
|
||||
- Zod validation on initialise via `SmtpTransportOptionsSchema`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createSmtpTransport } from "@suaveplan/emailer/transports/smtp";
|
||||
import { FakeSmtpClient } from "@suaveplan/emailer/fakes/fake-smtp-client";
|
||||
|
||||
const adapter = new FakeSmtpClient();
|
||||
adapter.push({ info: { messageId: "<abc@host>" } });
|
||||
|
||||
const transport = createSmtpTransport();
|
||||
await transport.initialize({ client: adapter });
|
||||
const result = await transport.send({
|
||||
to: "user@example.com",
|
||||
subject: "hi",
|
||||
text: "hello",
|
||||
});
|
||||
// result.messageId === "<abc@host>"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// Caller adapts nodemailer in their own app shell:
|
||||
import nodemailer from "nodemailer";
|
||||
import { createSmtpTransport } from "@suaveplan/emailer/transports/smtp";
|
||||
import type { SmtpClientLike } from "@suaveplan/emailer/transports/smtp";
|
||||
|
||||
const nm = nodemailer.createTransport({ host: "smtp.fastmail.com", port: 465, secure: true });
|
||||
const adapter: SmtpClientLike = {
|
||||
sendMail: async (payload) => {
|
||||
const info = await nm.sendMail(payload as unknown as Parameters<typeof nm.sendMail>[0]);
|
||||
return { messageId: info.messageId, accepted: info.accepted, rejected: info.rejected };
|
||||
},
|
||||
verify: () => nm.verify().then(() => true).catch(() => false),
|
||||
close: () => nm.close(),
|
||||
};
|
||||
const transport = createSmtpTransport();
|
||||
await transport.initialize({ client: adapter, host: "smtp.fastmail.com", port: 465, secure: true });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class SmtpTransport extends BaseEmailTransport<SmtpTransportOptions>`
|
||||
- `createSmtpTransport(): SmtpTransport`
|
||||
- `interface SmtpTransportOptions { client: SmtpClientLike; host?; port?; secure?; user?; }`
|
||||
- `interface SmtpClientLike` (re-exported from `./smtp-client-like.types`)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The transport never bundles nodemailer — the dependency stays in the
|
||||
caller's app shell. This keeps the emailer package universal and
|
||||
publishable to npm without bloating the install footprint of consumers
|
||||
who use HTTP-based transports.
|
||||
- `host` / `port` / `secure` / `user` are informational only — they are
|
||||
recorded for OTel attribute purposes but the adapter owns the actual
|
||||
connection.
|
||||
- `send` catches every thrown error from the adapter and converts it into
|
||||
a failed `EmailSendResult`. This matches the public contract of
|
||||
`EmailTransport.send` (never throw on send failure).
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { EMAILER_CODES } from "../../error-codes/error-codes.js";
|
||||
import { FakeSmtpClient } from "../../fakes/fake-smtp-client.js";
|
||||
import { createSmtpTransport, SmtpTransport } from "./smtp-transport.js";
|
||||
|
||||
describe("SmtpTransport", () => {
|
||||
it("delegates send to injected SmtpClientLike", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const client = new FakeSmtpClient();
|
||||
client.push({ info: { messageId: "<abc@host>" } });
|
||||
const t = createSmtpTransport();
|
||||
await t.initialize({ client });
|
||||
const r = await t.send({
|
||||
to: { email: "user@example.com", name: "User" },
|
||||
from: "noreply@example.com",
|
||||
subject: "hi",
|
||||
text: "hello",
|
||||
cc: ["c@x.y"],
|
||||
bcc: ["b@x.y"],
|
||||
replyTo: "r@x.y",
|
||||
html: "<p>hi</p>",
|
||||
attachments: [{ filename: "a.txt", content: "hi" }],
|
||||
headers: { "X-Custom": "yes" },
|
||||
priority: "high",
|
||||
messageId: "<id1>",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.messageId).toBe("<abc@host>");
|
||||
const payload = client.calls[0]!.payload;
|
||||
expect(payload.to).toBe('"User" <user@example.com>');
|
||||
expect(payload.cc).toEqual(["c@x.y"]);
|
||||
});
|
||||
|
||||
it("send returns error when adapter throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const client = new FakeSmtpClient();
|
||||
client.push({ reject: new Error("smtp boom") });
|
||||
const t = new SmtpTransport();
|
||||
await t.initialize({ client });
|
||||
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error?.message).toBe("smtp boom");
|
||||
});
|
||||
|
||||
it("send returns error when message invalid", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const client = new FakeSmtpClient();
|
||||
const t = new SmtpTransport();
|
||||
await t.initialize({ client });
|
||||
const r = await t.send({
|
||||
to: "",
|
||||
subject: "hi",
|
||||
text: "hi",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("send throws if not initialised", { timeout: 5000 }, async () => {
|
||||
const t = new SmtpTransport();
|
||||
await expect(
|
||||
t.send({ to: "a@b.c", subject: "hi", text: "hi" })
|
||||
).rejects.toMatchObject({
|
||||
code: EMAILER_CODES.TRANSPORT_NOT_INITIALIZED,
|
||||
});
|
||||
});
|
||||
|
||||
it("verify delegates to adapter", { timeout: 5000 }, async () => {
|
||||
const client = new FakeSmtpClient();
|
||||
client.scheduleVerify(true);
|
||||
const t = new SmtpTransport();
|
||||
await t.initialize({ client });
|
||||
expect(await t.verify()).toBe(true);
|
||||
});
|
||||
|
||||
it("surfaces verify failure as false", { timeout: 5000 }, async () => {
|
||||
const client = new FakeSmtpClient();
|
||||
client.scheduleVerify(new Error("nope"));
|
||||
const t = new SmtpTransport();
|
||||
await t.initialize({ client });
|
||||
expect(await t.verify()).toBe(false);
|
||||
});
|
||||
|
||||
it("verify without adapter.verify returns initialized", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const client = new FakeSmtpClient();
|
||||
const noVerifyClient = {
|
||||
sendMail: client.sendMail.bind(client),
|
||||
};
|
||||
const t = new SmtpTransport();
|
||||
await t.initialize({ client: noVerifyClient });
|
||||
expect(await t.verify()).toBe(true);
|
||||
});
|
||||
|
||||
it("close delegates to adapter when present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const client = new FakeSmtpClient();
|
||||
const t = new SmtpTransport();
|
||||
await t.initialize({ client });
|
||||
await t.close();
|
||||
expect(client.closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("close skips when adapter has no close", { timeout: 5000 }, async () => {
|
||||
const client: { sendMail: typeof FakeSmtpClient.prototype.sendMail } = {
|
||||
sendMail: new FakeSmtpClient().sendMail.bind(new FakeSmtpClient()),
|
||||
};
|
||||
const t = new SmtpTransport();
|
||||
await t.initialize({ client });
|
||||
await expect(t.close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("send wraps non-Error throw as TRANSPORT_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = {
|
||||
sendMail: async () => {
|
||||
throw "string-error";
|
||||
},
|
||||
};
|
||||
const t = new SmtpTransport();
|
||||
await t.initialize({ client: adapter });
|
||||
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
expect(r.success).toBe(false);
|
||||
expect((r.error as { code?: string }).code).toBe(
|
||||
EMAILER_CODES.TRANSPORT_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("send with html only (text undefined branch)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const client = new FakeSmtpClient();
|
||||
client.push({ info: { messageId: "<h@h>" } });
|
||||
const t = new SmtpTransport();
|
||||
await t.initialize({ client });
|
||||
const r = await t.send({
|
||||
to: "a@b.c",
|
||||
subject: "hi",
|
||||
html: "<p>hi</p>",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
expect(client.calls[0]!.payload.text).toBeUndefined();
|
||||
expect(client.calls[0]!.payload.html).toBe("<p>hi</p>");
|
||||
});
|
||||
|
||||
it("init rejects invalid config", { timeout: 5000 }, async () => {
|
||||
const client = new FakeSmtpClient();
|
||||
const t = new SmtpTransport();
|
||||
await expect(t.initialize({ client, port: 0 })).rejects.toMatchObject({
|
||||
code: EMAILER_CODES.INVALID_CONFIG,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { BaseEmailTransport } from "../../core/email-service/base-email-transport.js";
|
||||
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
|
||||
import { SmtpTransportOptionsSchema } from "../../schemas/schemas.js";
|
||||
import type {
|
||||
EmailMessage,
|
||||
EmailSendResult,
|
||||
} from "../../types/emailer.types.js";
|
||||
import type {
|
||||
SmtpClientLike,
|
||||
SmtpSendPayload,
|
||||
} from "./smtp-client-like.types.js";
|
||||
|
||||
/** Configuration for `SmtpTransport`. */
|
||||
export interface SmtpTransportOptions {
|
||||
/** Caller-supplied SMTP client adapter. Required. */
|
||||
readonly client: SmtpClientLike;
|
||||
/** Optional human-readable host. */
|
||||
readonly host?: string;
|
||||
/** Optional port. */
|
||||
readonly port?: number;
|
||||
/** Optional TLS flag (purely informational; the adapter owns transport security). */
|
||||
readonly secure?: boolean;
|
||||
/** Optional authenticated user (informational only). */
|
||||
readonly user?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP transport that delegates to a caller-supplied `SmtpClientLike`
|
||||
* adapter. The emailer package itself does not bundle nodemailer (or any
|
||||
* other SMTP library); consumers wire the library of their choice in their
|
||||
* own app shell and inject a thin adapter.
|
||||
*/
|
||||
export class SmtpTransport extends BaseEmailTransport<SmtpTransportOptions> {
|
||||
public readonly name = "smtp";
|
||||
#client: SmtpClientLike | undefined;
|
||||
|
||||
public override async initialize(
|
||||
config: SmtpTransportOptions
|
||||
): Promise<void> {
|
||||
const parsed = SmtpTransportOptionsSchema.safeParse({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
user: config.user,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.INVALID_CONFIG,
|
||||
"emailer.errors.invalidConfig",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
await super.initialize(config);
|
||||
this.#client = config.client;
|
||||
}
|
||||
|
||||
public override async send(
|
||||
message: EmailMessage
|
||||
): Promise<EmailSendResult> {
|
||||
this.assertInitialized();
|
||||
const client = this.#client as SmtpClientLike;
|
||||
try {
|
||||
this.validateMessage(message);
|
||||
const payload: SmtpSendPayload = this.#toPayload(message);
|
||||
const info = await client.sendMail(payload);
|
||||
return this.createSuccessResult(info.messageId, info);
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new EmailerError(
|
||||
EMAILER_CODES.TRANSPORT_FAILED,
|
||||
"emailer.errors.transportFailed"
|
||||
);
|
||||
return this.createErrorResult(err);
|
||||
}
|
||||
}
|
||||
|
||||
public override async verify(): Promise<boolean> {
|
||||
if (this.#client === undefined || this.#client.verify === undefined) {
|
||||
return this.initialized;
|
||||
}
|
||||
try {
|
||||
return await this.#client.verify();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public override async close(): Promise<void> {
|
||||
if (this.#client !== undefined && this.#client.close !== undefined) {
|
||||
await this.#client.close();
|
||||
}
|
||||
await super.close();
|
||||
}
|
||||
|
||||
#toPayload(message: EmailMessage): SmtpSendPayload {
|
||||
const out: {
|
||||
-readonly [K in keyof SmtpSendPayload]: SmtpSendPayload[K];
|
||||
} = {
|
||||
from: this.formatAddress(message.from),
|
||||
to: this.formatAddress(message.to),
|
||||
subject: message.subject,
|
||||
};
|
||||
if (message.cc !== undefined) {
|
||||
out.cc = this.formatAddress(message.cc) as string | string[];
|
||||
}
|
||||
if (message.bcc !== undefined) {
|
||||
out.bcc = this.formatAddress(message.bcc) as string | string[];
|
||||
}
|
||||
if (message.replyTo !== undefined) {
|
||||
out.replyTo = this.formatAddress(message.replyTo) as
|
||||
| string
|
||||
| string[];
|
||||
}
|
||||
if (message.html !== undefined) out.html = message.html;
|
||||
if (message.text !== undefined) out.text = message.text;
|
||||
if (message.attachments !== undefined) {
|
||||
out.attachments = message.attachments;
|
||||
}
|
||||
if (message.headers !== undefined) out.headers = message.headers;
|
||||
if (message.priority !== undefined) out.priority = message.priority;
|
||||
if (message.messageId !== undefined) out.messageId = message.messageId;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** Construct an `SmtpTransport`. */
|
||||
export function createSmtpTransport(): SmtpTransport {
|
||||
return new SmtpTransport();
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# `emailer.types.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Single source of truth for every public type surface in `@suaveplan/emailer`.
|
||||
The file pins the shape of email messages, transports, hooks, options, stats,
|
||||
and address bags so every concrete transport and every downstream consumer
|
||||
programs against one contract — never a near-identical local alias. Keeping
|
||||
the types in one file also means the spec-conformance test can assert literal
|
||||
type-unions without crawling every implementation module.
|
||||
|
||||
## Features
|
||||
|
||||
- `EmailAddress`, `EmailAttachment`, `EmailMessage`, `EmailSendResult` —
|
||||
wire-level shapes shared by every transport.
|
||||
- `EmailTransport<TConfig>` — pluggable contract with `initialize`, `send`,
|
||||
optional `verify`, and optional `close`. Concrete transports (Dummy / SMTP
|
||||
/ Gmail REST) implement this directly.
|
||||
- `EmailerOptions<TTransport>` — top-level service config; the transport
|
||||
parameter is generic so `EmailService<TTransport>` carries the transport's
|
||||
capability through its public methods.
|
||||
- `EmailerHooks` — nine lifecycle hook arrays with typed context unions.
|
||||
- `EmailerStats` — running counters (`sent`, `failed`, `queued`, `retries`).
|
||||
- `RetryPolicy`, `QueueLike`, `EventBusLike` — structural shims so callers
|
||||
inject their own implementations.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { EmailMessage, EmailerOptions } from "@suaveplan/emailer/types/emailer";
|
||||
|
||||
const message: EmailMessage = {
|
||||
to: "user@example.com",
|
||||
subject: "hello",
|
||||
html: "<p>hi</p>",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
EmailTransport,
|
||||
EmailerHooks,
|
||||
EmailerOptions,
|
||||
} from "@suaveplan/emailer/types/emailer";
|
||||
|
||||
interface MyConfig {
|
||||
readonly endpoint: string;
|
||||
}
|
||||
|
||||
class MyTransport implements EmailTransport<MyConfig> {
|
||||
public readonly name = "my-transport";
|
||||
public async initialize(_: MyConfig) {
|
||||
/* ... */
|
||||
}
|
||||
public async send() {
|
||||
return { success: true, messageId: "x", timestamp: new Date() };
|
||||
}
|
||||
}
|
||||
|
||||
const hooks: EmailerHooks = {
|
||||
beforeSend: [
|
||||
async (ctx) => {
|
||||
console.info(`about to send ${ctx.message.subject}`);
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options: EmailerOptions<MyTransport> = {
|
||||
transport: new MyTransport(),
|
||||
transportOptions: { endpoint: "https://my.example.com" },
|
||||
hooks,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EmailAddress`, `EmailAttachment`, `EmailMessage`, `EmailSendResult`
|
||||
- `EmailTransport<TConfig>`
|
||||
- `EmailerOptions<TTransport>`
|
||||
- `EmailerHooks`, `HookFunction`, `EmailHookContext`, `EmailResultHookContext`,
|
||||
`EmailErrorHookContext`, `MutableEmailerHooks`
|
||||
- `EmailerStats`
|
||||
- `RetryPolicy`, `QueueLike`, `EventBusLike`, `AddressBag`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All shapes use `readonly` where the field is settable only at construction
|
||||
time. `EmailMessage` itself is mutable because the service may inject
|
||||
`defaultFrom` between validation and send.
|
||||
- `EmailerOptions<TTransport>` uses `infer` to derive the transport's config
|
||||
parameter so `transportOptions` is typed against the supplied transport,
|
||||
not just `unknown`.
|
||||
- The hook context types are nominal (`EmailHookContext` vs
|
||||
`EmailResultHookContext` vs `EmailErrorHookContext`) so an `onError` hook
|
||||
registered against the `afterSend` array fails at compile time.
|
||||
147
packages/communication/emails/emailer/src/types/emailer.types.ts
Normal file
147
packages/communication/emails/emailer/src/types/emailer.types.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { Clock, LoggerLike, Rng, TimersLike } from "@suaveplan/types";
|
||||
|
||||
/** Email address with optional display name. */
|
||||
export interface EmailAddress {
|
||||
readonly email: string;
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
/** Email attachment. */
|
||||
export interface EmailAttachment {
|
||||
readonly filename: string;
|
||||
readonly content: Uint8Array | string;
|
||||
readonly contentType?: string;
|
||||
readonly cid?: string;
|
||||
readonly encoding?: string;
|
||||
}
|
||||
|
||||
/** Address-bag helper used in to / cc / bcc. */
|
||||
export type AddressBag =
|
||||
| string
|
||||
| EmailAddress
|
||||
| ReadonlyArray<string | EmailAddress>;
|
||||
|
||||
/** Single mutable email message. */
|
||||
export interface EmailMessage {
|
||||
to: AddressBag;
|
||||
from?: string | EmailAddress;
|
||||
subject: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
cc?: AddressBag;
|
||||
bcc?: AddressBag;
|
||||
replyTo?: string | EmailAddress;
|
||||
attachments?: readonly EmailAttachment[];
|
||||
headers?: Readonly<Record<string, string>>;
|
||||
priority?: "high" | "normal" | "low";
|
||||
messageId?: string;
|
||||
providerOptions?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** Outcome of a single send. */
|
||||
export interface EmailSendResult {
|
||||
readonly success: boolean;
|
||||
readonly messageId?: string;
|
||||
readonly response?: unknown;
|
||||
readonly error?: Error;
|
||||
readonly timestamp: Date;
|
||||
readonly metadata?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** Pluggable transport contract. */
|
||||
export interface EmailTransport<TConfig = unknown> {
|
||||
/** Human-readable transport name; used as OTel attribute. */
|
||||
readonly name: string;
|
||||
initialize(config: TConfig): Promise<void>;
|
||||
send(message: EmailMessage): Promise<EmailSendResult>;
|
||||
verify?(): Promise<boolean>;
|
||||
close?(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Hook function with a generic context. */
|
||||
export type HookFunction<TContext> = (
|
||||
context: TContext
|
||||
) => Promise<void> | void;
|
||||
|
||||
/** Context passed to lifecycle hooks. */
|
||||
export interface EmailHookContext {
|
||||
readonly message: EmailMessage;
|
||||
readonly metadata: Record<string, unknown>;
|
||||
readonly timestamp: Date;
|
||||
}
|
||||
|
||||
/** Context passed to `afterSend` hooks. */
|
||||
export interface EmailResultHookContext extends EmailHookContext {
|
||||
readonly result: EmailSendResult;
|
||||
}
|
||||
|
||||
/** Context passed to error hooks. */
|
||||
export interface EmailErrorHookContext extends EmailHookContext {
|
||||
readonly error: Error;
|
||||
readonly attempt?: number;
|
||||
}
|
||||
|
||||
/** Nine lifecycle hooks. */
|
||||
export interface EmailerHooks {
|
||||
beforeValidate?: ReadonlyArray<HookFunction<EmailHookContext>>;
|
||||
afterValidate?: ReadonlyArray<HookFunction<EmailHookContext>>;
|
||||
beforeSend?: ReadonlyArray<HookFunction<EmailHookContext>>;
|
||||
afterSend?: ReadonlyArray<HookFunction<EmailResultHookContext>>;
|
||||
onError?: ReadonlyArray<HookFunction<EmailErrorHookContext>>;
|
||||
afterError?: ReadonlyArray<HookFunction<EmailErrorHookContext>>;
|
||||
beforeRetry?: ReadonlyArray<HookFunction<EmailErrorHookContext>>;
|
||||
beforeQueue?: ReadonlyArray<HookFunction<EmailHookContext>>;
|
||||
afterQueue?: ReadonlyArray<HookFunction<EmailHookContext>>;
|
||||
}
|
||||
|
||||
/** Internal mutable hook map. */
|
||||
export type MutableEmailerHooks = {
|
||||
[K in keyof EmailerHooks]-?: Array<NonNullable<EmailerHooks[K]>[number]>;
|
||||
};
|
||||
|
||||
/** Retry policy. */
|
||||
export interface RetryPolicy {
|
||||
readonly maxAttempts: number;
|
||||
readonly delay: number;
|
||||
readonly backoff?: "linear" | "exponential";
|
||||
}
|
||||
|
||||
/** Structural queue contract. */
|
||||
export interface QueueLike {
|
||||
add(fn: () => Promise<void>): Promise<void>;
|
||||
}
|
||||
|
||||
/** Minimal structural event bus. */
|
||||
export interface EventBusLike {
|
||||
emit(event: string, payload: unknown): void;
|
||||
}
|
||||
|
||||
/** Top-level options for the `EmailService`. */
|
||||
export interface EmailerOptions<
|
||||
TTransport extends EmailTransport = EmailTransport,
|
||||
> {
|
||||
readonly transport: TTransport;
|
||||
readonly transportOptions?: TTransport extends EmailTransport<infer TC>
|
||||
? TC
|
||||
: unknown;
|
||||
readonly defaultFrom?: string | EmailAddress;
|
||||
readonly logger?: LoggerLike;
|
||||
readonly events?: EventBusLike;
|
||||
readonly queue?: QueueLike;
|
||||
readonly retry?: RetryPolicy;
|
||||
readonly hooks?: EmailerHooks;
|
||||
readonly clock?: Clock;
|
||||
readonly timers?: TimersLike;
|
||||
readonly rng?: Rng;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Running statistics for an `EmailService`. */
|
||||
export interface EmailerStats {
|
||||
sent: number;
|
||||
failed: number;
|
||||
queued: number;
|
||||
retries: number;
|
||||
startTime: Date;
|
||||
lastSendTime?: Date;
|
||||
}
|
||||
19
packages/communication/emails/emailer/src/types/index.ts
Normal file
19
packages/communication/emails/emailer/src/types/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type {
|
||||
AddressBag,
|
||||
EmailAddress,
|
||||
EmailAttachment,
|
||||
EmailErrorHookContext,
|
||||
EmailerHooks,
|
||||
EmailerOptions,
|
||||
EmailerStats,
|
||||
EmailHookContext,
|
||||
EmailMessage,
|
||||
EmailResultHookContext,
|
||||
EmailSendResult,
|
||||
EmailTransport,
|
||||
EventBusLike,
|
||||
HookFunction,
|
||||
MutableEmailerHooks,
|
||||
QueueLike,
|
||||
RetryPolicy,
|
||||
} from "./emailer.types.js";
|
||||
@@ -0,0 +1,63 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const pkgPath = join(__dirname, "..", "package.json");
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
|
||||
name: string;
|
||||
version: string;
|
||||
sideEffects: boolean;
|
||||
dependencies: Record<string, string>;
|
||||
devDependencies: Record<string, string>;
|
||||
exports: Record<string, unknown>;
|
||||
files: string[];
|
||||
};
|
||||
|
||||
describe("@suaveplan/emailer package.json", () => {
|
||||
it("starts at 0.1.0", { timeout: 5000 }, () => {
|
||||
expect(pkg.version).toBe("0.1.0");
|
||||
});
|
||||
|
||||
it("is tree-shake friendly", { timeout: 5000 }, () => {
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("depends on declared workspace siblings only", { timeout: 5000 }, () => {
|
||||
for (const dep of [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/logger",
|
||||
"@suaveplan/queue",
|
||||
"@suaveplan/timing",
|
||||
"@suaveplan/types",
|
||||
]) {
|
||||
expect(pkg.dependencies[dep]).toBe("workspace:^");
|
||||
}
|
||||
// No nodemailer / googleapis hard deps.
|
||||
expect(pkg.dependencies["nodemailer"]).toBeUndefined();
|
||||
expect(pkg.dependencies["googleapis"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("declares subpath exports", { timeout: 5000 }, () => {
|
||||
for (const subpath of [
|
||||
".",
|
||||
"./error-codes",
|
||||
"./schemas/schemas",
|
||||
"./types/emailer",
|
||||
"./core/email-service",
|
||||
"./core/retry",
|
||||
"./core/validate",
|
||||
"./transports/dummy",
|
||||
"./transports/smtp",
|
||||
"./transports/gmail-rest",
|
||||
"./fakes/fake-smtp-client",
|
||||
"./otel/metrics",
|
||||
]) {
|
||||
expect(pkg.exports[subpath]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes dist, src, README.md", { timeout: 5000 }, () => {
|
||||
expect(pkg.files).toEqual(["dist", "src", "README.md"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC = join(__dirname, "..", "src");
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
out.push(...walk(full));
|
||||
} else if (full.endsWith(".ts")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function find(
|
||||
pattern: RegExp,
|
||||
options: { includeTests?: boolean; onlyIndex?: boolean } = {}
|
||||
): string[] {
|
||||
const files = walk(SRC).filter((f) => {
|
||||
if (options.includeTests !== true && f.endsWith(".test.ts"))
|
||||
return false;
|
||||
if (options.onlyIndex === true && !f.endsWith("index.ts")) return false;
|
||||
return true;
|
||||
});
|
||||
const hits: string[] = [];
|
||||
for (const f of files) {
|
||||
const content = readFileSync(f, "utf8");
|
||||
const lines = content.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (pattern.test(lines[i]!)) {
|
||||
hits.push(`${f}:${i + 1}: ${lines[i]!.trim()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
describe("emailer no-disallowed patterns", () => {
|
||||
it("no vi.mock / jest.mock", { timeout: 5000 }, () => {
|
||||
expect(find(/\b(vi|jest)\.mock\(/, { includeTests: true })).toEqual([]);
|
||||
});
|
||||
|
||||
it("no console.log in production src", { timeout: 5000 }, () => {
|
||||
expect(find(/console\.log\(/)).toEqual([]);
|
||||
});
|
||||
|
||||
it("no biome-ignore comments", { timeout: 5000 }, () => {
|
||||
expect(find(/biome-ignore/, { includeTests: true })).toEqual([]);
|
||||
});
|
||||
|
||||
it("no direct vitest / bun:test imports in test files", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
find(/from ['"](vitest|bun:test)['"]/, { includeTests: true })
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("no export * in non-root barrels", { timeout: 5000 }, () => {
|
||||
const hits = find(/^\s*export\s+(type\s+)?\*\s+from/, {
|
||||
onlyIndex: true,
|
||||
includeTests: true,
|
||||
}).filter((h) => !h.includes(`${SRC}/index.ts:`));
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
12
packages/communication/emails/emailer/tsconfig.lib.json
Normal file
12
packages/communication/emails/emailer/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
41
packages/communication/emails/emailer/vite.config.ts
Normal file
41
packages/communication/emails/emailer/vite.config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/emailer",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/logger",
|
||||
"@suaveplan/queue",
|
||||
"@suaveplan/timing",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/communication/emails/emailer/vitest.config.ts
Normal file
31
packages/communication/emails/emailer/vitest.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user