@strav/mail 1.0.0-alpha.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ // Public API of @strav/mail — mail layer.
2
+ //
3
+ // Shipping:
4
+ // - The `Message` shape + `MailRecipient` / `MailAddress` /
5
+ // `MessageAttachment`.
6
+ // - The `Transport` driver contract.
7
+ // - Two transports: `ArrayTransport` (in-memory recorder for tests)
8
+ // + `LogTransport` (writes to a `Logger` channel — local-dev).
9
+ // - `MailManager` — multi-transport orchestration with `via(name?)`,
10
+ // default-`from` substitution, lazy/cached transport construction,
11
+ // and a Mailable-aware `send(MailableClass, payload)` overload.
12
+ // - `MailProvider` — wires `MailManager` into the container from
13
+ // `config('mail')`.
14
+ // - `Mailable<TPayload>` — typed `Job` subclass; `build(payload)` is
15
+ // the override point, `handle()` is auto-implemented to send via
16
+ // the default transport. Dispatch via `queue.dispatch(MailableClass,
17
+ // payload)` like any other Job.
18
+ //
19
+ // Inbound webhooks:
20
+ // - `PostmarkInboundParser` + `MailgunInboundParser` normalize provider
21
+ // webhooks to `ParsedInboundMail`. Mailgun verifies HMAC-SHA256;
22
+ // Postmark relies on HTTP-level auth (Basic/IP allow-list).
23
+ // - `isAutoGeneratedMessage` — mail-loop guard for application code.
24
+ //
25
+ // Notifications, broadcast, and SSE live in `@strav/notification`.
26
+
27
+ export { isAutoGeneratedMessage } from './inbound/loop_guard.ts'
28
+ export { MailgunInboundParser } from './inbound/mailgun_parser.ts'
29
+ export { PostmarkInboundParser } from './inbound/postmark_parser.ts'
30
+ export type {
31
+ InboundWebhookInput,
32
+ InboundWebhookParser,
33
+ MailgunInboundConfig,
34
+ ParsedInboundAddress,
35
+ ParsedInboundAttachment,
36
+ ParsedInboundMail,
37
+ } from './inbound/types.ts'
38
+ export { MailInboundError } from './inbound_error.ts'
39
+ export { type MailConfig, MailManager, type MailTransportConfig } from './mail_manager.ts'
40
+ export { MailProvider } from './mail_provider.ts'
41
+ export { Mailable, type MailableClass, type MailablePayloadOf } from './mailable.ts'
42
+ export type { MailAddress, MailRecipient, Message, MessageAttachment } from './message.ts'
43
+ export type { Transport } from './transport.ts'
44
+ export { MailTransportError } from './transport_error.ts'
45
+ export {
46
+ AlibabaDmTransport,
47
+ type AlibabaDmTransportOptions,
48
+ } from './transports/alibaba_transport.ts'
49
+ export { ArrayTransport } from './transports/array_transport.ts'
50
+ export { LogTransport, type LogTransportOptions } from './transports/log_transport.ts'
51
+ export { MailgunTransport, type MailgunTransportOptions } from './transports/mailgun_transport.ts'
52
+ export { ResendTransport, type ResendTransportOptions } from './transports/resend_transport.ts'
53
+ export {
54
+ SendGridTransport,
55
+ type SendGridTransportOptions,
56
+ } from './transports/sendgrid_transport.ts'
@@ -0,0 +1,322 @@
1
+ /**
2
+ * `MailManager` — builds and caches one `Transport` per configured
3
+ * mail transport.
4
+ *
5
+ * Built once at boot from `config('mail')`. Validates the config eagerly
6
+ * (default-transport exists, every entry has a known `driver`) so
7
+ * misconfiguration surfaces during provider boot, not on the first
8
+ * `mail.send()` call inside a request. Each transport's underlying
9
+ * resource is constructed lazily on first `via(name)`, then cached for
10
+ * the lifetime of the manager.
11
+ *
12
+ * The `from` substitution
13
+ * `config.mail.from` is an optional default sender. If a `Message`
14
+ * omits `from`, the manager fills it in before handing the message
15
+ * to the transport. If neither is set, the transport throws. The
16
+ * manager does NOT override a `from` the caller already set —
17
+ * per-message overrides win.
18
+ *
19
+ * Shutdown
20
+ * `shutdown()` runs every cached transport's optional `close()`
21
+ * best-effort, swallowing errors so a misbehaving transport can't
22
+ * block app shutdown.
23
+ *
24
+ * Multi-transport apps
25
+ * `via(name?)` returns a named transport (or the default if `name`
26
+ * is omitted). Callers that want to route a particular message
27
+ * through a non-default transport do:
28
+ *
29
+ * await mail.via('priority').send({ to, subject, ... })
30
+ */
31
+
32
+ import { ConfigError, type Container, type Logger, type LogManager } from '@strav/kernel'
33
+ import type { MailableClass, MailablePayloadOf } from './mailable.ts'
34
+ import type { MailRecipient, Message } from './message.ts'
35
+ import type { Transport } from './transport.ts'
36
+ import { AlibabaDmTransport } from './transports/alibaba_transport.ts'
37
+ import { ArrayTransport } from './transports/array_transport.ts'
38
+ import { LogTransport } from './transports/log_transport.ts'
39
+ import { MailgunTransport } from './transports/mailgun_transport.ts'
40
+ import { ResendTransport } from './transports/resend_transport.ts'
41
+ import { SendGridTransport } from './transports/sendgrid_transport.ts'
42
+
43
+ /** Per-driver transport config shapes. */
44
+ interface ArrayTransportConfig {
45
+ driver: 'array'
46
+ }
47
+
48
+ interface LogTransportConfig {
49
+ driver: 'log'
50
+ /**
51
+ * Logger channel to write to. Falls back to the default channel when
52
+ * omitted. Apps typically dedicate a channel (`'mail'`) so dev output
53
+ * is filterable.
54
+ */
55
+ channel?: string
56
+ /** Default `'info'`. */
57
+ level?: 'debug' | 'info'
58
+ /** See `LogTransportOptions.includeBody`. Default `false`. */
59
+ includeBody?: boolean
60
+ }
61
+
62
+ interface ResendTransportConfig {
63
+ driver: 'resend'
64
+ /** Resend API key. Pull from env in `config/mail.ts`; never hard-code. */
65
+ apiKey: string
66
+ /** Override the base URL — defaults to `https://api.resend.com`. */
67
+ endpoint?: string
68
+ }
69
+
70
+ interface SendGridTransportConfig {
71
+ driver: 'sendgrid'
72
+ /** SendGrid API key. */
73
+ apiKey: string
74
+ /** Override the base URL — defaults to `https://api.sendgrid.com`. */
75
+ endpoint?: string
76
+ }
77
+
78
+ interface MailgunTransportConfig {
79
+ driver: 'mailgun'
80
+ /** Mailgun API key. */
81
+ apiKey: string
82
+ /** Your Mailgun-verified sending domain (e.g. `mg.acme.com`). */
83
+ domain: string
84
+ /**
85
+ * Override the base URL — defaults to `https://api.mailgun.net`.
86
+ * Set to `https://api.eu.mailgun.net` for EU-region accounts.
87
+ */
88
+ endpoint?: string
89
+ }
90
+
91
+ interface AlibabaDmTransportConfig {
92
+ driver: 'alibaba'
93
+ /** Alibaba Cloud AccessKey ID. */
94
+ accessKeyId: string
95
+ /** Alibaba Cloud AccessKey Secret. */
96
+ accessKeySecret: string
97
+ /** Verified DirectMail sender account (set in the DM console). */
98
+ accountName: string
99
+ /**
100
+ * Override the base URL — defaults to `https://dm.aliyuncs.com` (global).
101
+ * SEA: `https://dm.ap-southeast-1.aliyuncs.com` (Singapore),
102
+ * `https://dm.ap-southeast-3.aliyuncs.com` (Kuala Lumpur),
103
+ * `https://dm.ap-southeast-5.aliyuncs.com` (Jakarta).
104
+ */
105
+ endpoint?: string
106
+ /** Optional `TagName` attached to every send. */
107
+ tagName?: string
108
+ /** Enable DM click-tracking. Default false. */
109
+ clickTrace?: boolean
110
+ }
111
+
112
+ /**
113
+ * Discriminated union — every shipping driver gets an entry. Adding a
114
+ * new driver here + a new `case` in `buildTransport` is the contract;
115
+ * apps configure by string name.
116
+ */
117
+ export type MailTransportConfig =
118
+ | ArrayTransportConfig
119
+ | LogTransportConfig
120
+ | ResendTransportConfig
121
+ | SendGridTransportConfig
122
+ | MailgunTransportConfig
123
+ | AlibabaDmTransportConfig
124
+
125
+ export interface MailConfig {
126
+ /**
127
+ * Name of the transport used by `send()` when the caller doesn't
128
+ * specify one. Must be a key of `transports`.
129
+ */
130
+ default: string
131
+ /**
132
+ * Default `from` filled in when a `Message` omits one. Optional —
133
+ * apps that always pass `from` per-message can leave this off.
134
+ */
135
+ from?: MailRecipient
136
+ /** Transport instances keyed by name. */
137
+ transports: Record<string, MailTransportConfig>
138
+ }
139
+
140
+ export class MailManager {
141
+ private readonly cache = new Map<string, Transport>()
142
+
143
+ constructor(
144
+ private readonly config: MailConfig,
145
+ private readonly logManager: LogManager,
146
+ /**
147
+ * Optional `Container` used by the `send(MailableClass, payload)`
148
+ * overload to construct mailables via `@inject()` reflection. Apps
149
+ * that only ever call `send(message)` can omit it; the Mailable
150
+ * overload throws a clear error if called without one wired.
151
+ *
152
+ * The `MailProvider` passes the resolving container automatically,
153
+ * so apps using the provider don't pass anything by hand.
154
+ */
155
+ private readonly container?: Container,
156
+ ) {
157
+ this.validate(config)
158
+ }
159
+
160
+ /**
161
+ * Send `message` via the default transport. Applies `config.mail.from`
162
+ * if the message lacks one.
163
+ */
164
+ async send(message: Message): Promise<void>
165
+ /**
166
+ * Sync-send overload — constructs the `Mailable` subclass via the
167
+ * container (so `@inject()` deps resolve), calls `build(payload)`,
168
+ * then sends through the default transport. No queue hop; the
169
+ * caller's process does the work.
170
+ *
171
+ * For async / retry / dead-letter semantics, dispatch through the
172
+ * queue instead — `await queue.dispatch(WelcomeEmail, payload)`.
173
+ * Mailables ARE Jobs, so the queue's `Worker` handles them with no
174
+ * additional wiring beyond a `JobRegistry.register(WelcomeEmail)`.
175
+ */
176
+ async send<T extends MailableClass>(
177
+ MailableClass: T,
178
+ payload: MailablePayloadOf<T>,
179
+ ): Promise<void>
180
+ async send(arg1: Message | MailableClass, payload?: unknown): Promise<void> {
181
+ if (typeof arg1 === 'function') {
182
+ const message = await this.buildMailable(arg1, payload)
183
+ await this.via().send(this.applyDefaultFrom(message))
184
+ return
185
+ }
186
+ await this.via().send(this.applyDefaultFrom(arg1))
187
+ }
188
+
189
+ /**
190
+ * Resolve a transport by name. Pass nothing to get the default.
191
+ * Returned transport is cached — subsequent calls return the same
192
+ * instance.
193
+ */
194
+ via(name?: string): Transport {
195
+ const key = name ?? this.config.default
196
+ const cached = this.cache.get(key)
197
+ if (cached) return cached
198
+ const built = this.buildTransport(key)
199
+ this.cache.set(key, built)
200
+ return built
201
+ }
202
+
203
+ /**
204
+ * Close every cached transport. Best-effort — individual transport
205
+ * `close()` errors are swallowed so a misbehaving driver can't block
206
+ * shutdown.
207
+ */
208
+ async shutdown(): Promise<void> {
209
+ const open = [...this.cache.values()]
210
+ this.cache.clear()
211
+ await Promise.all(
212
+ open.map(async (t) => {
213
+ if (t.close === undefined) return
214
+ try {
215
+ await t.close()
216
+ } catch {
217
+ // Best-effort: never throw during shutdown.
218
+ }
219
+ }),
220
+ )
221
+ }
222
+
223
+ // ─── Internals ─────────────────────────────────────────────────────────────
224
+
225
+ private async buildMailable(MailableClass: MailableClass, payload: unknown): Promise<Message> {
226
+ if (this.container === undefined) {
227
+ throw new ConfigError(
228
+ 'MailManager: send(MailableClass, payload) requires a Container — wire MailProvider instead of constructing MailManager directly.',
229
+ )
230
+ }
231
+ const mailable = this.container.make(MailableClass)
232
+ return mailable.build(payload as never)
233
+ }
234
+
235
+ private applyDefaultFrom(message: Message): Message {
236
+ if (message.from !== undefined) return message
237
+ if (this.config.from === undefined) return message
238
+ return { ...message, from: this.config.from }
239
+ }
240
+
241
+ private buildTransport(name: string): Transport {
242
+ const cfg = this.config.transports[name]
243
+ if (cfg === undefined) {
244
+ throw new ConfigError(`Mail: transport "${name}" is not defined in config.`)
245
+ }
246
+ switch (cfg.driver) {
247
+ case 'array':
248
+ return new ArrayTransport()
249
+ case 'log': {
250
+ const logger: Logger =
251
+ cfg.channel !== undefined
252
+ ? this.logManager.channel(cfg.channel)
253
+ : this.logManager.default()
254
+ return new LogTransport({
255
+ logger,
256
+ level: cfg.level,
257
+ includeBody: cfg.includeBody,
258
+ })
259
+ }
260
+ case 'resend':
261
+ return new ResendTransport({ apiKey: cfg.apiKey, endpoint: cfg.endpoint })
262
+ case 'sendgrid':
263
+ return new SendGridTransport({ apiKey: cfg.apiKey, endpoint: cfg.endpoint })
264
+ case 'mailgun':
265
+ return new MailgunTransport({
266
+ apiKey: cfg.apiKey,
267
+ domain: cfg.domain,
268
+ endpoint: cfg.endpoint,
269
+ })
270
+ case 'alibaba':
271
+ return new AlibabaDmTransport({
272
+ accessKeyId: cfg.accessKeyId,
273
+ accessKeySecret: cfg.accessKeySecret,
274
+ accountName: cfg.accountName,
275
+ endpoint: cfg.endpoint,
276
+ tagName: cfg.tagName,
277
+ clickTrace: cfg.clickTrace,
278
+ })
279
+ }
280
+ }
281
+
282
+ private validate(config: MailConfig): void {
283
+ if (config.transports[config.default] === undefined) {
284
+ throw new ConfigError(
285
+ `Mail: default transport "${config.default}" is not defined in transports.`,
286
+ )
287
+ }
288
+ const knownDrivers = new Set(['array', 'log', 'resend', 'sendgrid', 'mailgun', 'alibaba'])
289
+ for (const [name, cfg] of Object.entries(config.transports)) {
290
+ if (!knownDrivers.has(cfg.driver)) {
291
+ throw new ConfigError(
292
+ `Mail: transport "${name}" has unknown driver "${(cfg as { driver: string }).driver}".`,
293
+ )
294
+ }
295
+ if (
296
+ (cfg.driver === 'resend' || cfg.driver === 'sendgrid' || cfg.driver === 'mailgun') &&
297
+ !cfg.apiKey
298
+ ) {
299
+ throw new ConfigError(
300
+ `Mail: transport "${name}" (${cfg.driver}) requires a non-empty \`apiKey\`.`,
301
+ )
302
+ }
303
+ if (cfg.driver === 'mailgun' && !cfg.domain) {
304
+ throw new ConfigError(
305
+ `Mail: transport "${name}" (mailgun) requires a non-empty \`domain\`.`,
306
+ )
307
+ }
308
+ if (cfg.driver === 'alibaba') {
309
+ if (!cfg.accessKeyId || !cfg.accessKeySecret) {
310
+ throw new ConfigError(
311
+ `Mail: transport "${name}" (alibaba) requires \`accessKeyId\` and \`accessKeySecret\`.`,
312
+ )
313
+ }
314
+ if (!cfg.accountName) {
315
+ throw new ConfigError(
316
+ `Mail: transport "${name}" (alibaba) requires \`accountName\` — the verified DirectMail sender.`,
317
+ )
318
+ }
319
+ }
320
+ }
321
+ }
322
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `MailProvider` reads `config('mail')`, constructs a `MailManager`,
3
+ * and binds:
4
+ * - `MailManager` (singleton) — the public mail surface.
5
+ * - `'mail'` (string key, singleton) — alias resolving to the same
6
+ * `MailManager`, so apps can `@inject('mail')` without importing
7
+ * the class.
8
+ *
9
+ * Depends on `'config'` and `'logger'`, so `ConfigProvider` and
10
+ * `LoggerProvider` must be registered first. The provider's
11
+ * `shutdown()` runs `MailManager.shutdown()` to close every cached
12
+ * transport.
13
+ *
14
+ * @see docs/signal/api.md
15
+ */
16
+
17
+ import {
18
+ type Application,
19
+ ConfigError,
20
+ ConfigRepository,
21
+ LogManager,
22
+ ServiceProvider,
23
+ } from '@strav/kernel'
24
+ import { type MailConfig, MailManager } from './mail_manager.ts'
25
+
26
+ export class MailProvider extends ServiceProvider {
27
+ override readonly name = 'mail'
28
+ override readonly dependencies = ['config', 'logger']
29
+
30
+ override register(app: Application): void {
31
+ app.singleton(MailManager, (c) => {
32
+ const raw = c.resolve(ConfigRepository).get('mail')
33
+ if (raw === undefined || raw === null) {
34
+ throw new ConfigError(
35
+ 'MailProvider: `config.mail` is missing. Add a `config/mail.ts` file (see docs/signal/README.md).',
36
+ )
37
+ }
38
+ return new MailManager(raw as MailConfig, c.resolve(LogManager), c)
39
+ })
40
+ app.singleton('mail', (c) => c.resolve(MailManager))
41
+ }
42
+
43
+ override async boot(app: Application): Promise<void> {
44
+ // Construct the manager now so config errors surface at boot —
45
+ // not on the first send call inside a request.
46
+ app.resolve(MailManager)
47
+ }
48
+
49
+ override async shutdown(app: Application): Promise<void> {
50
+ try {
51
+ if (!app.has(MailManager)) return
52
+ await app.resolve(MailManager).shutdown()
53
+ } catch {
54
+ // No manager was constructed (config missing / boot failed earlier) —
55
+ // nothing to clean up.
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `Mailable<TPayload>` — a typed `Job` that builds and sends a mail
3
+ * message.
4
+ *
5
+ * Apps subclass `Mailable`, set `static override jobName`, and implement
6
+ * `build(payload)` to produce a `Message`. The framework provides
7
+ * `handle()` — it calls `build(ctx.payload)` and forwards the result to
8
+ * `MailManager.send(message)`.
9
+ *
10
+ * Because `Mailable extends Job`, mailables participate in the full job
11
+ * lifecycle: retries, backoff, abort-aware shutdown, `failed()` hook,
12
+ * dead-letter via `strav_failed_jobs`. There is no separate
13
+ * `MailableRegistry` — mailables register with the same `JobRegistry`
14
+ * the rest of the app uses, and dispatch the same way:
15
+ *
16
+ * await queue.dispatch(WelcomeEmail, { userId: '01J...' })
17
+ *
18
+ * For sync sends inside a request (no queue hop), apps call either
19
+ * `MailManager.send(WelcomeEmail, payload)` (the overload constructs
20
+ * the Mailable via the container, builds, sends) or — if the message
21
+ * is already built — `MailManager.send(message)` directly.
22
+ *
23
+ * Dependency injection
24
+ * The base class declares `@inject()` and a constructor taking
25
+ * `MailManager`. Subclasses without additional deps inherit both
26
+ * shape and metadata — `container.make(WelcomeEmail)` resolves
27
+ * `MailManager` via the inherited `@inject()` reflection.
28
+ *
29
+ * Subclasses with extra deps redeclare:
30
+ *
31
+ * @inject()
32
+ * class WelcomeEmail extends Mailable<{ userId: string }> {
33
+ * static override readonly jobName = 'mail.welcome'
34
+ * constructor(
35
+ * mail: MailManager,
36
+ * private readonly users: UserRepository,
37
+ * ) { super(mail) }
38
+ * async build({ userId }) {
39
+ * const user = await this.users.findOrFail(userId)
40
+ * return { to: user.email, subject: 'Welcome', text: `Hi ${user.name}` }
41
+ * }
42
+ * }
43
+ *
44
+ * Payload shape
45
+ * `TPayload` must round-trip through JSON — same constraint as any
46
+ * `Job` payload. The Worker `JSON.stringify`s on dispatch and
47
+ * `JSON.parse`s on pick-up; non-serialisable values silently corrupt.
48
+ */
49
+
50
+ import { inject } from '@strav/kernel'
51
+ import { Job, type JobContext } from '@strav/queue'
52
+ // Value import required — `@inject()` reads constructor paramtypes via
53
+ // `emitDecoratorMetadata`, which erases `import type` references to
54
+ // `Object`. Container then can't resolve them. This is one-way:
55
+ // `mail_manager.ts` imports types from this file only, so there's no
56
+ // runtime cycle. Biome's useImportType lint suggests the wrong fix.
57
+ // biome-ignore lint/style/useImportType: value import is load-bearing for @inject() metadata emission
58
+ import { MailManager } from './mail_manager.ts'
59
+ import type { Message } from './message.ts'
60
+
61
+ @inject()
62
+ export abstract class Mailable<TPayload = unknown> extends Job<TPayload> {
63
+ constructor(protected readonly mail: MailManager) {
64
+ super()
65
+ }
66
+
67
+ /**
68
+ * Construct the `Message` to send. Called once per attempt. Receives
69
+ * the dispatched payload (JSON-deserialized when running under a
70
+ * persistent queue driver).
71
+ *
72
+ * May read async resources — the `Job` lifecycle awaits it. Throwing
73
+ * triggers the standard retry path; consider whether the thrown
74
+ * condition is transient (give up after `maxAttempts`) or permanent
75
+ * (override `maxAttempts = 1` so it dead-letters immediately).
76
+ */
77
+ abstract build(payload: TPayload): Message | Promise<Message>
78
+
79
+ /**
80
+ * Default handler — builds the message, sends it through the default
81
+ * transport. Subclasses can override (e.g. to route through a named
82
+ * transport via `this.mail.via('priority').send(message)`), but the
83
+ * default covers the common case.
84
+ */
85
+ override async handle(context: JobContext<TPayload>): Promise<void> {
86
+ const message = await this.build(context.payload)
87
+ await this.mail.send(message)
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Constructor-shape for any `Mailable` subclass — used by
93
+ * `MailManager.send(MailableClass, payload)` typing. Mirrors
94
+ * `JobClass` from `@strav/queue` but constrained to `Mailable`.
95
+ */
96
+ export interface MailableClass<TPayload = unknown> {
97
+ // biome-ignore lint/suspicious/noExplicitAny: matches kernel `Constructor<T>` variance — subclasses have arbitrary constructor params
98
+ new (...args: any[]): Mailable<TPayload>
99
+ readonly jobName: string
100
+ }
101
+
102
+ /**
103
+ * Extract the payload type from a `MailableClass` reference. Useful for
104
+ * typed wrappers that take a `MailableClass` and need to type their
105
+ * `payload` parameter against it.
106
+ */
107
+ export type MailablePayloadOf<T> = T extends MailableClass<infer P> ? P : never
package/src/message.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `Message` — the wire-shape every Strav `Transport` accepts.
3
+ *
4
+ * Plain data, deliberately. The same `Message` literal a test handler
5
+ * constructs by hand is the same shape a future `Mailable.build()` will
6
+ * return — no driver-specific fields, no transport-specific options.
7
+ * Transports translate this into their own provider format (SMTP
8
+ * envelope, Resend JSON, SendGrid v3, etc.) on `send()`.
9
+ *
10
+ * Recipients
11
+ * `to` / `cc` / `bcc` / `replyTo` accept either a bare email string
12
+ * (`'alice@example.com'`) or a `{ email, name? }` object. A list of
13
+ * either is fine — `to: ['a@x', { email: 'b@x', name: 'Bob' }]` is
14
+ * legal. Use the structured form when the display name matters.
15
+ *
16
+ * Body
17
+ * At least one of `html` / `text` must be present. Transports that
18
+ * support multipart will send both when both are set; transports that
19
+ * only support one degrade as documented in the driver's notes.
20
+ *
21
+ * Headers + attachments
22
+ * `headers` is a flat string-to-string map; transports apply provider
23
+ * constraints (e.g. SMTP rejects newlines). `attachments` carries an
24
+ * optional list — see `MessageAttachment`. Per-driver size limits
25
+ * apply; transports throw on oversize content.
26
+ */
27
+
28
+ export interface MailAddress {
29
+ /** RFC 5322 addr-spec. Validated by the transport, not here. */
30
+ email: string
31
+ /** Optional display name. Joined as `"Name" <email>` by transports that support it. */
32
+ name?: string
33
+ }
34
+
35
+ /** Either a bare email or a `{ email, name? }` pair. */
36
+ export type MailRecipient = string | MailAddress
37
+
38
+ /**
39
+ * File attached to a message. `content` is the raw bytes; pass a
40
+ * `Uint8Array` for binary data or a UTF-8 `string` for text. When
41
+ * passing a string that's actually a base64-encoded payload, set
42
+ * `encoding: 'base64'` so transports decode it before transmission.
43
+ */
44
+ export interface MessageAttachment {
45
+ filename: string
46
+ content: string | Uint8Array
47
+ /** Defaults to `application/octet-stream` if omitted; transports may sniff from filename. */
48
+ contentType?: string
49
+ /** How `content` is encoded when it's a string. Defaults to `'utf-8'`. */
50
+ encoding?: 'utf-8' | 'base64'
51
+ }
52
+
53
+ export interface Message {
54
+ to: MailRecipient | MailRecipient[]
55
+ /**
56
+ * Optional. When omitted, `MailManager.send` substitutes `config.mail.from`
57
+ * before handing the message to the transport. If neither is set, the
58
+ * transport throws — there is no implicit "MAIL FROM:" guess.
59
+ */
60
+ from?: MailRecipient
61
+ cc?: MailRecipient | MailRecipient[]
62
+ bcc?: MailRecipient | MailRecipient[]
63
+ replyTo?: MailRecipient | MailRecipient[]
64
+ subject: string
65
+ /** HTML body. At least one of `html` / `text` is required. */
66
+ html?: string
67
+ /** Plain-text body. At least one of `html` / `text` is required. */
68
+ text?: string
69
+ /** Flat header map; values must be ASCII single-line strings. */
70
+ headers?: Record<string, string>
71
+ attachments?: MessageAttachment[]
72
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `Transport` — the per-driver contract every mail backend implements.
3
+ *
4
+ * `MailManager` holds a name → `Transport` map (Resend, SMTP, log,
5
+ * array, …) and delegates `send()` to the one named in `config.mail.default`
6
+ * (or whichever the caller passed to `via()`). Drivers translate the
7
+ * `Message` shape into their provider format and report transport
8
+ * errors by throwing.
9
+ *
10
+ * Lifecycle
11
+ * `close()` is optional. Implementations that hold long-lived
12
+ * resources (a pooled SMTP connection, a kept-alive HTTPS agent) use
13
+ * it to flush and release; the `MailManager.shutdown()` path awaits
14
+ * every cached transport's `close()` best-effort.
15
+ *
16
+ * Authoring a new driver
17
+ * 1. Class implementing `Transport`.
18
+ * 2. Constructor takes its config shape — never the global `MailConfig`.
19
+ * 3. `send()` rejects with an `Error` subclass that carries enough
20
+ * context for the queue Worker's `failed()` hook to log usefully
21
+ * (provider, status, attempted recipients).
22
+ * 4. Register the driver in `MailManager.buildTransport` (alpha-N
23
+ * bumps; once 1.0 ships, a `MailTransportRegistry` lets apps
24
+ * register additional drivers without forking).
25
+ */
26
+
27
+ import type { Message } from './message.ts'
28
+
29
+ export interface Transport {
30
+ /** Transmit `message`. Throws on transport-level failure. */
31
+ send(message: Message): Promise<void>
32
+ /** Optional cleanup. Called once on manager shutdown. Must not throw. */
33
+ close?(): void | Promise<void>
34
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `MailTransportError` — typed error raised by mail `Transport`
3
+ * implementations when a `send()` fails at the transport level
4
+ * (network / HTTP non-2xx / provider rejection).
5
+ *
6
+ * The Worker's `failed(ctx)` hook receives the thrown error as
7
+ * `ctx.error`, so carrying provider + status + retry-hint in the
8
+ * `context` payload makes log records and dead-letter rows actionable
9
+ * without parsing exception strings.
10
+ *
11
+ * throw new MailTransportError('Resend rejected the request', {
12
+ * context: {
13
+ * provider: 'resend',
14
+ * status: 422,
15
+ * retryable: false,
16
+ * providerError: { name: 'validation_error', message: '...' },
17
+ * },
18
+ * })
19
+ *
20
+ * `status` on the error itself is fixed at 502 — this is a Strav
21
+ * server-side surface ("an upstream mail provider failed"). The
22
+ * provider's HTTP status lives under `context.status`.
23
+ */
24
+
25
+ import { StravError, type StravErrorOptions } from '@strav/kernel'
26
+
27
+ export class MailTransportError extends StravError {
28
+ constructor(message: string, options: StravErrorOptions = {}) {
29
+ super(message, { code: 'mail-transport-error', status: 502 }, options)
30
+ }
31
+ }