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/LICENSE +15 -0
- package/README.md +170 -0
- package/dist/capture.js +82 -0
- package/dist/chunks/errors-CMUvhZvz.js +97 -0
- package/dist/chunks/seal-BORkLlJV.js +117 -0
- package/dist/http.js +65 -0
- package/dist/index.js +236 -0
- package/dist/smtp.js +195 -0
- package/package.json +77 -0
- package/src/adapter.ts +18 -0
- package/src/capture.ts +121 -0
- package/src/errors.ts +164 -0
- package/src/http.ts +98 -0
- package/src/index.ts +384 -0
- package/src/seal.ts +242 -0
- package/src/smtp.ts +315 -0
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
|
+
}
|