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/seal.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
import { Refused, type RefusalCode } from './errors'
|
|
4
|
+
|
|
5
|
+
/** One mailbox: a bare address, optionally with a display name. */
|
|
6
|
+
export interface Address {
|
|
7
|
+
readonly address: string
|
|
8
|
+
readonly name?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A mailbox written as a bare address or as a named pair. */
|
|
12
|
+
export type Recipient = string | Address
|
|
13
|
+
|
|
14
|
+
/** What an application asks the package to deliver. */
|
|
15
|
+
export interface Message {
|
|
16
|
+
/** Exactly one recipient. Credential mail must not fan out. */
|
|
17
|
+
to: Recipient
|
|
18
|
+
subject: string
|
|
19
|
+
text: string
|
|
20
|
+
html?: string
|
|
21
|
+
/** Overrides the configured sender for this message only. */
|
|
22
|
+
from?: Recipient
|
|
23
|
+
replyTo?: Recipient
|
|
24
|
+
/** Short label for logs and events: 'reset', 'verify', 'invite'. Default 'mail'. */
|
|
25
|
+
kind?: string
|
|
26
|
+
/** Idempotency key forwarded to providers that accept one. Never deduplicated locally. */
|
|
27
|
+
key?: string
|
|
28
|
+
/** Hard deadline. Nothing is ever attempted past it: pass the credential's own expiry. */
|
|
29
|
+
expires?: Date | number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
declare const sealed: unique symbol
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validated, frozen message. Only seal() constructs one, so a Transport is
|
|
36
|
+
* structurally incapable of receiving unvalidated input.
|
|
37
|
+
*/
|
|
38
|
+
export interface Envelope {
|
|
39
|
+
readonly id: string
|
|
40
|
+
readonly kind: string
|
|
41
|
+
readonly from: Address
|
|
42
|
+
readonly to: Address
|
|
43
|
+
readonly replyTo?: Address
|
|
44
|
+
readonly subject: string
|
|
45
|
+
readonly text: string
|
|
46
|
+
readonly html?: string
|
|
47
|
+
readonly key?: string
|
|
48
|
+
/** Absolute epoch-ms deadline for this attempt. */
|
|
49
|
+
readonly deadline: number
|
|
50
|
+
/** Aborts at deadline. Transports must honour it. */
|
|
51
|
+
readonly signal: AbortSignal
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** An envelope that crossed the package's sole validation boundary. */
|
|
55
|
+
export type Sealed = Readonly<Envelope> & { readonly [sealed]: 'ajo-kit-mail' }
|
|
56
|
+
|
|
57
|
+
/** Validation limits and sender identity. Pure data; a test builds one inline. */
|
|
58
|
+
export interface Policy {
|
|
59
|
+
from: Recipient
|
|
60
|
+
replyTo?: Recipient
|
|
61
|
+
/** Milliseconds for one attempt. Default 10_000. */
|
|
62
|
+
timeout?: number
|
|
63
|
+
/** Maximum text + html bytes. Default and hard maximum 262_144. */
|
|
64
|
+
limit?: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// C0 + DEL, written with escapes on purpose: a literal control byte is invisible
|
|
68
|
+
// in a diff and this is the regex that stops header injection.
|
|
69
|
+
const CONTROL = /[\u0000-\u001f\u007f]/
|
|
70
|
+
const ADDRESS = /^(?=.{3,254}$)[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i
|
|
71
|
+
const NAME = /^[^"<>,;:\\\u0000-\u001f\u007f]{1,128}$/
|
|
72
|
+
const KEY = /^[A-Za-z0-9._:-]{1,128}$/
|
|
73
|
+
const KIND = /^[a-z][a-z0-9-]{0,31}$/
|
|
74
|
+
const ADDRESS_BYTES = 254
|
|
75
|
+
const SUBJECT_BYTES = 255
|
|
76
|
+
const NAME_BYTES = 128
|
|
77
|
+
const KEY_BYTES = 128
|
|
78
|
+
const KIND_BYTES = 32
|
|
79
|
+
const BODY_BYTES = 262_144
|
|
80
|
+
const TIMEOUT = 10_000
|
|
81
|
+
const WORD_BYTES = 45
|
|
82
|
+
|
|
83
|
+
const bytes = (value: string) => Buffer.byteLength(value, 'utf8')
|
|
84
|
+
const refusal = (code: RefusalCode): never => {
|
|
85
|
+
throw new Refused(code)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const mailbox = (recipient: Recipient, code: 'invalid-sender' | 'invalid-recipient'): Address => {
|
|
89
|
+
const address = typeof recipient === 'string' ? recipient : recipient?.address
|
|
90
|
+
const name = typeof recipient === 'string' ? undefined : recipient?.name
|
|
91
|
+
|
|
92
|
+
if (typeof address !== 'string') refusal(code)
|
|
93
|
+
if (CONTROL.test(address) || !ADDRESS.test(address) || bytes(address) > ADDRESS_BYTES) {
|
|
94
|
+
refusal(code)
|
|
95
|
+
}
|
|
96
|
+
if (name !== undefined) {
|
|
97
|
+
if (
|
|
98
|
+
typeof name !== 'string'
|
|
99
|
+
|| CONTROL.test(name)
|
|
100
|
+
|| !NAME.test(name)
|
|
101
|
+
|| bytes(name) > NAME_BYTES
|
|
102
|
+
) {
|
|
103
|
+
refusal('invalid-name')
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return Object.freeze({
|
|
108
|
+
address,
|
|
109
|
+
...(name !== undefined && { name }),
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const duration = (value: number | undefined) => {
|
|
114
|
+
const timeout = value ?? TIMEOUT
|
|
115
|
+
if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 2_147_483_647) {
|
|
116
|
+
refusal('invalid-config')
|
|
117
|
+
}
|
|
118
|
+
return timeout
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const bodyLimit = (value: number | undefined) => {
|
|
122
|
+
const limit = value ?? BODY_BYTES
|
|
123
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > BODY_BYTES) refusal('invalid-config')
|
|
124
|
+
return limit
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const expiry = (value: Date | number | undefined) => {
|
|
128
|
+
if (value === undefined) return Infinity
|
|
129
|
+
const time = value instanceof Date ? value.getTime() : value
|
|
130
|
+
return typeof time === 'number' && Number.isFinite(time) ? time : refusal('expired')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Validates once, on the way in. Every later stage consumes only the result. */
|
|
134
|
+
export function seal(message: Message, policy: Policy): Sealed {
|
|
135
|
+
if (!message || typeof message !== 'object' || !policy || typeof policy !== 'object') {
|
|
136
|
+
refusal('invalid-config')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const toRecipient = message.to
|
|
140
|
+
const subject = message.subject
|
|
141
|
+
const text = message.text
|
|
142
|
+
const html = message.html
|
|
143
|
+
const fromRecipient = message.from
|
|
144
|
+
const messageReply = message.replyTo
|
|
145
|
+
const rawKind = message.kind
|
|
146
|
+
const key = message.key
|
|
147
|
+
const expires = message.expires
|
|
148
|
+
const policyFrom = policy.from
|
|
149
|
+
const policyReply = policy.replyTo
|
|
150
|
+
const policyTimeout = policy.timeout
|
|
151
|
+
const policyLimit = policy.limit
|
|
152
|
+
const now = Date.now()
|
|
153
|
+
const deadline = Math.min(now + duration(policyTimeout), expiry(expires))
|
|
154
|
+
const limit = bodyLimit(policyLimit)
|
|
155
|
+
const from = mailbox(fromRecipient ?? policyFrom, 'invalid-sender')
|
|
156
|
+
const to = mailbox(toRecipient, 'invalid-recipient')
|
|
157
|
+
const replyRecipient = messageReply ?? policyReply
|
|
158
|
+
const replyTo = replyRecipient === undefined
|
|
159
|
+
? undefined
|
|
160
|
+
: mailbox(replyRecipient, 'invalid-recipient')
|
|
161
|
+
const kind = rawKind ?? 'mail'
|
|
162
|
+
|
|
163
|
+
if (
|
|
164
|
+
typeof subject !== 'string'
|
|
165
|
+
|| !subject
|
|
166
|
+
|| CONTROL.test(subject)
|
|
167
|
+
|| bytes(subject) > SUBJECT_BYTES
|
|
168
|
+
) {
|
|
169
|
+
refusal('invalid-subject')
|
|
170
|
+
}
|
|
171
|
+
if (
|
|
172
|
+
typeof kind !== 'string'
|
|
173
|
+
|| CONTROL.test(kind)
|
|
174
|
+
|| !KIND.test(kind)
|
|
175
|
+
|| bytes(kind) > KIND_BYTES
|
|
176
|
+
) {
|
|
177
|
+
refusal('invalid-kind')
|
|
178
|
+
}
|
|
179
|
+
if (
|
|
180
|
+
key !== undefined
|
|
181
|
+
&& (
|
|
182
|
+
typeof key !== 'string'
|
|
183
|
+
|| CONTROL.test(key)
|
|
184
|
+
|| !KEY.test(key)
|
|
185
|
+
|| bytes(key) > KEY_BYTES
|
|
186
|
+
)
|
|
187
|
+
) {
|
|
188
|
+
refusal('invalid-key')
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
typeof text !== 'string'
|
|
192
|
+
|| (html !== undefined && typeof html !== 'string')
|
|
193
|
+
|| (!text && !html)
|
|
194
|
+
) {
|
|
195
|
+
refusal('empty-body')
|
|
196
|
+
}
|
|
197
|
+
if (bytes(text) + bytes(html ?? '') > limit) refusal('too-large')
|
|
198
|
+
if (deadline <= Date.now()) refusal('expired')
|
|
199
|
+
|
|
200
|
+
const signal = AbortSignal.timeout(Math.max(0, Math.ceil(deadline - Date.now())))
|
|
201
|
+
return Object.freeze({
|
|
202
|
+
id: randomUUID(),
|
|
203
|
+
kind,
|
|
204
|
+
from,
|
|
205
|
+
to,
|
|
206
|
+
...(replyTo && { replyTo }),
|
|
207
|
+
subject,
|
|
208
|
+
text,
|
|
209
|
+
...(html !== undefined && { html }),
|
|
210
|
+
...(key !== undefined && { key }),
|
|
211
|
+
deadline,
|
|
212
|
+
signal,
|
|
213
|
+
}) as Sealed
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Returns the recipient domain, the only address part safe for logs. */
|
|
217
|
+
export function domain(address: string): string {
|
|
218
|
+
return address.slice(address.lastIndexOf('@') + 1).toLowerCase()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Encodes and folds a value into RFC 2047 encoded-words of at most 75 characters. */
|
|
222
|
+
export function encode(value: string): string {
|
|
223
|
+
const chunks: string[] = []
|
|
224
|
+
let chunk = ''
|
|
225
|
+
let size = 0
|
|
226
|
+
|
|
227
|
+
for (const character of value) {
|
|
228
|
+
const width = bytes(character)
|
|
229
|
+
if (size + width > WORD_BYTES && chunk) {
|
|
230
|
+
chunks.push(chunk)
|
|
231
|
+
chunk = ''
|
|
232
|
+
size = 0
|
|
233
|
+
}
|
|
234
|
+
chunk += character
|
|
235
|
+
size += width
|
|
236
|
+
}
|
|
237
|
+
chunks.push(chunk)
|
|
238
|
+
|
|
239
|
+
return chunks
|
|
240
|
+
.map(part => `=?UTF-8?B?${Buffer.from(part, 'utf8').toString('base64')}?=`)
|
|
241
|
+
.join('\r\n ')
|
|
242
|
+
}
|
package/src/smtp.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { isIP, Socket } from 'node:net'
|
|
2
|
+
import { connect as connectTls } from 'node:tls'
|
|
3
|
+
import { createTransport } from 'nodemailer'
|
|
4
|
+
import { Refused, Undelivered } from './errors'
|
|
5
|
+
import type { Receipt, Transport } from './index'
|
|
6
|
+
import type { Sealed } from './seal'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Discrete credential fields on purpose. There is no `url` option: a
|
|
10
|
+
* smtp://user:pass@host string leaks into config dumps, error text and issue
|
|
11
|
+
* trackers far more casually than a `pass` field does.
|
|
12
|
+
*
|
|
13
|
+
* There is no `insecure` flag, no `requireTls: false`, no NODE_TLS_REJECT_UNAUTHORIZED
|
|
14
|
+
* accommodation. STARTTLS is required, the certificate is verified, the floor is
|
|
15
|
+
* TLS 1.2, and none of that is configurable. Local development uses capture().
|
|
16
|
+
*/
|
|
17
|
+
export interface SmtpOptions {
|
|
18
|
+
host: string
|
|
19
|
+
/** Default 587. Use 465 with implicit: true. */
|
|
20
|
+
port?: number
|
|
21
|
+
/** Implicit TLS from the first byte (465). Default false: plaintext connect + required STARTTLS. */
|
|
22
|
+
implicit?: boolean
|
|
23
|
+
user?: string
|
|
24
|
+
pass?: string
|
|
25
|
+
/** EHLO name. Defaults to the sender's domain, so the host name never leaves the box. */
|
|
26
|
+
name?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Configuration {
|
|
30
|
+
readonly host: string
|
|
31
|
+
readonly port: number
|
|
32
|
+
readonly implicit: boolean
|
|
33
|
+
readonly user?: string
|
|
34
|
+
readonly pass?: string
|
|
35
|
+
readonly name?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const CONTROL = /[\u0000-\u001f\u007f]/
|
|
39
|
+
const VERIFY_TIMEOUT = 10_000
|
|
40
|
+
|
|
41
|
+
type SocketOptions = {
|
|
42
|
+
readonly connection: Socket
|
|
43
|
+
readonly secured?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type SocketCallback = (error: Error | null, options?: SocketOptions) => void
|
|
47
|
+
|
|
48
|
+
const configuration = (options: SmtpOptions): Configuration => {
|
|
49
|
+
if (!options || typeof options !== 'object') throw new Refused('invalid-config')
|
|
50
|
+
|
|
51
|
+
const host = options.host
|
|
52
|
+
const port = options.port ?? 587
|
|
53
|
+
const implicit = options.implicit ?? false
|
|
54
|
+
const user = options.user
|
|
55
|
+
const pass = options.pass
|
|
56
|
+
const name = options.name
|
|
57
|
+
|
|
58
|
+
if (
|
|
59
|
+
typeof host !== 'string'
|
|
60
|
+
|| !host
|
|
61
|
+
|| host !== host.trim()
|
|
62
|
+
|| host.length > 253
|
|
63
|
+
|| CONTROL.test(host)
|
|
64
|
+
|| /\s/.test(host)
|
|
65
|
+
|| !Number.isInteger(port)
|
|
66
|
+
|| port < 1
|
|
67
|
+
|| port > 65_535
|
|
68
|
+
|| typeof implicit !== 'boolean'
|
|
69
|
+
|| (user !== undefined && (typeof user !== 'string' || !user))
|
|
70
|
+
|| (pass !== undefined && (typeof pass !== 'string' || !pass))
|
|
71
|
+
|| (user === undefined) !== (pass === undefined)
|
|
72
|
+
|| (
|
|
73
|
+
name !== undefined
|
|
74
|
+
&& (
|
|
75
|
+
typeof name !== 'string'
|
|
76
|
+
|| !name
|
|
77
|
+
|| name !== name.trim()
|
|
78
|
+
|| name.length > 253
|
|
79
|
+
|| CONTROL.test(name)
|
|
80
|
+
|| /\s/.test(name)
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
) {
|
|
84
|
+
throw new Refused('invalid-config')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
host,
|
|
89
|
+
port,
|
|
90
|
+
implicit,
|
|
91
|
+
...(user !== undefined && { user, pass }),
|
|
92
|
+
...(name !== undefined && { name }),
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const connector = (
|
|
97
|
+
config: Configuration,
|
|
98
|
+
own: (socket: Socket) => void,
|
|
99
|
+
) =>
|
|
100
|
+
(_options: unknown, callback: SocketCallback) => {
|
|
101
|
+
let settled = false
|
|
102
|
+
let socket: Socket
|
|
103
|
+
const finish = (error: Error | null, options?: SocketOptions) => {
|
|
104
|
+
if (settled) return
|
|
105
|
+
settled = true
|
|
106
|
+
callback(error, options)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (config.implicit) {
|
|
110
|
+
socket = connectTls({
|
|
111
|
+
host: config.host,
|
|
112
|
+
port: config.port,
|
|
113
|
+
...(isIP(config.host) === 0 && { servername: config.host }),
|
|
114
|
+
rejectUnauthorized: true,
|
|
115
|
+
minVersion: 'TLSv1.2',
|
|
116
|
+
}, () => finish(null, {
|
|
117
|
+
connection: socket,
|
|
118
|
+
secured: true,
|
|
119
|
+
}))
|
|
120
|
+
} else {
|
|
121
|
+
socket = new Socket()
|
|
122
|
+
socket.connect(config.port, config.host, () => finish(null, {
|
|
123
|
+
connection: socket,
|
|
124
|
+
}))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
own(socket)
|
|
128
|
+
socket.on('error', () => {
|
|
129
|
+
if (!settled) {
|
|
130
|
+
finish(Object.assign(new Error('SMTP connection failed'), {
|
|
131
|
+
code: config.implicit ? 'ETLS' : 'ESOCKET',
|
|
132
|
+
}))
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const settings = (
|
|
138
|
+
config: Configuration,
|
|
139
|
+
name: string,
|
|
140
|
+
timeout: number,
|
|
141
|
+
own: (socket: Socket) => void,
|
|
142
|
+
) => ({
|
|
143
|
+
host: config.host,
|
|
144
|
+
port: config.port,
|
|
145
|
+
secure: config.implicit,
|
|
146
|
+
pool: false,
|
|
147
|
+
requireTLS: !config.implicit,
|
|
148
|
+
ignoreTLS: false,
|
|
149
|
+
opportunisticTLS: false,
|
|
150
|
+
name,
|
|
151
|
+
...(config.user !== undefined && {
|
|
152
|
+
auth: {
|
|
153
|
+
user: config.user,
|
|
154
|
+
pass: config.pass!,
|
|
155
|
+
},
|
|
156
|
+
}),
|
|
157
|
+
connectionTimeout: timeout,
|
|
158
|
+
greetingTimeout: timeout,
|
|
159
|
+
socketTimeout: timeout,
|
|
160
|
+
dnsTimeout: timeout,
|
|
161
|
+
tls: {
|
|
162
|
+
rejectUnauthorized: true,
|
|
163
|
+
minVersion: 'TLSv1.2' as const,
|
|
164
|
+
},
|
|
165
|
+
disableFileAccess: true,
|
|
166
|
+
disableUrlAccess: true,
|
|
167
|
+
logger: false,
|
|
168
|
+
debug: false,
|
|
169
|
+
transactionLog: false,
|
|
170
|
+
getSocket: connector(config, own),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const remaining = (deadline: number) => {
|
|
174
|
+
const value = Math.ceil(deadline - Date.now())
|
|
175
|
+
return value > 0 ? Math.min(value, 2_147_483_647) : 0
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const bounded = <T>(work: Promise<T>, signal: AbortSignal, close: () => void) =>
|
|
179
|
+
new Promise<T>((resolve, reject) => {
|
|
180
|
+
const stop = () => {
|
|
181
|
+
close()
|
|
182
|
+
reject(new Undelivered('timeout', true))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (signal.aborted) return stop()
|
|
186
|
+
|
|
187
|
+
signal.addEventListener('abort', stop, { once: true })
|
|
188
|
+
work.then(
|
|
189
|
+
value => {
|
|
190
|
+
signal.removeEventListener('abort', stop)
|
|
191
|
+
resolve(value)
|
|
192
|
+
},
|
|
193
|
+
error => {
|
|
194
|
+
signal.removeEventListener('abort', stop)
|
|
195
|
+
reject(error)
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
/** SMTP reply-code mapping. Note the parentheses: this is where a precedence bug hides. */
|
|
201
|
+
const reply = (error: unknown): Undelivered => {
|
|
202
|
+
const value = (error ?? {}) as {
|
|
203
|
+
code?: unknown
|
|
204
|
+
command?: unknown
|
|
205
|
+
responseCode?: unknown
|
|
206
|
+
}
|
|
207
|
+
const code = typeof value.code === 'string' ? value.code : ''
|
|
208
|
+
const command = typeof value.command === 'string' ? value.command : ''
|
|
209
|
+
const status = typeof value.responseCode === 'number' ? value.responseCode : undefined
|
|
210
|
+
const hint = status === undefined ? undefined : `smtp ${status}`
|
|
211
|
+
|
|
212
|
+
if (code === 'EAUTH' || status === 530 || status === 535) return new Undelivered('auth', false, hint)
|
|
213
|
+
if (code === 'ETLS' || code.startsWith('ERR_TLS') || code.includes('CERT')) {
|
|
214
|
+
return new Undelivered('tls', false)
|
|
215
|
+
}
|
|
216
|
+
if (code === 'ESOCKET' && command === 'CONN') return new Undelivered('tls', false)
|
|
217
|
+
if (code === 'ETIMEDOUT' || (code === 'ECONNECTION' && status === undefined)) {
|
|
218
|
+
return new Undelivered('timeout', true)
|
|
219
|
+
}
|
|
220
|
+
if (status !== undefined && status >= 400 && status < 500) {
|
|
221
|
+
return new Undelivered('throttled', true, hint)
|
|
222
|
+
}
|
|
223
|
+
if (status !== undefined && status >= 500) return new Undelivered('rejected', false, hint)
|
|
224
|
+
if (code) return new Undelivered('connection', true)
|
|
225
|
+
|
|
226
|
+
return new Undelivered('unknown', false)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Creates a one-connection-per-message SMTP transport with mandatory verified
|
|
231
|
+
* TLS, deadline-derived socket timeouts and sanitized failure classifications.
|
|
232
|
+
*/
|
|
233
|
+
export function smtp(options: SmtpOptions): Transport {
|
|
234
|
+
const config = configuration(options)
|
|
235
|
+
|
|
236
|
+
const send = async (mail: Sealed): Promise<Receipt | void> => {
|
|
237
|
+
const timeout = remaining(mail.deadline)
|
|
238
|
+
if (!timeout) throw new Undelivered('timeout', true)
|
|
239
|
+
|
|
240
|
+
let mailer: ReturnType<typeof createTransport> | undefined
|
|
241
|
+
let socket: Socket | undefined
|
|
242
|
+
let closed = false
|
|
243
|
+
const own = (value: Socket) => {
|
|
244
|
+
socket = value
|
|
245
|
+
if (closed) value.destroy()
|
|
246
|
+
}
|
|
247
|
+
const close = () => {
|
|
248
|
+
if (closed) return
|
|
249
|
+
closed = true
|
|
250
|
+
socket?.destroy()
|
|
251
|
+
mailer?.close()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const name = config.name ?? mail.from.address.slice(mail.from.address.lastIndexOf('@') + 1)
|
|
256
|
+
mailer = createTransport(settings(config, name, timeout, own))
|
|
257
|
+
const result = await bounded<{ messageId?: unknown }>(mailer.sendMail({
|
|
258
|
+
from: mail.from,
|
|
259
|
+
to: mail.to,
|
|
260
|
+
...(mail.replyTo && { replyTo: mail.replyTo }),
|
|
261
|
+
subject: mail.subject,
|
|
262
|
+
text: mail.text,
|
|
263
|
+
...(mail.html !== undefined && { html: mail.html }),
|
|
264
|
+
envelope: {
|
|
265
|
+
from: mail.from.address,
|
|
266
|
+
to: [mail.to.address],
|
|
267
|
+
},
|
|
268
|
+
disableFileAccess: true,
|
|
269
|
+
disableUrlAccess: true,
|
|
270
|
+
}) as Promise<{ messageId?: unknown }>, mail.signal, close)
|
|
271
|
+
|
|
272
|
+
return typeof result.messageId === 'string' && result.messageId
|
|
273
|
+
? { id: result.messageId }
|
|
274
|
+
: undefined
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (error instanceof Undelivered) throw error
|
|
277
|
+
throw reply(error)
|
|
278
|
+
} finally {
|
|
279
|
+
close()
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const verify = async (signal: AbortSignal): Promise<void> => {
|
|
284
|
+
if (signal.aborted) throw new Undelivered('timeout', true)
|
|
285
|
+
|
|
286
|
+
let mailer: ReturnType<typeof createTransport> | undefined
|
|
287
|
+
let socket: Socket | undefined
|
|
288
|
+
let closed = false
|
|
289
|
+
const own = (value: Socket) => {
|
|
290
|
+
socket = value
|
|
291
|
+
if (closed) value.destroy()
|
|
292
|
+
}
|
|
293
|
+
const close = () => {
|
|
294
|
+
if (closed) return
|
|
295
|
+
closed = true
|
|
296
|
+
socket?.destroy()
|
|
297
|
+
mailer?.close()
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
mailer = createTransport(settings(config, config.name ?? config.host, VERIFY_TIMEOUT, own))
|
|
302
|
+
await bounded(mailer.verify(), signal, close)
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (error instanceof Undelivered) throw error
|
|
305
|
+
throw reply(error)
|
|
306
|
+
} finally {
|
|
307
|
+
close()
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return Object.assign(send, {
|
|
312
|
+
label: 'smtp',
|
|
313
|
+
verify,
|
|
314
|
+
})
|
|
315
|
+
}
|