ajo-kit-mail 0.1.0

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/errors.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { Failure } from 'ajo-kit'
2
+
3
+ /** Why a message was rejected before any network work happened. */
4
+ export type RefusalCode =
5
+ | 'no-transport'
6
+ | 'invalid-config'
7
+ | 'invalid-sender'
8
+ | 'invalid-recipient'
9
+ | 'invalid-subject'
10
+ | 'invalid-name'
11
+ | 'invalid-kind'
12
+ | 'invalid-key'
13
+ | 'empty-body'
14
+ | 'too-large'
15
+ | 'expired'
16
+
17
+ /** Why an accepted envelope failed in flight. */
18
+ export type DeliveryCode =
19
+ | 'timeout'
20
+ | 'busy'
21
+ | 'connection'
22
+ | 'tls'
23
+ | 'auth'
24
+ | 'rejected'
25
+ | 'throttled'
26
+ | 'unavailable'
27
+ | 'unknown'
28
+
29
+ const CONFIGURATION: ReadonlySet<RefusalCode> = new Set(['no-transport', 'invalid-config', 'invalid-sender'])
30
+ const DELIVERY: ReadonlySet<DeliveryCode> = new Set([
31
+ 'timeout',
32
+ 'busy',
33
+ 'connection',
34
+ 'tls',
35
+ 'auth',
36
+ 'rejected',
37
+ 'throttled',
38
+ 'unavailable',
39
+ 'unknown',
40
+ ])
41
+ const RETRYABLE: ReadonlySet<DeliveryCode> = new Set([
42
+ 'timeout',
43
+ 'busy',
44
+ 'connection',
45
+ 'throttled',
46
+ 'unavailable',
47
+ ])
48
+
49
+ /**
50
+ * The message or the configuration was rejected. Nothing was sent and no provider
51
+ * was contacted. Extends Failure with a deliberate status so normalize() cannot
52
+ * inherit a provider status and server.tsx never console.error()s the object.
53
+ * The message is the code and never echoes an input value.
54
+ */
55
+ export class Refused extends Failure {
56
+
57
+ readonly code: RefusalCode
58
+
59
+ constructor(code: RefusalCode) {
60
+ super(CONFIGURATION.has(code) ? 500 : 422, `Mail refused: ${code}`)
61
+ this.name = 'Refused'
62
+ this.code = code
63
+ if (CONFIGURATION.has(code)) console.error(`[mail] refused: ${code}`)
64
+ }
65
+ }
66
+
67
+ /**
68
+ * A transport accepted the envelope and the attempt failed. Carries a
69
+ * classification, a retry verdict and at most a protocol status. Never provider
70
+ * prose, never a recipient, never a subject, never a body, never a cause.
71
+ */
72
+ export class Undelivered extends Failure {
73
+
74
+ readonly code: DeliveryCode
75
+ readonly retryable: boolean
76
+ /** Protocol status only: 'smtp 451', 'status 429'. */
77
+ readonly hint?: string
78
+
79
+ constructor(code: DeliveryCode, retryable: boolean, hint?: string) {
80
+ super(502, `Mail delivery failed: ${code}`)
81
+ this.name = 'Undelivered'
82
+ this.code = code
83
+ this.retryable = retryable
84
+ this.hint = hint
85
+ }
86
+ }
87
+
88
+ type Shape = {
89
+ name?: unknown
90
+ code?: unknown
91
+ responseCode?: unknown
92
+ status?: unknown
93
+ statusCode?: unknown
94
+ }
95
+
96
+ const protocol = (value: unknown) =>
97
+ typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599
98
+ ? value
99
+ : undefined
100
+
101
+ /**
102
+ * Reduces any thrown value to an Undelivered by reading shape only: name, code,
103
+ * responseCode/status/statusCode. Never .message, never .cause, never .response,
104
+ * never .envelope. Exported so a third-party transport inherits the guarantee
105
+ * instead of reimplementing it.
106
+ */
107
+ export function classify(error: unknown): Undelivered {
108
+ const value: Shape = error !== null && (typeof error === 'object' || typeof error === 'function')
109
+ ? error
110
+ : {}
111
+ const rawName = value.name
112
+ const rawCode = value.code
113
+ const rawResponseCode = value.responseCode
114
+ const rawStatus = value.status
115
+ const rawStatusCode = value.statusCode
116
+ const name = typeof rawName === 'string' ? rawName : ''
117
+ const code = typeof rawCode === 'string' ? rawCode : ''
118
+ const responseCode = protocol(rawResponseCode)
119
+ const directStatus = protocol(rawStatus)
120
+ const statusCode = protocol(rawStatusCode)
121
+ const status = responseCode ?? directStatus ?? statusCode
122
+ const hint = status === undefined
123
+ ? undefined
124
+ : `${responseCode === undefined ? 'status' : 'smtp'} ${status}`
125
+ const undelivered = (delivery: DeliveryCode, retryable: boolean, detail?: string) =>
126
+ new Undelivered(delivery, retryable, detail)
127
+
128
+ if (name === 'Undelivered' && DELIVERY.has(code as DeliveryCode)) {
129
+ const delivery = code as DeliveryCode
130
+ return undelivered(delivery, RETRYABLE.has(delivery))
131
+ }
132
+ if (
133
+ code === 'EAUTH'
134
+ || responseCode === 530
135
+ || responseCode === 535
136
+ || (responseCode === undefined && (status === 401 || status === 403))
137
+ ) {
138
+ return undelivered('auth', false, hint)
139
+ }
140
+ if (code === 'ETLS' || code.startsWith('ERR_TLS') || code.includes('CERT')) {
141
+ return undelivered('tls', false)
142
+ }
143
+ if (
144
+ name === 'AbortError'
145
+ || name === 'TimeoutError'
146
+ || code === 'ETIMEDOUT'
147
+ || (code === 'ECONNECTION' && status === undefined)
148
+ || (responseCode === undefined && status === 408)
149
+ ) {
150
+ return undelivered('timeout', true)
151
+ }
152
+ if (responseCode !== undefined && responseCode >= 400 && responseCode < 500) {
153
+ return undelivered('throttled', true, hint)
154
+ }
155
+ if (responseCode !== undefined && responseCode >= 500) {
156
+ return undelivered('rejected', false, hint)
157
+ }
158
+ if (status === 429) return undelivered('throttled', true, hint)
159
+ if (status !== undefined && status >= 500) return undelivered('unavailable', true, hint)
160
+ if (status !== undefined && status >= 400) return undelivered('rejected', false, hint)
161
+ if (code) return undelivered('connection', true)
162
+
163
+ return undelivered('unknown', false)
164
+ }
package/src/http.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { classify } from './errors'
2
+ import type { Receipt, Transport } from './index'
3
+ import type { Sealed } from './seal'
4
+
5
+ const RESPONSE_BYTES = 65_536
6
+
7
+ const payload = async (response: Response): Promise<unknown> => {
8
+ if (!response.body) return
9
+
10
+ const reader = response.body.getReader()
11
+ const bytes = new Uint8Array(RESPONSE_BYTES)
12
+ let size = 0
13
+ let complete = false
14
+
15
+ try {
16
+ while (size < RESPONSE_BYTES) {
17
+ const chunk = await reader.read()
18
+ if (chunk.done) {
19
+ complete = true
20
+ break
21
+ }
22
+
23
+ const length = Math.min(chunk.value.byteLength, RESPONSE_BYTES - size)
24
+ bytes.set(chunk.value.subarray(0, length), size)
25
+ size += length
26
+ }
27
+ } finally {
28
+ if (complete) {
29
+ reader.releaseLock()
30
+ } else {
31
+ await reader.cancel().catch(() => {})
32
+ }
33
+ }
34
+
35
+ if (!size) return
36
+ return JSON.parse(new TextDecoder().decode(bytes.subarray(0, size)))
37
+ }
38
+
39
+ /** JSON provider settings. The body mapping is the only provider-specific code an app writes. */
40
+ export interface HttpOptions {
41
+ url: string
42
+ /** Static headers, or a factory so a rotated token is read per send, not captured at boot. */
43
+ headers?: Record<string, string> | (() => Record<string, string>)
44
+ /** Builds the provider payload from the validated envelope. */
45
+ body: (mail: Sealed) => unknown
46
+ /** Reads the provider id from the parsed success response. */
47
+ id?: (payload: unknown) => string | undefined
48
+ /** Reported in delivery events. Default 'http'. */
49
+ label?: string
50
+ }
51
+
52
+ /**
53
+ * Creates a provider transport over global fetch. Non-success bodies are
54
+ * cancelled unread, while success bodies are retained only up to 64 KiB.
55
+ */
56
+ export function http(options: HttpOptions): Transport {
57
+ const transport: Transport = async mail => {
58
+ let response: Response
59
+
60
+ try {
61
+ const configured = typeof options.headers === 'function'
62
+ ? options.headers()
63
+ : options.headers
64
+ const headers = new Headers(configured)
65
+
66
+ if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
67
+ headers.delete('Idempotency-Key')
68
+ if (mail.key !== undefined) headers.set('Idempotency-Key', mail.key)
69
+
70
+ response = await fetch(options.url, {
71
+ method: 'POST',
72
+ headers,
73
+ body: JSON.stringify(options.body(mail)),
74
+ signal: mail.signal,
75
+ })
76
+ } catch (error) {
77
+ throw classify(error)
78
+ }
79
+
80
+ if (!response.ok) {
81
+ await response.body?.cancel().catch(() => {})
82
+ throw classify({ status: response.status })
83
+ }
84
+
85
+ try {
86
+ const result = await payload(response)
87
+ const id = options.id?.(result)
88
+
89
+ return id === undefined ? undefined : { id } satisfies Receipt
90
+ } catch (error) {
91
+ throw classify(error)
92
+ }
93
+ }
94
+
95
+ return Object.assign(transport, {
96
+ label: options.label ?? 'http',
97
+ })
98
+ }
package/src/index.ts ADDED
@@ -0,0 +1,384 @@
1
+ import { configure as seam } from 'ajo-kit/mail'
2
+ import {
3
+ Refused,
4
+ Undelivered,
5
+ classify,
6
+ type DeliveryCode,
7
+ type RefusalCode,
8
+ } from './errors'
9
+ import {
10
+ seal,
11
+ domain,
12
+ type Message,
13
+ type Policy,
14
+ type Sealed,
15
+ } from './seal'
16
+
17
+ export { seal, domain, encode } from './seal'
18
+ export type { Address, Recipient, Message, Policy, Sealed, Envelope } from './seal'
19
+ export { Refused, Undelivered, classify } from './errors'
20
+ export type { RefusalCode, DeliveryCode } from './errors'
21
+ export { adapter } from './adapter'
22
+
23
+ /** What a transport returns on success. */
24
+ export interface Receipt {
25
+ /** Provider-assigned id, when the provider assigns one. */
26
+ readonly id?: string
27
+ }
28
+
29
+ /** A delivery mechanism. Receives Sealed and nothing else. */
30
+ export interface Transport {
31
+ (mail: Sealed): Promise<Receipt | void>
32
+ /** Name reported in delivery events. */
33
+ readonly label?: string
34
+ /** True for test and development transports. configure() refuses them in production. */
35
+ readonly dev?: boolean
36
+ /** Optional credential and connectivity check, used by probe(). */
37
+ verify?(signal: AbortSignal): Promise<void>
38
+ }
39
+
40
+ /** Result of deliver(). Never a bare void. */
41
+ export type Outcome =
42
+ | {
43
+ readonly ok: true
44
+ readonly id: string
45
+ readonly transport: string
46
+ }
47
+ | {
48
+ readonly ok: false
49
+ readonly kind: 'refused'
50
+ readonly code: RefusalCode
51
+ readonly error: Refused
52
+ }
53
+ | {
54
+ readonly ok: false
55
+ readonly kind: 'undelivered'
56
+ readonly code: DeliveryCode
57
+ readonly retryable: boolean
58
+ readonly error: Undelivered
59
+ }
60
+
61
+ /** Body-free delivery event. No field of this type can hold a credential. */
62
+ export interface Delivery {
63
+ readonly id: string
64
+ readonly kind: string
65
+ readonly transport: string
66
+ readonly outcome: 'sent' | 'refused' | 'undelivered'
67
+ readonly code?: RefusalCode | DeliveryCode
68
+ readonly retryable?: boolean
69
+ /** Recipient domain only, or undefined when the message never validated. */
70
+ readonly domain?: string
71
+ readonly ms: number
72
+ }
73
+
74
+ /** Process-wide mail policy, transport and observability settings. */
75
+ export interface Options extends Policy {
76
+ transport: Transport
77
+ /** Overrides transport.label in delivery events. */
78
+ label?: string
79
+ /** Maximum concurrent in-flight sends. Default 4. Backpressure, not throughput. */
80
+ concurrency?: number
81
+ /** Synchronous, non-throwing observer. Never receives a body. */
82
+ observe?: (delivery: Delivery) => void
83
+ }
84
+
85
+ type Gate = ReturnType<typeof admit>
86
+
87
+ type Configuration = {
88
+ readonly transport: Transport
89
+ readonly label: string
90
+ readonly policy: Policy
91
+ readonly gate: Gate
92
+ readonly observe?: (delivery: Delivery) => void
93
+ }
94
+
95
+ const CONCURRENCY = 4
96
+ const TIMEOUT = 10_000
97
+ const MAXIMUM_TIMER = 2_147_483_647
98
+ const CONTROL = /[\u0000-\u001f\u007f]/
99
+
100
+ let configuration: Configuration | undefined
101
+
102
+ const production = () => process.env.NODE_ENV === 'production'
103
+
104
+ const refuse = (code: RefusalCode): never => {
105
+ throw new Refused(code)
106
+ }
107
+
108
+ const duration = (value: number | undefined) => {
109
+ const timeout = value ?? TIMEOUT
110
+ if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > MAXIMUM_TIMER) {
111
+ refuse('invalid-config')
112
+ }
113
+ return timeout
114
+ }
115
+
116
+ const concurrency = (value: number | undefined) => {
117
+ const limit = value ?? CONCURRENCY
118
+ if (!Number.isSafeInteger(limit) || limit <= 0) refuse('invalid-config')
119
+ return limit
120
+ }
121
+
122
+ const label = (value: unknown): string => {
123
+ if (typeof value !== 'string') return refuse('invalid-config')
124
+ return value
125
+ }
126
+
127
+ /**
128
+ * Bounded concurrency with no resident timer: a setTimeout exists only while a
129
+ * send is genuinely waiting and is cleared on both paths. The slot is transferred
130
+ * on release rather than decremented, so the limit cannot be overshot by callers
131
+ * arriving in the same tick.
132
+ */
133
+ const admit = (limit: number) => {
134
+
135
+ let active = 0
136
+
137
+ const waiting: (() => void)[] = []
138
+
139
+ return {
140
+ acquire: (deadline: number) => new Promise<void>((resolve, reject) => {
141
+
142
+ if (active < limit) {
143
+ active++
144
+ resolve()
145
+ return
146
+ }
147
+
148
+ const timer = setTimeout(() => {
149
+ const index = waiting.indexOf(entry)
150
+ if (index >= 0) waiting.splice(index, 1)
151
+ reject(new Undelivered('busy', true))
152
+ }, Math.max(0, deadline - Date.now()))
153
+
154
+ const entry = () => {
155
+ clearTimeout(timer)
156
+ active++
157
+ resolve()
158
+ }
159
+
160
+ waiting.push(entry)
161
+ }),
162
+ release: () => {
163
+ active--
164
+ waiting.shift()?.()
165
+ },
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Races the transport against the envelope signal. The race rejects the caller.
171
+ * it cannot reclaim a socket a third-party transport leaked, which is why both
172
+ * shipped transports honour the signal and close in finally.
173
+ */
174
+ const bounded = <T>(work: Promise<T>, signal: AbortSignal) => new Promise<T>((resolve, reject) => {
175
+
176
+ const stop = () => reject(new Undelivered('timeout', true))
177
+
178
+ if (signal.aborted) return stop()
179
+
180
+ signal.addEventListener('abort', stop, { once: true })
181
+
182
+ work.then(resolve, reject).finally(() => signal.removeEventListener('abort', stop))
183
+ })
184
+
185
+ const sanitized = (error: unknown) => {
186
+ try {
187
+ return classify(error)
188
+ } catch {
189
+ return new Undelivered('unknown', false)
190
+ }
191
+ }
192
+
193
+ const elapsed = (started: number) => Math.max(0, Date.now() - started)
194
+
195
+ const receipt = (value: Receipt | void, fallback: string) => {
196
+ try {
197
+ const id = value?.id
198
+ return typeof id === 'string' && id && !CONTROL.test(id) ? id : fallback
199
+ } catch {
200
+ return fallback
201
+ }
202
+ }
203
+
204
+ const report = (
205
+ current: Configuration,
206
+ mail: Sealed | undefined,
207
+ outcome: Outcome,
208
+ started: number,
209
+ ) => {
210
+ if (!current.observe) return
211
+
212
+ const event: Delivery = {
213
+ id: outcome.ok ? outcome.id : mail?.id ?? 'unknown',
214
+ kind: mail?.kind ?? 'mail',
215
+ transport: current.label,
216
+ outcome: outcome.ok ? 'sent' : outcome.kind,
217
+ ...(!outcome.ok && { code: outcome.code }),
218
+ ...(!outcome.ok && outcome.kind === 'undelivered' && { retryable: outcome.retryable }),
219
+ domain: mail ? domain(mail.to.address) : undefined,
220
+ ms: elapsed(started),
221
+ }
222
+
223
+ try {
224
+ current.observe(event)
225
+ } catch {
226
+ // Observability must never change delivery semantics.
227
+ }
228
+ }
229
+
230
+ const refused = (error: Refused): Outcome => ({
231
+ ok: false,
232
+ kind: 'refused',
233
+ code: error.code,
234
+ error,
235
+ })
236
+
237
+ const undelivered = (error: unknown): Outcome => {
238
+ const failure = sanitized(error)
239
+ return {
240
+ ok: false,
241
+ kind: 'undelivered',
242
+ code: failure.code,
243
+ retryable: failure.retryable,
244
+ error: failure,
245
+ }
246
+ }
247
+
248
+ const execute = async (message: Message): Promise<Outcome> => {
249
+ const started = Date.now()
250
+ const current = configuration
251
+
252
+ if (!current) return refused(new Refused('no-transport'))
253
+
254
+ let mail: Sealed
255
+
256
+ try {
257
+ mail = seal(message, current.policy)
258
+ } catch (error) {
259
+ const outcome = refused(error instanceof Refused ? error : new Refused('invalid-config'))
260
+ report(current, undefined, outcome, started)
261
+ return outcome
262
+ }
263
+
264
+ try {
265
+ await current.gate.acquire(mail.deadline)
266
+ } catch (error) {
267
+ const outcome = undelivered(error)
268
+ report(current, mail, outcome, started)
269
+ return outcome
270
+ }
271
+
272
+ let outcome: Outcome
273
+
274
+ try {
275
+ if (mail.signal.aborted || mail.deadline <= Date.now()) {
276
+ throw new Undelivered('timeout', true)
277
+ }
278
+
279
+ const transport = current.transport
280
+ const result = await bounded(
281
+ Promise.resolve().then(() => transport(mail)),
282
+ mail.signal,
283
+ )
284
+ outcome = {
285
+ ok: true,
286
+ id: receipt(result, mail.id),
287
+ transport: current.label,
288
+ }
289
+ } catch (error) {
290
+ outcome = undelivered(error)
291
+ } finally {
292
+ current.gate.release()
293
+ }
294
+
295
+ report(current, mail, outcome, started)
296
+ return outcome
297
+ }
298
+
299
+ /**
300
+ * Installs the transport process-wide and adapts it into ajo-kit's mail seam, so
301
+ * existing send() call sites gain validation and a deadline without being edited.
302
+ * Pure assignment: no socket, no pool, no timer, safe to re-run on every dev reload.
303
+ */
304
+ export function configure(options: Options): void {
305
+ try {
306
+ if (!options || typeof options !== 'object' || typeof options.transport !== 'function') {
307
+ refuse('invalid-config')
308
+ }
309
+ if (options.transport.dev === true && production()) refuse('invalid-config')
310
+ if (options.transport.verify !== undefined && typeof options.transport.verify !== 'function') {
311
+ refuse('invalid-config')
312
+ }
313
+ if (options.observe !== undefined && typeof options.observe !== 'function') {
314
+ refuse('invalid-config')
315
+ }
316
+
317
+ const transport = options.transport
318
+ const current: Configuration = {
319
+ transport,
320
+ label: label(options.label ?? transport.label ?? 'mail'),
321
+ policy: {
322
+ from: options.from,
323
+ ...(options.replyTo !== undefined && { replyTo: options.replyTo }),
324
+ ...(options.timeout !== undefined && { timeout: options.timeout }),
325
+ ...(options.limit !== undefined && { limit: options.limit }),
326
+ },
327
+ gate: admit(concurrency(options.concurrency)),
328
+ ...(options.observe && { observe: options.observe }),
329
+ }
330
+
331
+ configuration = current
332
+ seam(async mail => { await send(mail) })
333
+ } catch (error) {
334
+ if (error instanceof Refused) throw error
335
+ throw new Refused('invalid-config')
336
+ }
337
+ }
338
+
339
+ /** Validates, delivers once inside the deadline, reports the outcome. Never throws. */
340
+ export async function deliver(message: Message): Promise<Outcome> {
341
+ try {
342
+ return await execute(message)
343
+ } catch (error) {
344
+ return undelivered(error)
345
+ }
346
+ }
347
+
348
+ /** Delivers once and resolves to the message id or throws Refused or Undelivered. */
349
+ export async function send(message: Message): Promise<string> {
350
+ const outcome = await deliver(message)
351
+ if (!outcome.ok) throw outcome.error
352
+ return outcome.id
353
+ }
354
+
355
+ /** Runs the transport's optional credential check. Opens a connection only when called. */
356
+ export async function probe(
357
+ timeout?: number,
358
+ ): Promise<
359
+ { ok: true }
360
+ | {
361
+ ok: false
362
+ error: Refused | Undelivered
363
+ }
364
+ > {
365
+ try {
366
+ const current = configuration
367
+ if (!current) throw new Refused('no-transport')
368
+
369
+ const verify = current.transport.verify
370
+ if (!verify) return { ok: true }
371
+
372
+ const signal = AbortSignal.timeout(duration(timeout ?? current.policy.timeout))
373
+ await bounded(
374
+ Promise.resolve().then(() => verify.call(current.transport, signal)),
375
+ signal,
376
+ )
377
+ return { ok: true }
378
+ } catch (error) {
379
+ return {
380
+ ok: false,
381
+ error: error instanceof Refused ? error : sanitized(error),
382
+ }
383
+ }
384
+ }