@fabrika/email 0.8.1 → 0.8.2
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/package.json +16 -1
- package/src/ahasend.ts +172 -0
- package/src/conformance.ts +54 -0
- package/src/content.ts +3 -1
- package/src/directory.ts +4 -1
- package/src/email.ts +12 -0
- package/src/http-sender.ts +138 -0
- package/src/index.ts +2 -0
- package/src/lettermint.ts +153 -0
- package/src/memory.ts +3 -1
- package/src/platform-wire.ts +6 -0
- package/src/platform.ts +2 -1
- package/src/postmark.ts +136 -0
- package/src/providers.ts +30 -2
- package/src/resend.ts +48 -85
- package/src/smartemailing.ts +30 -74
- package/src/validation.ts +31 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/email",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -25,6 +25,21 @@
|
|
|
25
25
|
"types": "./src/resend.ts",
|
|
26
26
|
"default": "./src/resend.ts"
|
|
27
27
|
},
|
|
28
|
+
"./postmark": {
|
|
29
|
+
"bun": "./src/postmark.ts",
|
|
30
|
+
"types": "./src/postmark.ts",
|
|
31
|
+
"default": "./src/postmark.ts"
|
|
32
|
+
},
|
|
33
|
+
"./ahasend": {
|
|
34
|
+
"bun": "./src/ahasend.ts",
|
|
35
|
+
"types": "./src/ahasend.ts",
|
|
36
|
+
"default": "./src/ahasend.ts"
|
|
37
|
+
},
|
|
38
|
+
"./lettermint": {
|
|
39
|
+
"bun": "./src/lettermint.ts",
|
|
40
|
+
"types": "./src/lettermint.ts",
|
|
41
|
+
"default": "./src/lettermint.ts"
|
|
42
|
+
},
|
|
28
43
|
"./directory": {
|
|
29
44
|
"bun": "./src/directory.ts",
|
|
30
45
|
"types": "./src/directory.ts",
|
package/src/ahasend.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// AhaSend's API v2 (https://ahasend.com/docs/api-reference/messages/create-message), built from its documentation and
|
|
2
|
+
// verified on a live account in October 2026. It keeps an idempotency key for 24 hours, and a 202 carries one result
|
|
3
|
+
// per recipient, which can itself be a refusal. It renders a message's content as a template only when the request
|
|
4
|
+
// carries substitutions, which this adapter never sends, so `{{` in a message reaches the recipient as written.
|
|
5
|
+
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import { type EmailProvider, parseProviderVariables } from './config.js'
|
|
8
|
+
import { base64Of } from './content.js'
|
|
9
|
+
import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities, oneClickUnsubscribeHeaders } from './email.js'
|
|
10
|
+
import {
|
|
11
|
+
credentialVariable,
|
|
12
|
+
type HttpEmailRequest,
|
|
13
|
+
HttpEmailSender,
|
|
14
|
+
type HttpEmailSenderOptions,
|
|
15
|
+
isCredential,
|
|
16
|
+
optionalVariable,
|
|
17
|
+
providerIdempotencyKey,
|
|
18
|
+
} from './http-sender.js'
|
|
19
|
+
import type { ProviderResponse } from './http.js'
|
|
20
|
+
import { isValidEmailAddress } from './validation.js'
|
|
21
|
+
|
|
22
|
+
const AHASEND_API_URL = 'https://api.ahasend.com/v2/accounts'
|
|
23
|
+
const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/
|
|
24
|
+
/** A refusal carries only a free-text `message`, read here to tell a reused key from another 422 and never put into an error. */
|
|
25
|
+
const ahaSendErrorSchema = z.object({ message: z.string() })
|
|
26
|
+
const KEY_REUSED = /idempotency key/i
|
|
27
|
+
const MAX_IDEMPOTENCY_KEY_LENGTH = 255
|
|
28
|
+
const ahaSendResultSchema = z.object({
|
|
29
|
+
// `queued` and `scheduled` are documented; any status but `error` with an id is taken, so a new one is no failure.
|
|
30
|
+
data: z.tuple([z.object({ status: z.string(), id: z.string().nullable() })]),
|
|
31
|
+
})
|
|
32
|
+
/**
|
|
33
|
+
* AhaSend refuses a message over 20 MiB with its attachments base64 and MIME-wrapped; 12 MiB of bytes leaves room for
|
|
34
|
+
* the bodies, and its body stays under the 30 MB request bound.
|
|
35
|
+
*/
|
|
36
|
+
const AHASEND_MAX_ATTACHMENT_BYTES = 12 * 1024 * 1024
|
|
37
|
+
|
|
38
|
+
export interface AhaSendEmailSenderOptions extends HttpEmailSenderOptions {
|
|
39
|
+
/** An API key v2, scoped to `messages:send:{domain}` at least. */
|
|
40
|
+
readonly apiKey: string
|
|
41
|
+
/** The account the key belongs to: the path of every request. */
|
|
42
|
+
readonly accountId: string
|
|
43
|
+
/**
|
|
44
|
+
* The address a `broadcast` message is sent from, on a domain the account has configured as a Marketing domain:
|
|
45
|
+
* AhaSend's terms allow marketing mail through the API from such a domain only. Without it, a broadcast is refused.
|
|
46
|
+
*/
|
|
47
|
+
readonly broadcastFrom?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A refusal's body is one free-text `message`, so only the status is read. */
|
|
51
|
+
export class AhaSendEmailSender extends HttpEmailSender {
|
|
52
|
+
/** `broadcast` only with `broadcastFrom`. */
|
|
53
|
+
readonly capabilities: EmailSenderCapabilities
|
|
54
|
+
readonly #apiKey: string
|
|
55
|
+
readonly #messagesUrl: string
|
|
56
|
+
readonly #broadcastFrom: string | undefined
|
|
57
|
+
|
|
58
|
+
constructor(options: AhaSendEmailSenderOptions) {
|
|
59
|
+
if (!isCredential(options.apiKey)) {
|
|
60
|
+
throw new EmailDeliveryError('AhaSend API key is invalid', { code: 'invalid_configuration' })
|
|
61
|
+
}
|
|
62
|
+
if (!ACCOUNT_ID_PATTERN.test(options.accountId)) {
|
|
63
|
+
throw new EmailDeliveryError('AhaSend account ID is invalid', { code: 'invalid_configuration' })
|
|
64
|
+
}
|
|
65
|
+
if (options.broadcastFrom !== undefined && !isValidEmailAddress(options.broadcastFrom)) {
|
|
66
|
+
throw new EmailDeliveryError('AhaSend broadcast sender is invalid', { code: 'invalid_configuration' })
|
|
67
|
+
}
|
|
68
|
+
super({ label: 'AhaSend', provider: 'ahasend' }, options)
|
|
69
|
+
this.#apiKey = options.apiKey
|
|
70
|
+
this.#messagesUrl = `${AHASEND_API_URL}/${options.accountId}/messages`
|
|
71
|
+
this.#broadcastFrom = options.broadcastFrom
|
|
72
|
+
this.capabilities = {
|
|
73
|
+
idempotent: true,
|
|
74
|
+
confirmation: 'sync',
|
|
75
|
+
maxBatch: 1,
|
|
76
|
+
listUnsubscribe: true,
|
|
77
|
+
maxAttachmentBytes: AHASEND_MAX_ATTACHMENT_BYTES,
|
|
78
|
+
...(options.broadcastFrom === undefined ? {} : { broadcast: true }),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
protected async request(message: EmailMessage): Promise<HttpEmailRequest> {
|
|
83
|
+
const senderName = message.fromName ?? this.from.name
|
|
84
|
+
// `send` refused a broadcast message without `broadcastFrom`; never send one from the transactional domain.
|
|
85
|
+
const fromAddress = message.broadcast === true ? this.#broadcastFrom : this.from.address
|
|
86
|
+
if (fromAddress === undefined) throw new EmailDeliveryError('AhaSend sends no broadcast email', { code: 'invalid_message' })
|
|
87
|
+
const attachments = message.attachments ?? []
|
|
88
|
+
return {
|
|
89
|
+
url: this.#messagesUrl,
|
|
90
|
+
headers: {
|
|
91
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
92
|
+
'Idempotency-Key': await providerIdempotencyKey(message.idempotencyKey, MAX_IDEMPOTENCY_KEY_LENGTH),
|
|
93
|
+
},
|
|
94
|
+
body: {
|
|
95
|
+
from: {
|
|
96
|
+
email: fromAddress,
|
|
97
|
+
...(senderName === undefined ? {} : { name: senderName }),
|
|
98
|
+
},
|
|
99
|
+
recipients: [{ email: message.to }],
|
|
100
|
+
subject: message.subject,
|
|
101
|
+
text_content: message.text,
|
|
102
|
+
...(message.html === undefined ? {} : { html_content: message.html }),
|
|
103
|
+
...(message.replyTo === undefined ? {} : { reply_to: { email: message.replyTo } }),
|
|
104
|
+
...(message.unsubscribeUrl === undefined ? {} : { headers: oneClickUnsubscribeHeaders(message.unsubscribeUrl) }),
|
|
105
|
+
...(attachments.length === 0
|
|
106
|
+
? {}
|
|
107
|
+
: {
|
|
108
|
+
attachments: attachments.map((attachment) => ({
|
|
109
|
+
base64: true,
|
|
110
|
+
data: base64Of(attachment.content),
|
|
111
|
+
content_type: attachment.contentType,
|
|
112
|
+
file_name: attachment.filename,
|
|
113
|
+
})),
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
protected messageId(response: ProviderResponse): string | undefined {
|
|
120
|
+
const result = ahaSendResultSchema.safeParse(response.body).data?.data[0]
|
|
121
|
+
if (result === undefined) return undefined
|
|
122
|
+
// The item's `error` is free text about the recipient, so it stays out of the refusal.
|
|
123
|
+
if (result.status === 'error') {
|
|
124
|
+
throw new EmailDeliveryError(`AhaSend refused the recipient (${response.status})`, { code: 'provider_rejected', httpStatus: response.status })
|
|
125
|
+
}
|
|
126
|
+
return result.id ?? undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
protected override refusal(response: ProviderResponse): EmailDeliveryError | undefined {
|
|
130
|
+
const status = response.status
|
|
131
|
+
// Documented for an unknown account; a key of another account answers 401.
|
|
132
|
+
if (status === 404) {
|
|
133
|
+
return new EmailDeliveryError('AhaSend knows no such account (404)', { code: 'provider_unauthorized', httpStatus: status })
|
|
134
|
+
}
|
|
135
|
+
// Live, a concurrent request under the key waited for the first and replayed its answer; 409 is documented.
|
|
136
|
+
if (status === 409) {
|
|
137
|
+
return new EmailDeliveryError('A matching AhaSend request is still in progress', { code: 'request_in_progress', httpStatus: status })
|
|
138
|
+
}
|
|
139
|
+
// A malformed body is a 400; a 422 names the key reused for another request, and any other is a rejection.
|
|
140
|
+
if (status === 422 && KEY_REUSED.test(ahaSendErrorSchema.safeParse(response.body).data?.message ?? '')) {
|
|
141
|
+
return new EmailDeliveryError('AhaSend refused the idempotency key for this request (422)', {
|
|
142
|
+
code: 'idempotency_conflict',
|
|
143
|
+
httpStatus: status,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ahaSendVariablesSchema = z.object({
|
|
151
|
+
FABRIKA_EMAIL_AHASEND_API_KEY: credentialVariable,
|
|
152
|
+
FABRIKA_EMAIL_AHASEND_ACCOUNT_ID: z.string().regex(ACCOUNT_ID_PATTERN),
|
|
153
|
+
FABRIKA_EMAIL_AHASEND_BROADCAST_FROM: optionalVariable(isValidEmailAddress),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
export const ahaSendEmailProvider: EmailProvider = {
|
|
157
|
+
variables: [
|
|
158
|
+
{ name: 'FABRIKA_EMAIL_AHASEND_API_KEY', secret: true, required: true },
|
|
159
|
+
{ name: 'FABRIKA_EMAIL_AHASEND_ACCOUNT_ID', secret: false, required: true },
|
|
160
|
+
{ name: 'FABRIKA_EMAIL_AHASEND_BROADCAST_FROM', secret: false, required: false },
|
|
161
|
+
],
|
|
162
|
+
create({ from, env, fetch }) {
|
|
163
|
+
const variables = parseProviderVariables(ahaSendVariablesSchema, env)
|
|
164
|
+
return new AhaSendEmailSender({
|
|
165
|
+
apiKey: variables.FABRIKA_EMAIL_AHASEND_API_KEY,
|
|
166
|
+
accountId: variables.FABRIKA_EMAIL_AHASEND_ACCOUNT_ID,
|
|
167
|
+
broadcastFrom: variables.FABRIKA_EMAIL_AHASEND_BROADCAST_FROM,
|
|
168
|
+
from,
|
|
169
|
+
fetch,
|
|
170
|
+
})
|
|
171
|
+
},
|
|
172
|
+
}
|
package/src/conformance.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface WireMessage {
|
|
|
26
26
|
readonly unsubscribeUrl?: string
|
|
27
27
|
/** Each attachment with its bytes as base64. */
|
|
28
28
|
readonly attachments?: readonly EmailAttachmentContent[]
|
|
29
|
+
/** Whether the provider got it as broadcast mail, on its channel for them where it has one. */
|
|
30
|
+
readonly broadcast?: boolean
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/** A stored or sent message in wire terms: what a local harness answers for an `EmailMessage` it kept. */
|
|
@@ -99,6 +101,8 @@ const UNSUBSCRIBE_MESSAGE: EmailMessage = {
|
|
|
99
101
|
unsubscribeUrl: 'https://app.example.test/api/mail.unsubscribe?t=conformance-token-6c2e',
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
const BROADCAST_MESSAGE: EmailMessage = { ...UNSUBSCRIBE_MESSAGE, idempotencyKey: 'conformance/broadcast-1', broadcast: true }
|
|
105
|
+
|
|
102
106
|
const ATTACHMENT_MESSAGE: EmailMessage = {
|
|
103
107
|
...MINIMAL_MESSAGE,
|
|
104
108
|
idempotencyKey: 'conformance/attachment-1',
|
|
@@ -134,6 +138,7 @@ const INVALID_MESSAGES: readonly { readonly problem: string; readonly message: E
|
|
|
134
138
|
{ problem: 'a sender name over 128 characters', message: { ...MINIMAL_MESSAGE, fromName: 'x'.repeat(129) } },
|
|
135
139
|
{ problem: 'an unsubscribe URL over http', message: { ...MINIMAL_MESSAGE, unsubscribeUrl: 'http://app.example.test/u' } },
|
|
136
140
|
{ problem: 'an unsubscribe URL with a bracket', message: { ...MINIMAL_MESSAGE, unsubscribeUrl: 'https://app.example.test/u>x' } },
|
|
141
|
+
{ problem: 'a broadcast without an unsubscribe URL', message: { ...MINIMAL_MESSAGE, broadcast: true } },
|
|
137
142
|
{
|
|
138
143
|
problem: 'an attachment name with a path',
|
|
139
144
|
message: { ...MINIMAL_MESSAGE, attachments: [{ filename: '../etc/passwd', contentType: 'text/plain', content: ONE_BYTE }] },
|
|
@@ -231,6 +236,19 @@ function describeHttpSender(harness: EmailSenderHttpHarness): void {
|
|
|
231
236
|
expect(current.requests).toHaveLength(0)
|
|
232
237
|
})
|
|
233
238
|
|
|
239
|
+
test('hands a broadcast message to the provider as one when it says it sends them, and refuses it otherwise', async () => {
|
|
240
|
+
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
241
|
+
if (current.sender.capabilities.broadcast === true) {
|
|
242
|
+
await current.sender.send(BROADCAST_MESSAGE, new AbortController().signal)
|
|
243
|
+
const wire = await harness.readRequest(only(current.requests))
|
|
244
|
+
expect(wire).toMatchObject({ ...contentOf(BROADCAST_MESSAGE), unsubscribeUrl: BROADCAST_MESSAGE.unsubscribeUrl })
|
|
245
|
+
expect(wire.broadcast).toBe(true)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
await expectRejection(() => current.sender.send(BROADCAST_MESSAGE, new AbortController().signal), 'invalid_message', () => MESSAGE_MARKERS)
|
|
249
|
+
expect(current.requests).toHaveLength(0)
|
|
250
|
+
})
|
|
251
|
+
|
|
234
252
|
test('hands the attachments it says it sends to the provider, and refuses them otherwise', async () => {
|
|
235
253
|
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
236
254
|
if (current.sender.capabilities.maxAttachmentBytes !== undefined) {
|
|
@@ -250,6 +268,31 @@ function describeHttpSender(harness: EmailSenderHttpHarness): void {
|
|
|
250
268
|
expect(current.requests).toHaveLength(0)
|
|
251
269
|
})
|
|
252
270
|
|
|
271
|
+
test('refuses an attachment of a file type it says it refuses, without a request', async () => {
|
|
272
|
+
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
273
|
+
const extension = current.sender.capabilities.refusedAttachmentExtensions?.[0]
|
|
274
|
+
if (extension === undefined || current.sender.capabilities.maxAttachmentBytes === undefined) return
|
|
275
|
+
const message: EmailMessage = {
|
|
276
|
+
...ATTACHMENT_MESSAGE,
|
|
277
|
+
attachments: [{ filename: `report.${extension.toUpperCase()}`, contentType: 'application/octet-stream', content: ONE_BYTE }],
|
|
278
|
+
}
|
|
279
|
+
await expectRejection(() => current.sender.send(message, new AbortController().signal), 'invalid_message', () => MESSAGE_MARKERS)
|
|
280
|
+
expect(current.requests).toHaveLength(0)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('refuses a body shorter than it says it sends, counted in characters once trimmed, without a request', async () => {
|
|
284
|
+
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
285
|
+
const min = current.sender.capabilities.minBodyLength
|
|
286
|
+
if (min === undefined) return
|
|
287
|
+
const short = ` ${'😀'.repeat(min - 1)} `
|
|
288
|
+
for (const message of [{ ...MINIMAL_MESSAGE, text: short }, { ...MINIMAL_MESSAGE, html: short }]) {
|
|
289
|
+
await expectRejection(() => current.sender.send(message, new AbortController().signal), 'invalid_message', () => MESSAGE_MARKERS)
|
|
290
|
+
}
|
|
291
|
+
expect(current.requests).toHaveLength(0)
|
|
292
|
+
await current.sender.send({ ...MINIMAL_MESSAGE, text: '😀'.repeat(min) }, new AbortController().signal)
|
|
293
|
+
expect(current.requests).toHaveLength(1)
|
|
294
|
+
})
|
|
295
|
+
|
|
253
296
|
test.each(INVALID_MESSAGES)('refuses $problem as invalid_message without a request', async ({ message }) => {
|
|
254
297
|
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
255
298
|
await expectRejection(() => current.sender.send(message, new AbortController().signal), 'invalid_message', forbidden(current))
|
|
@@ -382,6 +425,16 @@ function describeLocalSender(harness: EmailSenderLocalHarness): void {
|
|
|
382
425
|
expect(await harness.stored()).toHaveLength(0)
|
|
383
426
|
})
|
|
384
427
|
|
|
428
|
+
test('stores a broadcast message as one when it says it sends them, and refuses it otherwise', async () => {
|
|
429
|
+
if (current().capabilities.broadcast === true) {
|
|
430
|
+
await current().send(BROADCAST_MESSAGE, new AbortController().signal)
|
|
431
|
+
expect(only(await harness.stored()).broadcast).toBe(true)
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
await expectRejection(() => current().send(BROADCAST_MESSAGE, new AbortController().signal), 'invalid_message', () => MESSAGE_MARKERS)
|
|
435
|
+
expect(await harness.stored()).toHaveLength(0)
|
|
436
|
+
})
|
|
437
|
+
|
|
385
438
|
test('stores the attachments it says it sends, and refuses them otherwise', async () => {
|
|
386
439
|
const limit = current().capabilities.maxAttachmentBytes
|
|
387
440
|
if (limit !== undefined) {
|
|
@@ -448,6 +501,7 @@ function expectOptionalFieldsAbsent(wire: WireMessage): void {
|
|
|
448
501
|
expect(wire.fromName).toBeUndefined()
|
|
449
502
|
expect(wire.unsubscribeUrl).toBeUndefined()
|
|
450
503
|
expect(wire.attachments).toBeUndefined()
|
|
504
|
+
expect(wire.broadcast ?? false).toBe(false)
|
|
451
505
|
}
|
|
452
506
|
|
|
453
507
|
function contentOf(message: EmailMessage): WireMessage {
|
package/src/content.ts
CHANGED
|
@@ -16,13 +16,14 @@ export interface EmailContent {
|
|
|
16
16
|
readonly replyTo?: string
|
|
17
17
|
readonly fromName?: string
|
|
18
18
|
readonly unsubscribeUrl?: string
|
|
19
|
+
readonly broadcast?: true
|
|
19
20
|
readonly attachments?: readonly EmailAttachmentContent[]
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export function sameEmailContent(left: EmailContent, right: EmailContent): boolean {
|
|
23
24
|
return left.to === right.to && left.subject === right.subject && left.text === right.text && left.html === right.html
|
|
24
25
|
&& left.replyTo === right.replyTo && left.fromName === right.fromName && left.unsubscribeUrl === right.unsubscribeUrl
|
|
25
|
-
&& sameAttachments(left.attachments ?? [], right.attachments ?? [])
|
|
26
|
+
&& left.broadcast === right.broadcast && sameAttachments(left.attachments ?? [], right.attachments ?? [])
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export function emailContentOf(message: EmailMessage): EmailContent {
|
|
@@ -35,6 +36,7 @@ export function emailContentOf(message: EmailMessage): EmailContent {
|
|
|
35
36
|
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
|
|
36
37
|
...(message.fromName === undefined ? {} : { fromName: message.fromName }),
|
|
37
38
|
...(message.unsubscribeUrl === undefined ? {} : { unsubscribeUrl: message.unsubscribeUrl }),
|
|
39
|
+
...(message.broadcast === true ? { broadcast: true } : {}),
|
|
38
40
|
...(attachments.length === 0
|
|
39
41
|
? {}
|
|
40
42
|
: {
|
package/src/directory.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { z } from 'zod'
|
|
|
9
9
|
import { type EmailProvider, parseProviderVariables } from './config.js'
|
|
10
10
|
import { emailContentOf, sameEmailContent } from './content.js'
|
|
11
11
|
import { EmailDeliveryError, type EmailDeliveryResult, type EmailMessage, type EmailSender, type EmailSenderCapabilities } from './email.js'
|
|
12
|
-
import { validateEmailAttachments, validateEmailMessage } from './validation.js'
|
|
12
|
+
import { validateEmailAttachments, validateEmailBroadcast, validateEmailMessage } from './validation.js'
|
|
13
13
|
|
|
14
14
|
/** Resend's bound, so a local composition meets the limit a production transport has. */
|
|
15
15
|
const DIRECTORY_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
|
@@ -24,6 +24,7 @@ const capturedEmailSchema = z.object({
|
|
|
24
24
|
replyTo: z.string().optional(),
|
|
25
25
|
fromName: z.string().optional(),
|
|
26
26
|
unsubscribeUrl: z.string().optional(),
|
|
27
|
+
broadcast: z.literal(true).optional(),
|
|
27
28
|
attachments: z.array(z.object({ filename: z.string(), contentType: z.string(), content: z.string() })).readonly().optional(),
|
|
28
29
|
capturedAt: z.number().int(),
|
|
29
30
|
})
|
|
@@ -43,6 +44,7 @@ export class DirectoryEmailSender implements EmailSender {
|
|
|
43
44
|
maxBatch: 1,
|
|
44
45
|
listUnsubscribe: true,
|
|
45
46
|
maxAttachmentBytes: DIRECTORY_MAX_ATTACHMENT_BYTES,
|
|
47
|
+
broadcast: true,
|
|
46
48
|
}
|
|
47
49
|
readonly #directory: string
|
|
48
50
|
readonly #now: () => number
|
|
@@ -59,6 +61,7 @@ export class DirectoryEmailSender implements EmailSender {
|
|
|
59
61
|
async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
|
|
60
62
|
validateEmailMessage(message)
|
|
61
63
|
validateEmailAttachments(message, this.capabilities, 'The mail capture')
|
|
64
|
+
validateEmailBroadcast(message, this.capabilities, 'The mail capture')
|
|
62
65
|
if (signal.aborted) throw new EmailDeliveryError('The mail capture was aborted by the caller', { code: 'aborted' })
|
|
63
66
|
const messageId = createHash('sha256').update(message.idempotencyKey).digest('hex')
|
|
64
67
|
const captured: CapturedEmail = { messageId, idempotencyKey: message.idempotencyKey, ...emailContentOf(message), capturedAt: this.#now() }
|
package/src/email.ts
CHANGED
|
@@ -23,6 +23,12 @@ export interface EmailMessage {
|
|
|
23
23
|
* <url>` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click`. A transport without `listUnsubscribe` refuses it.
|
|
24
24
|
*/
|
|
25
25
|
readonly unsubscribeUrl?: string
|
|
26
|
+
/**
|
|
27
|
+
* One message of a send to many recipients (a newsletter, an announcement), not a reply to what the recipient did.
|
|
28
|
+
* A provider keeps such mail apart from transactional mail, and the law and the mailbox providers ask it to carry
|
|
29
|
+
* an unsubscribe, so it needs `unsubscribeUrl`. A transport without `broadcast` refuses it.
|
|
30
|
+
*/
|
|
31
|
+
readonly broadcast?: boolean
|
|
26
32
|
/**
|
|
27
33
|
* Files the message carries, at most `MAX_EMAIL_ATTACHMENTS`, together at most the transport's `maxAttachmentBytes`.
|
|
28
34
|
* A transport without `maxAttachmentBytes` refuses a message that has any; an empty list is none.
|
|
@@ -47,6 +53,12 @@ export interface EmailSenderCapabilities {
|
|
|
47
53
|
readonly listUnsubscribe?: boolean
|
|
48
54
|
/** The most attachment bytes one message carries, every file together; absent: it sends no attachments. */
|
|
49
55
|
readonly maxAttachmentBytes?: number
|
|
56
|
+
/** File extensions it refuses an attachment for, lowercase and without the dot; absent: none. */
|
|
57
|
+
readonly refusedAttachmentExtensions?: readonly string[]
|
|
58
|
+
/** The fewest characters a text or HTML body has once trimmed; absent: any body that is not blank. */
|
|
59
|
+
readonly minBodyLength?: number
|
|
60
|
+
/** Whether it sends a `broadcast` message, on its provider's channel for them where it has one; absent is no. */
|
|
61
|
+
readonly broadcast?: boolean
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
export interface EmailSender {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// What every HTTP provider adapter shares, so an adapter holds only its provider's mapping: the request it sends for a
|
|
2
|
+
// message, the message id of an accepted answer, and the answers it tells apart beyond the HTTP status. The base
|
|
3
|
+
// validates the message against the adapter's capabilities before any I/O and classifies the rest.
|
|
4
|
+
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
import type { EmailSenderIdentity } from './config.js'
|
|
7
|
+
import {
|
|
8
|
+
EmailDeliveryError,
|
|
9
|
+
type EmailDeliveryResult,
|
|
10
|
+
type EmailFetch,
|
|
11
|
+
type EmailMessage,
|
|
12
|
+
type EmailSender,
|
|
13
|
+
type EmailSenderCapabilities,
|
|
14
|
+
} from './email.js'
|
|
15
|
+
import { parseTimeout, postToProvider, type ProviderResponse } from './http.js'
|
|
16
|
+
import {
|
|
17
|
+
isValidEmailSenderIdentity,
|
|
18
|
+
isVisibleAscii,
|
|
19
|
+
validateEmailAttachments,
|
|
20
|
+
validateEmailBodies,
|
|
21
|
+
validateEmailBroadcast,
|
|
22
|
+
validateEmailMessage,
|
|
23
|
+
} from './validation.js'
|
|
24
|
+
|
|
25
|
+
/** The options every HTTP adapter takes beside its credentials. */
|
|
26
|
+
export interface HttpEmailSenderOptions {
|
|
27
|
+
readonly from: EmailSenderIdentity
|
|
28
|
+
readonly fetch?: EmailFetch
|
|
29
|
+
readonly timeoutMs?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface HttpEmailRequest {
|
|
33
|
+
readonly url: string
|
|
34
|
+
readonly headers: Readonly<Record<string, string>>
|
|
35
|
+
readonly body: unknown
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export abstract class HttpEmailSender implements EmailSender {
|
|
39
|
+
abstract readonly capabilities: EmailSenderCapabilities
|
|
40
|
+
protected readonly from: EmailSenderIdentity
|
|
41
|
+
readonly #label: string
|
|
42
|
+
readonly #provider: string
|
|
43
|
+
readonly #fetch: EmailFetch
|
|
44
|
+
readonly #timeoutMs: number
|
|
45
|
+
|
|
46
|
+
/** `label` names the provider in every message, `provider` in every result. An adapter checks its credentials first. */
|
|
47
|
+
protected constructor(names: { readonly label: string; readonly provider: string }, options: HttpEmailSenderOptions) {
|
|
48
|
+
if (!isValidEmailSenderIdentity(options.from)) {
|
|
49
|
+
throw new EmailDeliveryError(`${names.label} sender is invalid`, { code: 'invalid_configuration' })
|
|
50
|
+
}
|
|
51
|
+
this.from = options.from
|
|
52
|
+
this.#label = names.label
|
|
53
|
+
this.#provider = names.provider
|
|
54
|
+
this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init))
|
|
55
|
+
this.#timeoutMs = parseTimeout(names.label, options.timeoutMs)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The request for a message that passed validation. */
|
|
59
|
+
protected abstract request(message: EmailMessage): HttpEmailRequest | Promise<HttpEmailRequest>
|
|
60
|
+
|
|
61
|
+
/** The message id of a 2xx answer; undefined when the answer is not a valid success. May throw a refusal. */
|
|
62
|
+
protected abstract messageId(response: ProviderResponse): string | undefined
|
|
63
|
+
|
|
64
|
+
/** A failed answer to the message the provider tells apart beyond its status; undefined leaves it to `refusalByStatus`. */
|
|
65
|
+
protected refusal(_response: ProviderResponse, _message: EmailMessage): EmailDeliveryError | undefined {
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
|
|
70
|
+
validateEmailMessage(message)
|
|
71
|
+
if (message.unsubscribeUrl !== undefined && this.capabilities.listUnsubscribe !== true) {
|
|
72
|
+
throw new EmailDeliveryError(`${this.#label} cannot write the List-Unsubscribe headers`, { code: 'invalid_message' })
|
|
73
|
+
}
|
|
74
|
+
validateEmailAttachments(message, this.capabilities, this.#label)
|
|
75
|
+
validateEmailBodies(message, this.capabilities, this.#label)
|
|
76
|
+
validateEmailBroadcast(message, this.capabilities, this.#label)
|
|
77
|
+
|
|
78
|
+
const request = await this.request(message)
|
|
79
|
+
const response = await postToProvider({ label: this.#label, fetch: this.#fetch, ...request, signal, timeoutMs: this.#timeoutMs })
|
|
80
|
+
if (!response.ok) throw this.refusal(response, message) ?? refusalByStatus(this.#label, response.status)
|
|
81
|
+
|
|
82
|
+
const messageId = this.messageId(response)
|
|
83
|
+
if (messageId === undefined || messageId === '') {
|
|
84
|
+
throw new EmailDeliveryError(`${this.#label} returned an invalid success response`, {
|
|
85
|
+
code: 'malformed_response',
|
|
86
|
+
httpStatus: response.status,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
return { status: 'accepted', provider: this.#provider, messageId }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The classification of a failed answer by its status alone, for every answer an adapter does not tell apart. */
|
|
94
|
+
export function refusalByStatus(label: string, status: number): EmailDeliveryError {
|
|
95
|
+
if (status === 401 || status === 403) {
|
|
96
|
+
return new EmailDeliveryError(`${label} refused the credentials (${status})`, { code: 'provider_unauthorized', httpStatus: status })
|
|
97
|
+
}
|
|
98
|
+
if (status === 429) return new EmailDeliveryError(`${label} rate limit exceeded`, { code: 'rate_limited', httpStatus: status })
|
|
99
|
+
if (status >= 500) return new EmailDeliveryError(`${label} is unavailable`, { code: 'provider_unavailable', httpStatus: status })
|
|
100
|
+
return new EmailDeliveryError(`${label} rejected the request (${status})`, { code: 'provider_rejected', httpStatus: status })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** RFC 5322 specials a display name may still hold after validation; any of them makes the name a quoted string. */
|
|
104
|
+
const DISPLAY_NAME_SPECIALS = /[()[\]:;@,.]/
|
|
105
|
+
|
|
106
|
+
/** `Name <address>` for an API that takes the sender as one header value; validation keeps the name quotable. */
|
|
107
|
+
export function formatMailbox(address: string, name: string | undefined): string {
|
|
108
|
+
if (name === undefined) return address
|
|
109
|
+
return DISPLAY_NAME_SPECIALS.test(name) ? `"${name}" <${address}>` : `${name} <${address}>`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** An API key or token: non-empty visible ASCII, so it fits a header unchanged. */
|
|
113
|
+
export function isCredential(value: string): boolean {
|
|
114
|
+
return value.length > 0 && isVisibleAscii(value)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The name of a provider's stream or route: 1..64 letters, digits, `_` and `-`. */
|
|
118
|
+
export function isChannelName(value: string): boolean {
|
|
119
|
+
return /^[A-Za-z0-9_-]{1,64}$/.test(value)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A provider variable that holds a credential. */
|
|
123
|
+
export const credentialVariable = z.string().refine(isCredential)
|
|
124
|
+
|
|
125
|
+
/** An optional provider variable: empty is absent, and a present value must pass `valid`. */
|
|
126
|
+
export function optionalVariable(valid: (value: string) => boolean) {
|
|
127
|
+
return z.string().optional().transform((value) => (value === '' ? undefined : value)).refine((value) => value === undefined || valid(value))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The message's key as a provider that takes at most `maxLength` characters sends it. A longer key is sent as its
|
|
132
|
+
* SHA-256, which keeps one key one key: a retry sends the same digest, another message another.
|
|
133
|
+
*/
|
|
134
|
+
export async function providerIdempotencyKey(key: string, maxLength: number): Promise<string> {
|
|
135
|
+
if (key.length <= maxLength) return key
|
|
136
|
+
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key)))
|
|
137
|
+
return `sha256:${Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('')}`
|
|
138
|
+
}
|