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/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
+ }