@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/README.md +112 -0
- package/package.json +29 -0
- package/src/inbound/loop_guard.ts +18 -0
- package/src/inbound/mailgun_parser.ts +218 -0
- package/src/inbound/postmark_parser.ts +159 -0
- package/src/inbound/types.ts +82 -0
- package/src/inbound_error.ts +22 -0
- package/src/index.ts +56 -0
- package/src/mail_manager.ts +322 -0
- package/src/mail_provider.ts +58 -0
- package/src/mailable.ts +107 -0
- package/src/message.ts +72 -0
- package/src/transport.ts +34 -0
- package/src/transport_error.ts +31 -0
- package/src/transports/alibaba_transport.ts +273 -0
- package/src/transports/array_transport.ts +36 -0
- package/src/transports/internal/normalize.ts +92 -0
- package/src/transports/log_transport.ts +74 -0
- package/src/transports/mailgun_transport.ts +160 -0
- package/src/transports/resend_transport.ts +149 -0
- package/src/transports/sendgrid_transport.ts +179 -0
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
|
+
}
|
package/src/mailable.ts
ADDED
|
@@ -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
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -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
|
+
}
|