@fabrika/email 0.6.0 → 0.8.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/package.json +6 -1
- package/src/conformance.ts +120 -1
- package/src/content.ts +42 -1
- package/src/directory.ts +14 -2
- package/src/email.ts +31 -0
- package/src/index.ts +14 -2
- package/src/memory.ts +16 -2
- package/src/platform-wire.ts +207 -0
- package/src/platform.ts +109 -0
- package/src/resend.ts +24 -2
- package/src/smartemailing.ts +6 -1
- package/src/validation.ts +44 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/email",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -45,6 +45,11 @@
|
|
|
45
45
|
"types": "./src/providers.ts",
|
|
46
46
|
"default": "./src/providers.ts"
|
|
47
47
|
},
|
|
48
|
+
"./platform": {
|
|
49
|
+
"bun": "./src/platform.ts",
|
|
50
|
+
"types": "./src/platform.ts",
|
|
51
|
+
"default": "./src/platform.ts"
|
|
52
|
+
},
|
|
48
53
|
"./conformance": {
|
|
49
54
|
"bun": "./src/conformance.ts",
|
|
50
55
|
"types": "./src/conformance.ts",
|
package/src/conformance.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// request and how its provider answers; a local sender says where it stores what it took.
|
|
4
4
|
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
6
|
+
import { type EmailAttachmentContent, emailContentOf } from './content.js'
|
|
6
7
|
import {
|
|
7
8
|
EMAIL_DELIVERY_ERRORS,
|
|
8
9
|
EmailDeliveryError,
|
|
@@ -21,6 +22,15 @@ export interface WireMessage {
|
|
|
21
22
|
readonly replyTo?: string
|
|
22
23
|
readonly fromName?: string
|
|
23
24
|
readonly idempotencyKey?: string
|
|
25
|
+
/** The URL of the `List-Unsubscribe` header, when `List-Unsubscribe-Post` says one click. */
|
|
26
|
+
readonly unsubscribeUrl?: string
|
|
27
|
+
/** Each attachment with its bytes as base64. */
|
|
28
|
+
readonly attachments?: readonly EmailAttachmentContent[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A stored or sent message in wire terms: what a local harness answers for an `EmailMessage` it kept. */
|
|
32
|
+
export function wireMessageOf(message: EmailMessage): WireMessage {
|
|
33
|
+
return { ...emailContentOf(message), idempotencyKey: message.idempotencyKey }
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
/** How the provider answers. `inProgress` and `idempotencyConflict` are required when the sender is idempotent. */
|
|
@@ -83,6 +93,34 @@ const MINIMAL_MESSAGE: EmailMessage = {
|
|
|
83
93
|
idempotencyKey: 'conformance/minimal-1',
|
|
84
94
|
}
|
|
85
95
|
|
|
96
|
+
const UNSUBSCRIBE_MESSAGE: EmailMessage = {
|
|
97
|
+
...MINIMAL_MESSAGE,
|
|
98
|
+
idempotencyKey: 'conformance/unsubscribe-1',
|
|
99
|
+
unsubscribeUrl: 'https://app.example.test/api/mail.unsubscribe?t=conformance-token-6c2e',
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const ATTACHMENT_MESSAGE: EmailMessage = {
|
|
103
|
+
...MINIMAL_MESSAGE,
|
|
104
|
+
idempotencyKey: 'conformance/attachment-1',
|
|
105
|
+
attachments: [{ filename: 'conformance report.txt', contentType: 'text/plain', content: new TextEncoder().encode('Conformance attachment 9d4e') }],
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The same message under its key, with other bytes in its one attachment. */
|
|
109
|
+
const OTHER_ATTACHMENT_MESSAGE: EmailMessage = {
|
|
110
|
+
...ATTACHMENT_MESSAGE,
|
|
111
|
+
attachments: [{ filename: 'conformance report.txt', contentType: 'text/plain', content: new TextEncoder().encode('Conformance attachment 0f1a') }],
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function oversizedMessage(maxAttachmentBytes: number): EmailMessage {
|
|
115
|
+
return {
|
|
116
|
+
...MINIMAL_MESSAGE,
|
|
117
|
+
idempotencyKey: 'conformance/oversized-1',
|
|
118
|
+
attachments: [{ filename: 'large.bin', contentType: 'application/octet-stream', content: new Uint8Array(maxAttachmentBytes + 1) }],
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const ONE_BYTE = new Uint8Array([0x61])
|
|
123
|
+
|
|
86
124
|
const INVALID_MESSAGES: readonly { readonly problem: string; readonly message: EmailMessage }[] = [
|
|
87
125
|
{ problem: 'a bad recipient', message: { ...MINIMAL_MESSAGE, to: 'not-an-address' } },
|
|
88
126
|
{ problem: 'an empty subject', message: { ...MINIMAL_MESSAGE, subject: '' } },
|
|
@@ -94,6 +132,23 @@ const INVALID_MESSAGES: readonly { readonly problem: string; readonly message: E
|
|
|
94
132
|
{ problem: 'a bad reply-to', message: { ...MINIMAL_MESSAGE, replyTo: 'not-an-address' } },
|
|
95
133
|
{ problem: 'a sender name with brackets', message: { ...MINIMAL_MESSAGE, fromName: 'Bad <name>' } },
|
|
96
134
|
{ problem: 'a sender name over 128 characters', message: { ...MINIMAL_MESSAGE, fromName: 'x'.repeat(129) } },
|
|
135
|
+
{ problem: 'an unsubscribe URL over http', message: { ...MINIMAL_MESSAGE, unsubscribeUrl: 'http://app.example.test/u' } },
|
|
136
|
+
{ problem: 'an unsubscribe URL with a bracket', message: { ...MINIMAL_MESSAGE, unsubscribeUrl: 'https://app.example.test/u>x' } },
|
|
137
|
+
{
|
|
138
|
+
problem: 'an attachment name with a path',
|
|
139
|
+
message: { ...MINIMAL_MESSAGE, attachments: [{ filename: '../etc/passwd', contentType: 'text/plain', content: ONE_BYTE }] },
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
problem: 'an attachment type with a line break',
|
|
143
|
+
message: { ...MINIMAL_MESSAGE, attachments: [{ filename: 'a.txt', contentType: 'text/plain\r\nX-Bad: 1', content: ONE_BYTE }] },
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
problem: 'more than 20 attachments',
|
|
147
|
+
message: {
|
|
148
|
+
...MINIMAL_MESSAGE,
|
|
149
|
+
attachments: Array.from({ length: 21 }, () => ({ filename: 'a.txt', contentType: 'text/plain', content: ONE_BYTE })),
|
|
150
|
+
},
|
|
151
|
+
},
|
|
97
152
|
]
|
|
98
153
|
|
|
99
154
|
/**
|
|
@@ -165,6 +220,36 @@ function describeHttpSender(harness: EmailSenderHttpHarness): void {
|
|
|
165
220
|
expectOptionalFieldsAbsent(await harness.readRequest(only(current.requests)))
|
|
166
221
|
})
|
|
167
222
|
|
|
223
|
+
test('writes the one-click unsubscribe headers it says it writes, and refuses them otherwise', async () => {
|
|
224
|
+
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
225
|
+
if (current.sender.capabilities.listUnsubscribe === true) {
|
|
226
|
+
await current.sender.send(UNSUBSCRIBE_MESSAGE, new AbortController().signal)
|
|
227
|
+
expect((await harness.readRequest(only(current.requests))).unsubscribeUrl).toBe(UNSUBSCRIBE_MESSAGE.unsubscribeUrl)
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
await expectRejection(() => current.sender.send(UNSUBSCRIBE_MESSAGE, new AbortController().signal), 'invalid_message', () => MESSAGE_MARKERS)
|
|
231
|
+
expect(current.requests).toHaveLength(0)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('hands the attachments it says it sends to the provider, and refuses them otherwise', async () => {
|
|
235
|
+
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
236
|
+
if (current.sender.capabilities.maxAttachmentBytes !== undefined) {
|
|
237
|
+
await current.sender.send(ATTACHMENT_MESSAGE, new AbortController().signal)
|
|
238
|
+
expect((await harness.readRequest(only(current.requests))).attachments).toEqual(wireMessageOf(ATTACHMENT_MESSAGE).attachments)
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
await expectRejection(() => current.sender.send(ATTACHMENT_MESSAGE, new AbortController().signal), 'invalid_message', () => MESSAGE_MARKERS)
|
|
242
|
+
expect(current.requests).toHaveLength(0)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('refuses attachments over its limit without a request', async () => {
|
|
246
|
+
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
247
|
+
const limit = current.sender.capabilities.maxAttachmentBytes
|
|
248
|
+
if (limit === undefined) return
|
|
249
|
+
await expectRejection(() => current.sender.send(oversizedMessage(limit), new AbortController().signal), 'invalid_message', () => MESSAGE_MARKERS)
|
|
250
|
+
expect(current.requests).toHaveLength(0)
|
|
251
|
+
})
|
|
252
|
+
|
|
168
253
|
test.each(INVALID_MESSAGES)('refuses $problem as invalid_message without a request', async ({ message }) => {
|
|
169
254
|
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
170
255
|
await expectRejection(() => current.sender.send(message, new AbortController().signal), 'invalid_message', forbidden(current))
|
|
@@ -287,6 +372,37 @@ function describeLocalSender(harness: EmailSenderLocalHarness): void {
|
|
|
287
372
|
expectOptionalFieldsAbsent(only(await harness.stored()))
|
|
288
373
|
})
|
|
289
374
|
|
|
375
|
+
test('stores the one-click unsubscribe URL it says it writes, and refuses it otherwise', async () => {
|
|
376
|
+
if (current().capabilities.listUnsubscribe === true) {
|
|
377
|
+
await current().send(UNSUBSCRIBE_MESSAGE, new AbortController().signal)
|
|
378
|
+
expect(only(await harness.stored()).unsubscribeUrl).toBe(UNSUBSCRIBE_MESSAGE.unsubscribeUrl)
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
await expectRejection(() => current().send(UNSUBSCRIBE_MESSAGE, new AbortController().signal), 'invalid_message', () => MESSAGE_MARKERS)
|
|
382
|
+
expect(await harness.stored()).toHaveLength(0)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
test('stores the attachments it says it sends, and refuses them otherwise', async () => {
|
|
386
|
+
const limit = current().capabilities.maxAttachmentBytes
|
|
387
|
+
if (limit !== undefined) {
|
|
388
|
+
await current().send(ATTACHMENT_MESSAGE, new AbortController().signal)
|
|
389
|
+
expect(only(await harness.stored()).attachments).toEqual(wireMessageOf(ATTACHMENT_MESSAGE).attachments)
|
|
390
|
+
await expectRejection(() => current().send(oversizedMessage(limit), new AbortController().signal), 'invalid_message', () => MESSAGE_MARKERS)
|
|
391
|
+
expect(await harness.stored()).toHaveLength(1)
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
await expectRejection(() => current().send(ATTACHMENT_MESSAGE, new AbortController().signal), 'invalid_message', () => MESSAGE_MARKERS)
|
|
395
|
+
expect(await harness.stored()).toHaveLength(0)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test('an idempotent sender refuses other attachment bytes under the same key', async () => {
|
|
399
|
+
const target = current()
|
|
400
|
+
if (!target.capabilities.idempotent || target.capabilities.maxAttachmentBytes === undefined) return
|
|
401
|
+
await target.send(ATTACHMENT_MESSAGE, new AbortController().signal)
|
|
402
|
+
await expectRejection(() => target.send(OTHER_ATTACHMENT_MESSAGE, new AbortController().signal), 'idempotency_conflict', () => MESSAGE_MARKERS)
|
|
403
|
+
expect(await harness.stored()).toHaveLength(1)
|
|
404
|
+
})
|
|
405
|
+
|
|
290
406
|
test.each(INVALID_MESSAGES)('refuses $problem as invalid_message and stores nothing', async ({ message }) => {
|
|
291
407
|
await expectRejection(() => current().send(message, new AbortController().signal), 'invalid_message', () => MESSAGE_MARKERS)
|
|
292
408
|
expect(await harness.stored()).toHaveLength(0)
|
|
@@ -330,6 +446,8 @@ function expectOptionalFieldsAbsent(wire: WireMessage): void {
|
|
|
330
446
|
expect(wire.html).toBeUndefined()
|
|
331
447
|
expect(wire.replyTo).toBeUndefined()
|
|
332
448
|
expect(wire.fromName).toBeUndefined()
|
|
449
|
+
expect(wire.unsubscribeUrl).toBeUndefined()
|
|
450
|
+
expect(wire.attachments).toBeUndefined()
|
|
333
451
|
}
|
|
334
452
|
|
|
335
453
|
function contentOf(message: EmailMessage): WireMessage {
|
|
@@ -394,7 +512,8 @@ function safeString(value: unknown): string {
|
|
|
394
512
|
function markersOf(body: string): string[] {
|
|
395
513
|
const markers: string[] = []
|
|
396
514
|
const collect = (value: unknown): void => {
|
|
397
|
-
|
|
515
|
+
// A delivery error code is the contract's own vocabulary, which a relay answers with, not provider content.
|
|
516
|
+
if (typeof value === 'string' && value.length >= MIN_BODY_MARKER_LENGTH && !Object.hasOwn(EMAIL_DELIVERY_ERRORS, value)) markers.push(value)
|
|
398
517
|
else if (Array.isArray(value)) value.forEach(collect)
|
|
399
518
|
else if (typeof value === 'object' && value !== null) Object.values(value).forEach(collect)
|
|
400
519
|
}
|
package/src/content.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { EmailMessage } from './email.js'
|
|
2
2
|
|
|
3
|
+
/** An attachment as a local sender keeps and compares it: the bytes as base64. */
|
|
4
|
+
export interface EmailAttachmentContent {
|
|
5
|
+
readonly filename: string
|
|
6
|
+
readonly contentType: string
|
|
7
|
+
readonly content: string
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
/** What a local sender compares when one idempotency key arrives twice: everything the recipient would see. */
|
|
4
11
|
export interface EmailContent {
|
|
5
12
|
readonly to: string
|
|
@@ -8,14 +15,18 @@ export interface EmailContent {
|
|
|
8
15
|
readonly html?: string
|
|
9
16
|
readonly replyTo?: string
|
|
10
17
|
readonly fromName?: string
|
|
18
|
+
readonly unsubscribeUrl?: string
|
|
19
|
+
readonly attachments?: readonly EmailAttachmentContent[]
|
|
11
20
|
}
|
|
12
21
|
|
|
13
22
|
export function sameEmailContent(left: EmailContent, right: EmailContent): boolean {
|
|
14
23
|
return left.to === right.to && left.subject === right.subject && left.text === right.text && left.html === right.html
|
|
15
|
-
&& left.replyTo === right.replyTo && left.fromName === right.fromName
|
|
24
|
+
&& left.replyTo === right.replyTo && left.fromName === right.fromName && left.unsubscribeUrl === right.unsubscribeUrl
|
|
25
|
+
&& sameAttachments(left.attachments ?? [], right.attachments ?? [])
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
export function emailContentOf(message: EmailMessage): EmailContent {
|
|
29
|
+
const attachments = message.attachments ?? []
|
|
19
30
|
return {
|
|
20
31
|
to: message.to,
|
|
21
32
|
subject: message.subject,
|
|
@@ -23,5 +34,35 @@ export function emailContentOf(message: EmailMessage): EmailContent {
|
|
|
23
34
|
...(message.html === undefined ? {} : { html: message.html }),
|
|
24
35
|
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
|
|
25
36
|
...(message.fromName === undefined ? {} : { fromName: message.fromName }),
|
|
37
|
+
...(message.unsubscribeUrl === undefined ? {} : { unsubscribeUrl: message.unsubscribeUrl }),
|
|
38
|
+
...(attachments.length === 0
|
|
39
|
+
? {}
|
|
40
|
+
: {
|
|
41
|
+
attachments: attachments.map((attachment) => ({
|
|
42
|
+
filename: attachment.filename,
|
|
43
|
+
contentType: attachment.contentType,
|
|
44
|
+
content: base64Of(attachment.content),
|
|
45
|
+
})),
|
|
46
|
+
}),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const BASE64_CHUNK = 0x8000
|
|
51
|
+
|
|
52
|
+
/** `bytes` as standard base64, on every runtime: `btoa` over chunks, so a large file never overflows the call stack. */
|
|
53
|
+
export function base64Of(bytes: Uint8Array): string {
|
|
54
|
+
let binary = ''
|
|
55
|
+
for (let offset = 0; offset < bytes.byteLength; offset += BASE64_CHUNK) {
|
|
56
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK))
|
|
26
57
|
}
|
|
58
|
+
return btoa(binary)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sameAttachments(left: readonly EmailAttachmentContent[], right: readonly EmailAttachmentContent[]): boolean {
|
|
62
|
+
return left.length === right.length
|
|
63
|
+
&& left.every((attachment, index) => {
|
|
64
|
+
const other = right[index]
|
|
65
|
+
return other !== undefined && attachment.filename === other.filename && attachment.contentType === other.contentType
|
|
66
|
+
&& attachment.content === other.content
|
|
67
|
+
})
|
|
27
68
|
}
|
package/src/directory.ts
CHANGED
|
@@ -9,7 +9,10 @@ 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 { validateEmailMessage } from './validation.js'
|
|
12
|
+
import { validateEmailAttachments, validateEmailMessage } from './validation.js'
|
|
13
|
+
|
|
14
|
+
/** Resend's bound, so a local composition meets the limit a production transport has. */
|
|
15
|
+
const DIRECTORY_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
|
13
16
|
|
|
14
17
|
const capturedEmailSchema = z.object({
|
|
15
18
|
messageId: z.string().min(1),
|
|
@@ -20,6 +23,8 @@ const capturedEmailSchema = z.object({
|
|
|
20
23
|
html: z.string().optional(),
|
|
21
24
|
replyTo: z.string().optional(),
|
|
22
25
|
fromName: z.string().optional(),
|
|
26
|
+
unsubscribeUrl: z.string().optional(),
|
|
27
|
+
attachments: z.array(z.object({ filename: z.string(), contentType: z.string(), content: z.string() })).readonly().optional(),
|
|
23
28
|
capturedAt: z.number().int(),
|
|
24
29
|
})
|
|
25
30
|
|
|
@@ -32,7 +37,13 @@ export interface DirectoryEmailSenderOptions {
|
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
export class DirectoryEmailSender implements EmailSender {
|
|
35
|
-
readonly capabilities: EmailSenderCapabilities = {
|
|
40
|
+
readonly capabilities: EmailSenderCapabilities = {
|
|
41
|
+
idempotent: true,
|
|
42
|
+
confirmation: 'sync',
|
|
43
|
+
maxBatch: 1,
|
|
44
|
+
listUnsubscribe: true,
|
|
45
|
+
maxAttachmentBytes: DIRECTORY_MAX_ATTACHMENT_BYTES,
|
|
46
|
+
}
|
|
36
47
|
readonly #directory: string
|
|
37
48
|
readonly #now: () => number
|
|
38
49
|
|
|
@@ -47,6 +58,7 @@ export class DirectoryEmailSender implements EmailSender {
|
|
|
47
58
|
/** One file per idempotency key: a repeated send of the same message answers the first id, as a provider does. */
|
|
48
59
|
async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
|
|
49
60
|
validateEmailMessage(message)
|
|
61
|
+
validateEmailAttachments(message, this.capabilities, 'The mail capture')
|
|
50
62
|
if (signal.aborted) throw new EmailDeliveryError('The mail capture was aborted by the caller', { code: 'aborted' })
|
|
51
63
|
const messageId = createHash('sha256').update(message.idempotencyKey).digest('hex')
|
|
52
64
|
const captured: CapturedEmail = { messageId, idempotencyKey: message.idempotencyKey, ...emailContentOf(message), capturedAt: this.#now() }
|
package/src/email.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export type EmailFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
2
2
|
|
|
3
|
+
/** One file a message carries: the name the recipient sees, its media type and its bytes. */
|
|
4
|
+
export interface EmailAttachment {
|
|
5
|
+
readonly filename: string
|
|
6
|
+
readonly contentType: string
|
|
7
|
+
readonly content: Uint8Array
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
export interface EmailMessage {
|
|
4
11
|
readonly to: string
|
|
5
12
|
readonly subject: string
|
|
@@ -11,6 +18,16 @@ export interface EmailMessage {
|
|
|
11
18
|
readonly replyTo?: string
|
|
12
19
|
/** Replaces the transport's display name for this message. */
|
|
13
20
|
readonly fromName?: string
|
|
21
|
+
/**
|
|
22
|
+
* An `https:` URL that unsubscribes the recipient in one click (RFC 8058): the transport writes `List-Unsubscribe:
|
|
23
|
+
* <url>` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click`. A transport without `listUnsubscribe` refuses it.
|
|
24
|
+
*/
|
|
25
|
+
readonly unsubscribeUrl?: string
|
|
26
|
+
/**
|
|
27
|
+
* Files the message carries, at most `MAX_EMAIL_ATTACHMENTS`, together at most the transport's `maxAttachmentBytes`.
|
|
28
|
+
* A transport without `maxAttachmentBytes` refuses a message that has any; an empty list is none.
|
|
29
|
+
*/
|
|
30
|
+
readonly attachments?: readonly EmailAttachment[]
|
|
14
31
|
}
|
|
15
32
|
|
|
16
33
|
export interface EmailDeliveryResult {
|
|
@@ -26,6 +43,10 @@ export interface EmailSenderCapabilities {
|
|
|
26
43
|
readonly confirmation: 'sync' | 'queued'
|
|
27
44
|
/** The most messages one call carries. `send` carries one, so every built-in adapter reports 1. */
|
|
28
45
|
readonly maxBatch: number
|
|
46
|
+
/** Whether it writes a message's one-click unsubscribe headers (`unsubscribeUrl`); absent is no. */
|
|
47
|
+
readonly listUnsubscribe?: boolean
|
|
48
|
+
/** The most attachment bytes one message carries, every file together; absent: it sends no attachments. */
|
|
49
|
+
readonly maxAttachmentBytes?: number
|
|
29
50
|
}
|
|
30
51
|
|
|
31
52
|
export interface EmailSender {
|
|
@@ -79,7 +100,17 @@ export class EmailDeliveryError extends Error {
|
|
|
79
100
|
}
|
|
80
101
|
}
|
|
81
102
|
|
|
103
|
+
/** The two headers of a one-click unsubscribe (RFC 8058), as a transport writes them for `unsubscribeUrl`. */
|
|
104
|
+
export function oneClickUnsubscribeHeaders(url: string): Readonly<Record<string, string>> {
|
|
105
|
+
return { 'List-Unsubscribe': `<${url}>`, 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click' }
|
|
106
|
+
}
|
|
107
|
+
|
|
82
108
|
/** The one retry rule: an unknown outcome is retried only over an idempotent transport. */
|
|
83
109
|
export function mayRetryDelivery(error: EmailDeliveryError, capabilities: EmailSenderCapabilities): boolean {
|
|
84
110
|
return error.retryable && (error.outcome === 'not_sent' || capabilities.idempotent)
|
|
85
111
|
}
|
|
112
|
+
|
|
113
|
+
/** The bytes of every attachment of `message` together. */
|
|
114
|
+
export function attachmentBytes(message: EmailMessage): number {
|
|
115
|
+
return (message.attachments ?? []).reduce((total, attachment) => total + attachment.content.byteLength, 0)
|
|
116
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -14,8 +14,9 @@ export type {
|
|
|
14
14
|
EmailSenderIdentity,
|
|
15
15
|
EmailTransportConfig,
|
|
16
16
|
} from './config.js'
|
|
17
|
-
export { EMAIL_DELIVERY_ERRORS, EmailDeliveryError, mayRetryDelivery } from './email.js'
|
|
17
|
+
export { attachmentBytes, EMAIL_DELIVERY_ERRORS, EmailDeliveryError, mayRetryDelivery, oneClickUnsubscribeHeaders } from './email.js'
|
|
18
18
|
export type {
|
|
19
|
+
EmailAttachment,
|
|
19
20
|
EmailDeliveryErrorCode,
|
|
20
21
|
EmailDeliveryErrorOptions,
|
|
21
22
|
EmailDeliveryOutcome,
|
|
@@ -25,4 +26,15 @@ export type {
|
|
|
25
26
|
EmailSender,
|
|
26
27
|
EmailSenderCapabilities,
|
|
27
28
|
} from './email.js'
|
|
28
|
-
export {
|
|
29
|
+
export {
|
|
30
|
+
isUnsubscribeUrl,
|
|
31
|
+
isValidAttachmentFilename,
|
|
32
|
+
isValidEmailAddress,
|
|
33
|
+
isValidEmailDisplayName,
|
|
34
|
+
isValidEmailFrom,
|
|
35
|
+
MAX_EMAIL_ATTACHMENTS,
|
|
36
|
+
MAX_EMAIL_SUBJECT_LENGTH,
|
|
37
|
+
MAX_UNSUBSCRIBE_URL_LENGTH,
|
|
38
|
+
validateEmailAttachments,
|
|
39
|
+
validateEmailMessage,
|
|
40
|
+
} from './validation.js'
|
package/src/memory.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type EmailSender,
|
|
12
12
|
type EmailSenderCapabilities,
|
|
13
13
|
} from './email.js'
|
|
14
|
-
import { validateEmailMessage } from './validation.js'
|
|
14
|
+
import { validateEmailAttachments, validateEmailMessage } from './validation.js'
|
|
15
15
|
|
|
16
16
|
export interface MemoryEmail {
|
|
17
17
|
readonly message: EmailMessage
|
|
@@ -51,6 +51,9 @@ interface KeyedEmail {
|
|
|
51
51
|
readonly messageId: string
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** Resend's bound, so a test meets the limit a production transport has. */
|
|
55
|
+
const MEMORY_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
|
56
|
+
|
|
54
57
|
const FAULT_MESSAGES: Readonly<Record<MemoryFault, string>> = {
|
|
55
58
|
provider_rejected: 'The memory sender rejected the message',
|
|
56
59
|
provider_unauthorized: 'The memory sender refused the credentials',
|
|
@@ -73,7 +76,14 @@ export class MemoryEmailSender implements EmailSender {
|
|
|
73
76
|
#taken = 0
|
|
74
77
|
|
|
75
78
|
constructor(options: MemoryEmailSenderOptions = {}) {
|
|
76
|
-
this.capabilities = {
|
|
79
|
+
this.capabilities = {
|
|
80
|
+
idempotent: true,
|
|
81
|
+
confirmation: 'sync',
|
|
82
|
+
maxBatch: 1,
|
|
83
|
+
listUnsubscribe: true,
|
|
84
|
+
maxAttachmentBytes: MEMORY_MAX_ATTACHMENT_BYTES,
|
|
85
|
+
...options.capabilities,
|
|
86
|
+
}
|
|
77
87
|
if (!Number.isSafeInteger(this.capabilities.maxBatch) || this.capabilities.maxBatch < 1) {
|
|
78
88
|
throw new EmailDeliveryError('The memory sender batch size is invalid', { code: 'invalid_configuration' })
|
|
79
89
|
}
|
|
@@ -124,6 +134,10 @@ export class MemoryEmailSender implements EmailSender {
|
|
|
124
134
|
|
|
125
135
|
async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
|
|
126
136
|
validateEmailMessage(message)
|
|
137
|
+
if (message.unsubscribeUrl !== undefined && this.capabilities.listUnsubscribe !== true) {
|
|
138
|
+
throw new EmailDeliveryError('The memory sender writes no List-Unsubscribe header', { code: 'invalid_message' })
|
|
139
|
+
}
|
|
140
|
+
validateEmailAttachments(message, this.capabilities, 'The memory sender')
|
|
127
141
|
if (signal.aborted) throw failure('aborted')
|
|
128
142
|
const handoff: MutableHandoff = { message, at: this.#now(), taken: false, answer: 'network_error' }
|
|
129
143
|
this.#handoffs.push(handoff)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// The wire of the platform transport's mail receiver: what an application's `PlatformEmailSender` posts to Control's
|
|
2
|
+
// relay and what the relay answers. Both sides import it, so the encoding, the one parser and the status mapping
|
|
3
|
+
// cannot drift apart. Runtime-neutral: Web APIs only.
|
|
4
|
+
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
import { base64Of } from './content.js'
|
|
7
|
+
import { EMAIL_DELIVERY_ERRORS, EmailDeliveryError, type EmailDeliveryErrorCode, type EmailMessage, type EmailSenderCapabilities } from './email.js'
|
|
8
|
+
import { validateEmailAttachments, validateEmailMessage } from './validation.js'
|
|
9
|
+
|
|
10
|
+
/** The relay's path under the receiver's URL (`FABRIKA_PLATFORM_MAIL_URL`). */
|
|
11
|
+
export const PLATFORM_MAIL_SEND_PATH = '/platform/mail/send'
|
|
12
|
+
|
|
13
|
+
/** What the relay promises: it records the message under its key and dispatches it later, perhaps after a person releases it. */
|
|
14
|
+
export const PLATFORM_MAIL_CAPABILITIES: EmailSenderCapabilities = {
|
|
15
|
+
idempotent: true,
|
|
16
|
+
confirmation: 'queued',
|
|
17
|
+
maxBatch: 1,
|
|
18
|
+
listUnsubscribe: true,
|
|
19
|
+
maxAttachmentBytes: 10 * 1024 * 1024,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const LABEL = 'Platform mail'
|
|
23
|
+
|
|
24
|
+
/** One attachment on the wire: the bytes as standard base64. */
|
|
25
|
+
interface PlatformMailAttachment {
|
|
26
|
+
readonly filename: string
|
|
27
|
+
readonly contentType: string
|
|
28
|
+
readonly contentBase64: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The request body: the message's own fields in a fixed order, absent optional fields left out. */
|
|
32
|
+
interface PlatformMailRequest {
|
|
33
|
+
readonly to: string
|
|
34
|
+
readonly subject: string
|
|
35
|
+
readonly text: string
|
|
36
|
+
readonly html?: string
|
|
37
|
+
readonly idempotencyKey: string
|
|
38
|
+
readonly replyTo?: string
|
|
39
|
+
readonly fromName?: string
|
|
40
|
+
readonly unsubscribeUrl?: string
|
|
41
|
+
readonly attachments?: readonly PlatformMailAttachment[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function platformMailRequestOf(message: EmailMessage): PlatformMailRequest {
|
|
45
|
+
const attachments = message.attachments ?? []
|
|
46
|
+
return {
|
|
47
|
+
to: message.to,
|
|
48
|
+
subject: message.subject,
|
|
49
|
+
text: message.text,
|
|
50
|
+
...(message.html === undefined ? {} : { html: message.html }),
|
|
51
|
+
idempotencyKey: message.idempotencyKey,
|
|
52
|
+
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
|
|
53
|
+
...(message.fromName === undefined ? {} : { fromName: message.fromName }),
|
|
54
|
+
...(message.unsubscribeUrl === undefined ? {} : { unsubscribeUrl: message.unsubscribeUrl }),
|
|
55
|
+
...(attachments.length === 0
|
|
56
|
+
? {}
|
|
57
|
+
: {
|
|
58
|
+
attachments: attachments.map((attachment) => ({
|
|
59
|
+
filename: attachment.filename,
|
|
60
|
+
contentType: attachment.contentType,
|
|
61
|
+
contentBase64: base64Of(attachment.content),
|
|
62
|
+
})),
|
|
63
|
+
}),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The request body as JSON text. It is canonical: two messages with the same content encode to the same text, so the
|
|
69
|
+
* relay fingerprints and stores this form.
|
|
70
|
+
*/
|
|
71
|
+
export function encodePlatformMailRequest(message: EmailMessage): string {
|
|
72
|
+
return JSON.stringify(platformMailRequestOf(message))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The SHA-256 (hex) of the canonical request body: what tells a repeated send from a different one under the same key. */
|
|
76
|
+
export async function platformMailFingerprint(message: EmailMessage): Promise<string> {
|
|
77
|
+
return hexOf(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(encodePlatformMailRequest(message))))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The SHA-256 (hex) of a transport key: the only form the receiver stores. */
|
|
81
|
+
export async function hashPlatformKey(key: string): Promise<string> {
|
|
82
|
+
return hexOf(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key)))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The key an installation service (IAM) sends to the relay with: derived from the installation secret both it and
|
|
87
|
+
* Control already hold, so an existing installation needs no new secret.
|
|
88
|
+
*/
|
|
89
|
+
export async function installationMailKey(installationSecret: string): Promise<string> {
|
|
90
|
+
const secret = await crypto.subtle.importKey('raw', new TextEncoder().encode(installationSecret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
|
91
|
+
return `px_${hexOf(await crypto.subtle.sign('HMAC', secret, new TextEncoder().encode('fabrika-platform-mail')))}`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function hexOf(digest: ArrayBuffer): string {
|
|
95
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const BASE64_ALPHABET = /^[A-Za-z0-9+/]*={0,2}$/
|
|
99
|
+
|
|
100
|
+
/** Standard padded base64. A single character class, because a grouped pattern overflows on a 10 MiB attachment. */
|
|
101
|
+
function isBase64(value: string): boolean {
|
|
102
|
+
return value.length % 4 === 0 && BASE64_ALPHABET.test(value)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const attachmentSchema = z.object({
|
|
106
|
+
filename: z.string(),
|
|
107
|
+
contentType: z.string(),
|
|
108
|
+
contentBase64: z.string().refine(isBase64),
|
|
109
|
+
}).strict()
|
|
110
|
+
|
|
111
|
+
// Strict: a field this release does not know is refused, never dropped from a message someone will receive.
|
|
112
|
+
const requestSchema = z.object({
|
|
113
|
+
to: z.string(),
|
|
114
|
+
subject: z.string(),
|
|
115
|
+
text: z.string(),
|
|
116
|
+
html: z.string().optional(),
|
|
117
|
+
idempotencyKey: z.string(),
|
|
118
|
+
replyTo: z.string().optional(),
|
|
119
|
+
fromName: z.string().optional(),
|
|
120
|
+
unsubscribeUrl: z.string().optional(),
|
|
121
|
+
attachments: z.array(attachmentSchema).optional(),
|
|
122
|
+
}).strict()
|
|
123
|
+
|
|
124
|
+
export type PlatformMailRequestResult =
|
|
125
|
+
| { readonly ok: true; readonly message: EmailMessage }
|
|
126
|
+
/** `reason` is a fixed text that names no recipient, subject or body. */
|
|
127
|
+
| { readonly ok: false; readonly code: 'invalid_message'; readonly reason: string }
|
|
128
|
+
|
|
129
|
+
/** The relay's one parser of a request body (parsed JSON): a message the package's own validation accepts, or a refusal. */
|
|
130
|
+
export function parsePlatformMailRequest(body: unknown): PlatformMailRequestResult {
|
|
131
|
+
const parsed = requestSchema.safeParse(body)
|
|
132
|
+
if (!parsed.success) return { ok: false, code: 'invalid_message', reason: 'Platform mail request is malformed' }
|
|
133
|
+
const { attachments, ...fields } = parsed.data
|
|
134
|
+
const message: EmailMessage = {
|
|
135
|
+
...fields,
|
|
136
|
+
...(attachments === undefined || attachments.length === 0
|
|
137
|
+
? {}
|
|
138
|
+
: {
|
|
139
|
+
attachments: attachments.map((attachment) => ({
|
|
140
|
+
filename: attachment.filename,
|
|
141
|
+
contentType: attachment.contentType,
|
|
142
|
+
content: bytesOf(attachment.contentBase64),
|
|
143
|
+
})),
|
|
144
|
+
}),
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
validateEmailMessage(message)
|
|
148
|
+
validateEmailAttachments(message, PLATFORM_MAIL_CAPABILITIES, LABEL)
|
|
149
|
+
} catch (cause) {
|
|
150
|
+
if (cause instanceof EmailDeliveryError) return { ok: false, code: 'invalid_message', reason: cause.message }
|
|
151
|
+
throw cause
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, message }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function bytesOf(base64: string): Uint8Array {
|
|
157
|
+
return Uint8Array.from(atob(base64), (character) => character.charCodeAt(0))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The HTTP status of each error the relay answers. */
|
|
161
|
+
export const PLATFORM_MAIL_ERROR_STATUS = {
|
|
162
|
+
invalid_message: 400,
|
|
163
|
+
provider_unauthorized: 401,
|
|
164
|
+
idempotency_conflict: 409,
|
|
165
|
+
// An installation sender's message when Control has no transport: refused at once, as `not_sent`.
|
|
166
|
+
mail_not_configured: 503,
|
|
167
|
+
provider_unavailable: 503,
|
|
168
|
+
} as const satisfies Partial<Record<EmailDeliveryErrorCode, number>>
|
|
169
|
+
|
|
170
|
+
export type PlatformMailErrorCode = keyof typeof PLATFORM_MAIL_ERROR_STATUS
|
|
171
|
+
|
|
172
|
+
export interface PlatformMailAccepted {
|
|
173
|
+
readonly status: 'accepted'
|
|
174
|
+
readonly messageId: string
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The relay's answer as a sender reads it: any `EmailDeliveryErrorCode`, so its `retryable` and `outcome` carry over. */
|
|
178
|
+
export type PlatformMailAnswer = PlatformMailAccepted | { readonly error: { readonly code: EmailDeliveryErrorCode } }
|
|
179
|
+
|
|
180
|
+
/** The relay's answer as it writes one: an acceptance, or an error that has a status. */
|
|
181
|
+
export type PlatformMailReply = PlatformMailAccepted | { readonly error: { readonly code: PlatformMailErrorCode } }
|
|
182
|
+
|
|
183
|
+
/** `200 { status: 'accepted', messageId }`, or the error's status with `{ error: { code } }`. */
|
|
184
|
+
export function platformMailAnswerResponse(reply: PlatformMailReply): Response {
|
|
185
|
+
if ('error' in reply) {
|
|
186
|
+
return Response.json({ error: { code: reply.error.code } }, { status: PLATFORM_MAIL_ERROR_STATUS[reply.error.code] })
|
|
187
|
+
}
|
|
188
|
+
return Response.json({ status: 'accepted', messageId: reply.messageId }, { status: 200 })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const errorCodeSchema = z.string().refine((code): code is EmailDeliveryErrorCode => Object.hasOwn(EMAIL_DELIVERY_ERRORS, code))
|
|
192
|
+
const acceptedSchema = z.object({ status: z.literal('accepted'), messageId: z.string().min(1) })
|
|
193
|
+
const errorSchema = z.object({ error: z.object({ code: errorCodeSchema }) })
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The relay's answer from its status and parsed JSON body, or `null` when it is no answer the relay writes: an
|
|
197
|
+
* acceptance under any status but 200, an error code under a success status, or anything else.
|
|
198
|
+
*/
|
|
199
|
+
export function parsePlatformMailAnswer(status: number, body: unknown): PlatformMailAnswer | null {
|
|
200
|
+
if (status === 200) {
|
|
201
|
+
const accepted = acceptedSchema.safeParse(body)
|
|
202
|
+
return accepted.success ? { status: 'accepted', messageId: accepted.data.messageId } : null
|
|
203
|
+
}
|
|
204
|
+
if (status < 400) return null
|
|
205
|
+
const refused = errorSchema.safeParse(body)
|
|
206
|
+
return refused.success ? { error: { code: refused.data.error.code } } : null
|
|
207
|
+
}
|
package/src/platform.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EmailDeliveryError,
|
|
3
|
+
type EmailDeliveryResult,
|
|
4
|
+
type EmailFetch,
|
|
5
|
+
type EmailMessage,
|
|
6
|
+
type EmailSender,
|
|
7
|
+
type EmailSenderCapabilities,
|
|
8
|
+
} from './email.js'
|
|
9
|
+
import { parseTimeout, postToProvider } from './http.js'
|
|
10
|
+
import { encodePlatformMailRequest, parsePlatformMailAnswer, PLATFORM_MAIL_CAPABILITIES, PLATFORM_MAIL_SEND_PATH } from './platform-wire.js'
|
|
11
|
+
import { isVisibleAscii, validateEmailAttachments, validateEmailMessage } from './validation.js'
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
encodePlatformMailRequest,
|
|
15
|
+
hashPlatformKey,
|
|
16
|
+
installationMailKey,
|
|
17
|
+
parsePlatformMailAnswer,
|
|
18
|
+
parsePlatformMailRequest,
|
|
19
|
+
PLATFORM_MAIL_CAPABILITIES,
|
|
20
|
+
PLATFORM_MAIL_ERROR_STATUS,
|
|
21
|
+
PLATFORM_MAIL_SEND_PATH,
|
|
22
|
+
platformMailAnswerResponse,
|
|
23
|
+
platformMailFingerprint,
|
|
24
|
+
} from './platform-wire.js'
|
|
25
|
+
export type {
|
|
26
|
+
PlatformMailAccepted,
|
|
27
|
+
PlatformMailAnswer,
|
|
28
|
+
PlatformMailErrorCode,
|
|
29
|
+
PlatformMailReply,
|
|
30
|
+
PlatformMailRequestResult,
|
|
31
|
+
} from './platform-wire.js'
|
|
32
|
+
|
|
33
|
+
/** `FABRIKA_EMAIL_PROVIDER=platform`: the application sends through the platform's relay, not a provider of its own. */
|
|
34
|
+
export const PLATFORM_EMAIL_PROVIDER = 'platform'
|
|
35
|
+
|
|
36
|
+
const LABEL = 'Platform mail'
|
|
37
|
+
|
|
38
|
+
export interface PlatformEmailSenderOptions {
|
|
39
|
+
/** The receiver's http(s) origin (`FABRIKA_PLATFORM_MAIL_URL`); the send path is appended to it. */
|
|
40
|
+
readonly url: string
|
|
41
|
+
readonly key: string
|
|
42
|
+
readonly fetch?: EmailFetch
|
|
43
|
+
readonly timeoutMs?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Hands each message to the platform's relay, which records it under its key and dispatches it later. */
|
|
47
|
+
export class PlatformEmailSender implements EmailSender {
|
|
48
|
+
readonly capabilities: EmailSenderCapabilities = PLATFORM_MAIL_CAPABILITIES
|
|
49
|
+
readonly #sendUrl: string
|
|
50
|
+
readonly #key: string
|
|
51
|
+
readonly #fetch: EmailFetch
|
|
52
|
+
readonly #timeoutMs: number
|
|
53
|
+
|
|
54
|
+
constructor(options: PlatformEmailSenderOptions) {
|
|
55
|
+
if (!isHttpOrigin(options.url)) {
|
|
56
|
+
throw new EmailDeliveryError('Platform mail URL is not an http(s) origin', { code: 'invalid_configuration' })
|
|
57
|
+
}
|
|
58
|
+
if (options.key.length === 0 || !isVisibleAscii(options.key)) {
|
|
59
|
+
throw new EmailDeliveryError('Platform mail key is invalid', { code: 'invalid_configuration' })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.#sendUrl = `${options.url}${PLATFORM_MAIL_SEND_PATH}`
|
|
63
|
+
this.#key = options.key
|
|
64
|
+
this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init))
|
|
65
|
+
this.#timeoutMs = parseTimeout(LABEL, options.timeoutMs)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
|
|
69
|
+
validateEmailMessage(message)
|
|
70
|
+
validateEmailAttachments(message, this.capabilities, LABEL)
|
|
71
|
+
|
|
72
|
+
// postToProvider serializes the body itself; the canonical text survives the round trip unchanged.
|
|
73
|
+
const body: unknown = JSON.parse(encodePlatformMailRequest(message))
|
|
74
|
+
const response = await postToProvider({
|
|
75
|
+
label: LABEL,
|
|
76
|
+
fetch: this.#fetch,
|
|
77
|
+
url: this.#sendUrl,
|
|
78
|
+
headers: { Authorization: `Bearer ${this.#key}` },
|
|
79
|
+
body,
|
|
80
|
+
signal,
|
|
81
|
+
timeoutMs: this.#timeoutMs,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const answer = parsePlatformMailAnswer(response.status, response.body)
|
|
85
|
+
if (answer === null) {
|
|
86
|
+
throw new EmailDeliveryError(`Platform mail relay returned an invalid answer (${response.status})`, {
|
|
87
|
+
code: 'malformed_response',
|
|
88
|
+
httpStatus: response.status,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
if ('error' in answer) {
|
|
92
|
+
throw new EmailDeliveryError(`Platform mail relay refused the message (${response.status})`, {
|
|
93
|
+
code: answer.error.code,
|
|
94
|
+
httpStatus: response.status,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
return { status: 'accepted', provider: 'platform', messageId: answer.messageId }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `http(s)://host[:port]` exactly as written: no path, query, fragment or credentials. */
|
|
102
|
+
function isHttpOrigin(value: string): boolean {
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(value)
|
|
105
|
+
return (url.protocol === 'http:' || url.protocol === 'https:') && url.origin === value
|
|
106
|
+
} catch {
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/resend.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { type EmailProvider, type EmailSenderIdentity, parseProviderVariables } from './config.js'
|
|
3
|
+
import { base64Of } from './content.js'
|
|
3
4
|
import {
|
|
4
5
|
EmailDeliveryError,
|
|
5
6
|
type EmailDeliveryResult,
|
|
@@ -7,9 +8,10 @@ import {
|
|
|
7
8
|
type EmailMessage,
|
|
8
9
|
type EmailSender,
|
|
9
10
|
type EmailSenderCapabilities,
|
|
11
|
+
oneClickUnsubscribeHeaders,
|
|
10
12
|
} from './email.js'
|
|
11
13
|
import { parseTimeout, postToProvider, type ProviderResponse } from './http.js'
|
|
12
|
-
import { isValidEmailSenderIdentity, isVisibleAscii, validateEmailMessage } from './validation.js'
|
|
14
|
+
import { isValidEmailSenderIdentity, isVisibleAscii, validateEmailAttachments, validateEmailMessage } from './validation.js'
|
|
13
15
|
|
|
14
16
|
const RESEND_EMAILS_URL = 'https://api.resend.com/emails'
|
|
15
17
|
const LABEL = 'Resend'
|
|
@@ -18,6 +20,8 @@ const resendErrorSchema = z.object({ name: z.string() })
|
|
|
18
20
|
const QUOTA_ERRORS: ReadonlySet<string> = new Set(['daily_quota_exceeded', 'monthly_quota_exceeded'])
|
|
19
21
|
/** RFC 5322 specials a display name may still hold after validation; any of them makes the name a quoted string. */
|
|
20
22
|
const DISPLAY_NAME_SPECIALS = /[()[\]:;@,.]/
|
|
23
|
+
/** Resend refuses a message over 40 MB once its attachments are base64; 25 MiB of bytes leaves room for the rest. */
|
|
24
|
+
const RESEND_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
|
21
25
|
|
|
22
26
|
export interface ResendEmailSenderOptions {
|
|
23
27
|
readonly apiKey: string
|
|
@@ -27,7 +31,13 @@ export interface ResendEmailSenderOptions {
|
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export class ResendEmailSender implements EmailSender {
|
|
30
|
-
readonly capabilities: EmailSenderCapabilities = {
|
|
34
|
+
readonly capabilities: EmailSenderCapabilities = {
|
|
35
|
+
idempotent: true,
|
|
36
|
+
confirmation: 'sync',
|
|
37
|
+
maxBatch: 1,
|
|
38
|
+
listUnsubscribe: true,
|
|
39
|
+
maxAttachmentBytes: RESEND_MAX_ATTACHMENT_BYTES,
|
|
40
|
+
}
|
|
31
41
|
readonly #apiKey: string
|
|
32
42
|
readonly #from: EmailSenderIdentity
|
|
33
43
|
readonly #fetch: EmailFetch
|
|
@@ -49,6 +59,8 @@ export class ResendEmailSender implements EmailSender {
|
|
|
49
59
|
|
|
50
60
|
async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
|
|
51
61
|
validateEmailMessage(message)
|
|
62
|
+
validateEmailAttachments(message, this.capabilities, LABEL)
|
|
63
|
+
const attachments = message.attachments ?? []
|
|
52
64
|
|
|
53
65
|
const response = await postToProvider({
|
|
54
66
|
label: LABEL,
|
|
@@ -62,6 +74,16 @@ export class ResendEmailSender implements EmailSender {
|
|
|
62
74
|
text: message.text,
|
|
63
75
|
...(message.html === undefined ? {} : { html: message.html }),
|
|
64
76
|
...(message.replyTo === undefined ? {} : { reply_to: message.replyTo }),
|
|
77
|
+
...(message.unsubscribeUrl === undefined ? {} : { headers: oneClickUnsubscribeHeaders(message.unsubscribeUrl) }),
|
|
78
|
+
...(attachments.length === 0
|
|
79
|
+
? {}
|
|
80
|
+
: {
|
|
81
|
+
attachments: attachments.map((attachment) => ({
|
|
82
|
+
filename: attachment.filename,
|
|
83
|
+
content: base64Of(attachment.content),
|
|
84
|
+
content_type: attachment.contentType,
|
|
85
|
+
})),
|
|
86
|
+
}),
|
|
65
87
|
},
|
|
66
88
|
signal,
|
|
67
89
|
timeoutMs: this.#timeoutMs,
|
package/src/smartemailing.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type EmailSenderCapabilities,
|
|
13
13
|
} from './email.js'
|
|
14
14
|
import { parseTimeout, postToProvider } from './http.js'
|
|
15
|
-
import { isValidEmailSenderIdentity, isVisibleAscii, validateEmailMessage } from './validation.js'
|
|
15
|
+
import { isValidEmailSenderIdentity, isVisibleAscii, validateEmailAttachments, validateEmailMessage } from './validation.js'
|
|
16
16
|
|
|
17
17
|
const SMARTEMAILING_SEND_URL = 'https://app.smartemailing.cz/api/v3/send/transactional-emails-bulk'
|
|
18
18
|
const LABEL = 'SmartEmailing'
|
|
@@ -64,6 +64,11 @@ export class SmartEmailingEmailSender implements EmailSender {
|
|
|
64
64
|
|
|
65
65
|
async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
|
|
66
66
|
validateEmailMessage(message)
|
|
67
|
+
// Its API documents no message headers, so a one-click unsubscribe is refused rather than sent without one.
|
|
68
|
+
if (message.unsubscribeUrl !== undefined) {
|
|
69
|
+
throw new EmailDeliveryError('SmartEmailing cannot write the List-Unsubscribe headers', { code: 'invalid_message' })
|
|
70
|
+
}
|
|
71
|
+
validateEmailAttachments(message, this.capabilities, LABEL)
|
|
67
72
|
|
|
68
73
|
const senderName = message.fromName ?? this.#from.name
|
|
69
74
|
const response = await postToProvider({
|
package/src/validation.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EmailSenderIdentity } from './config.js'
|
|
2
|
-
import { EmailDeliveryError, type EmailMessage } from './email.js'
|
|
2
|
+
import { attachmentBytes, EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities } from './email.js'
|
|
3
3
|
|
|
4
4
|
/** The longest subject a message may have, in UTF-16 code units: RFC 5322's line limit. */
|
|
5
5
|
export const MAX_EMAIL_SUBJECT_LENGTH = 998
|
|
@@ -63,6 +63,49 @@ export function validateEmailMessage(message: EmailMessage): void {
|
|
|
63
63
|
}
|
|
64
64
|
if (message.replyTo !== undefined && !isValidEmailAddress(message.replyTo)) throw invalidMessage('Email reply-to address is invalid')
|
|
65
65
|
if (message.fromName !== undefined && !isValidEmailDisplayName(message.fromName)) throw invalidMessage('Email sender name is invalid')
|
|
66
|
+
if (message.unsubscribeUrl !== undefined && !isUnsubscribeUrl(message.unsubscribeUrl)) throw invalidMessage('Email unsubscribe URL is invalid')
|
|
67
|
+
const attachments = message.attachments ?? []
|
|
68
|
+
if (attachments.length > MAX_EMAIL_ATTACHMENTS) throw invalidMessage(`Email has more than ${MAX_EMAIL_ATTACHMENTS} attachments`)
|
|
69
|
+
for (const attachment of attachments) {
|
|
70
|
+
if (!isValidAttachmentFilename(attachment.filename)) throw invalidMessage('Email attachment name is invalid')
|
|
71
|
+
if (!MEDIA_TYPE.test(attachment.contentType)) throw invalidMessage('Email attachment type is invalid')
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Refuses a message whose attachments `capabilities` does not carry: any attachment where the transport sends none, or
|
|
77
|
+
* more bytes together than its `maxAttachmentBytes`. `label` names the transport in the message.
|
|
78
|
+
*/
|
|
79
|
+
export function validateEmailAttachments(message: EmailMessage, capabilities: EmailSenderCapabilities, label: string): void {
|
|
80
|
+
if ((message.attachments ?? []).length === 0) return
|
|
81
|
+
const limit = capabilities.maxAttachmentBytes
|
|
82
|
+
if (limit === undefined) throw invalidMessage(`${label} sends no attachments`)
|
|
83
|
+
const bytes = attachmentBytes(message)
|
|
84
|
+
if (bytes > limit) throw invalidMessage(`Email attachments have ${bytes} bytes, more than the ${limit} ${label} sends`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The most files one message carries. */
|
|
88
|
+
export const MAX_EMAIL_ATTACHMENTS = 20
|
|
89
|
+
|
|
90
|
+
const MEDIA_TYPE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$/
|
|
91
|
+
|
|
92
|
+
/** 1..255 characters, no control characters, no path separator and no `"`, so every transport can quote it. */
|
|
93
|
+
export function isValidAttachmentFilename(value: string): boolean {
|
|
94
|
+
return value.length > 0 && value.length <= 255 && value.trim() === value && !containsControlCharacter(value) && !/[/\\"]/.test(value)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The longest unsubscribe URL a header carries: one header line, with room for its name. */
|
|
98
|
+
export const MAX_UNSUBSCRIBE_URL_LENGTH = 2048
|
|
99
|
+
|
|
100
|
+
/** An absolute `https:` URL as it is written, which a `<…>` header value carries unchanged. */
|
|
101
|
+
export function isUnsubscribeUrl(value: string): boolean {
|
|
102
|
+
if (value.length > MAX_UNSUBSCRIBE_URL_LENGTH || containsControlCharacter(value) || /[\s<>"]/.test(value)) return false
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(value)
|
|
105
|
+
return url.protocol === 'https:' && url.href === value && url.username === '' && url.password === ''
|
|
106
|
+
} catch {
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
66
109
|
}
|
|
67
110
|
|
|
68
111
|
function invalidMessage(message: string): EmailDeliveryError {
|