@fabrika/email 0.4.8 → 0.5.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 +21 -1
- package/src/config.ts +166 -0
- package/src/conformance.ts +414 -0
- package/src/content.ts +27 -0
- package/src/directory.ts +36 -15
- package/src/email.ts +50 -13
- package/src/http.ts +72 -0
- package/src/index.ts +28 -3
- package/src/memory.ts +174 -0
- package/src/providers.ts +9 -0
- package/src/resend.ts +80 -87
- package/src/smartemailing.ts +150 -0
- package/src/validation.ts +33 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/email",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -29,6 +29,26 @@
|
|
|
29
29
|
"bun": "./src/directory.ts",
|
|
30
30
|
"types": "./src/directory.ts",
|
|
31
31
|
"default": "./src/directory.ts"
|
|
32
|
+
},
|
|
33
|
+
"./memory": {
|
|
34
|
+
"bun": "./src/memory.ts",
|
|
35
|
+
"types": "./src/memory.ts",
|
|
36
|
+
"default": "./src/memory.ts"
|
|
37
|
+
},
|
|
38
|
+
"./smartemailing": {
|
|
39
|
+
"bun": "./src/smartemailing.ts",
|
|
40
|
+
"types": "./src/smartemailing.ts",
|
|
41
|
+
"default": "./src/smartemailing.ts"
|
|
42
|
+
},
|
|
43
|
+
"./providers": {
|
|
44
|
+
"bun": "./src/providers.ts",
|
|
45
|
+
"types": "./src/providers.ts",
|
|
46
|
+
"default": "./src/providers.ts"
|
|
47
|
+
},
|
|
48
|
+
"./conformance": {
|
|
49
|
+
"bun": "./src/conformance.ts",
|
|
50
|
+
"types": "./src/conformance.ts",
|
|
51
|
+
"default": "./src/conformance.ts"
|
|
32
52
|
}
|
|
33
53
|
},
|
|
34
54
|
"scripts": {
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// The installation's mail transport as environment variables: `FABRIKA_EMAIL_PROVIDER` picks a provider from the
|
|
2
|
+
// registry the composition hands in, and each provider declares and parses its own variables. Every refusal names
|
|
3
|
+
// variables and never a value, because most of them are credentials.
|
|
4
|
+
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
import { EmailDeliveryError, type EmailFetch, type EmailSender } from './email.js'
|
|
7
|
+
import { isValidEmailDisplayName, parseEmailFrom } from './validation.js'
|
|
8
|
+
|
|
9
|
+
export type EmailEnvironment = Readonly<Record<string, string | undefined>>
|
|
10
|
+
|
|
11
|
+
export interface EmailSenderIdentity {
|
|
12
|
+
readonly address: string
|
|
13
|
+
readonly name?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface EmailProviderVariable {
|
|
17
|
+
readonly name: string
|
|
18
|
+
readonly secret: boolean
|
|
19
|
+
readonly required: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface EmailProvider {
|
|
23
|
+
/** Provider-specific variables, beside FABRIKA_EMAIL_PROVIDER/FROM/FROM_NAME. */
|
|
24
|
+
readonly variables: readonly EmailProviderVariable[]
|
|
25
|
+
/** Parses its own variables; throws `invalid_configuration` naming the variable, never its value. */
|
|
26
|
+
create(input: { readonly from: EmailSenderIdentity; readonly env: EmailEnvironment; readonly fetch?: EmailFetch }): EmailSender
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type EmailProviderRegistry = Readonly<Record<string, EmailProvider>>
|
|
30
|
+
|
|
31
|
+
export type EmailTransportConfig =
|
|
32
|
+
| { readonly kind: 'none' }
|
|
33
|
+
| { readonly kind: 'provider'; readonly provider: string; readonly from: EmailSenderIdentity; readonly variables: readonly EmailProviderVariable[] }
|
|
34
|
+
|
|
35
|
+
/** The variable that selects the provider. */
|
|
36
|
+
export const EMAIL_PROVIDER_VARIABLE = 'FABRIKA_EMAIL_PROVIDER'
|
|
37
|
+
/** `FABRIKA_EMAIL_PROVIDER=none`: the installation states it has no transport, which an absent variable does not. */
|
|
38
|
+
export const NO_EMAIL_PROVIDER = 'none'
|
|
39
|
+
const FROM_VARIABLE = 'FABRIKA_EMAIL_FROM'
|
|
40
|
+
const FROM_NAME_VARIABLE = 'FABRIKA_EMAIL_FROM_NAME'
|
|
41
|
+
const EMAIL_VARIABLE_PREFIX = 'FABRIKA_EMAIL_'
|
|
42
|
+
const UNSET_SENDER: EmailSenderIdentity = { address: 'unset@example.invalid' }
|
|
43
|
+
|
|
44
|
+
const senderSchema = z.string().transform((value, context): EmailSenderIdentity => {
|
|
45
|
+
const identity = parseEmailFrom(value)
|
|
46
|
+
if (identity === null) {
|
|
47
|
+
context.addIssue({ code: z.ZodIssueCode.custom })
|
|
48
|
+
return z.NEVER
|
|
49
|
+
}
|
|
50
|
+
return identity
|
|
51
|
+
})
|
|
52
|
+
const displayNameSchema = z.string().refine(isValidEmailDisplayName)
|
|
53
|
+
|
|
54
|
+
interface Transport {
|
|
55
|
+
readonly config: EmailTransportConfig
|
|
56
|
+
readonly sender: EmailSender | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ConfigurationProblem {
|
|
60
|
+
/** The variable the problem names; empty when a provider's refusal names none. */
|
|
61
|
+
readonly variable: string
|
|
62
|
+
readonly text: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** An `invalid_configuration` that keeps its problems apart, so `readTransport` can merge a provider's into its own. */
|
|
66
|
+
class EmailConfigurationError extends EmailDeliveryError {
|
|
67
|
+
readonly problems: readonly ConfigurationProblem[]
|
|
68
|
+
|
|
69
|
+
constructor(problems: readonly ConfigurationProblem[]) {
|
|
70
|
+
super(`Email configuration is invalid: ${problems.map((problem) => problem.text).join('; ')}`, { code: 'invalid_configuration' })
|
|
71
|
+
this.problems = problems
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Throws one `invalid_configuration` listing every missing or invalid variable name. */
|
|
76
|
+
export function readEmailTransportConfig(env: EmailEnvironment, registry: EmailProviderRegistry): EmailTransportConfig {
|
|
77
|
+
return readTransport(env, registry, undefined).config
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** null for `none` or an absent provider. */
|
|
81
|
+
export function emailSenderFromEnv(
|
|
82
|
+
env: EmailEnvironment,
|
|
83
|
+
registry: EmailProviderRegistry,
|
|
84
|
+
options: { readonly fetch?: EmailFetch } = {},
|
|
85
|
+
): EmailSender | null {
|
|
86
|
+
return readTransport(env, registry, options.fetch).sender
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The non-empty string `FABRIKA_EMAIL_*` entries of any binding object (Worker bindings, process.env). */
|
|
90
|
+
export function pickEmailEnvironment(source: object): EmailEnvironment {
|
|
91
|
+
const entries: [string, unknown][] = Object.entries(source)
|
|
92
|
+
const picked: Record<string, string> = {}
|
|
93
|
+
for (const [name, value] of entries) {
|
|
94
|
+
if (name.startsWith(EMAIL_VARIABLE_PREFIX) && typeof value === 'string' && value !== '') picked[name] = value
|
|
95
|
+
}
|
|
96
|
+
return picked
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A provider's own variables through its schema. A failure is one `invalid_configuration` naming each offending
|
|
101
|
+
* variable and never its value, in the form `readEmailTransportConfig` merges into its own refusal.
|
|
102
|
+
*/
|
|
103
|
+
export function parseProviderVariables<Schema extends z.ZodTypeAny>(schema: Schema, env: EmailEnvironment): z.output<Schema> {
|
|
104
|
+
const parsed = schema.safeParse(env)
|
|
105
|
+
if (parsed.success) return parsed.data
|
|
106
|
+
const names = [...new Set(parsed.error.issues.map((issue) => String(issue.path[0] ?? '')))]
|
|
107
|
+
throw new EmailConfigurationError(names.map((name) => ({ variable: name, text: `${name || 'a variable'} is missing or invalid` })))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readTransport(env: EmailEnvironment, registry: EmailProviderRegistry, fetch: EmailFetch | undefined): Transport {
|
|
111
|
+
const providerName = presentValue(env[EMAIL_PROVIDER_VARIABLE])
|
|
112
|
+
if (providerName === undefined || providerName === NO_EMAIL_PROVIDER) return { config: { kind: 'none' }, sender: null }
|
|
113
|
+
const provider = Object.hasOwn(registry, providerName) ? registry[providerName] : undefined
|
|
114
|
+
if (provider === undefined) {
|
|
115
|
+
throw new EmailDeliveryError(`${EMAIL_PROVIDER_VARIABLE} must be none or one of: ${Object.keys(registry).join(', ')}`, {
|
|
116
|
+
code: 'invalid_configuration',
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const problems: ConfigurationProblem[] = []
|
|
121
|
+
const problem = (variable: string, text: string): void => {
|
|
122
|
+
problems.push({ variable, text: `${variable} ${text}` })
|
|
123
|
+
}
|
|
124
|
+
const fromValue = presentValue(env[FROM_VARIABLE])
|
|
125
|
+
const parsedFrom = fromValue === undefined ? undefined : senderSchema.safeParse(fromValue).data
|
|
126
|
+
if (fromValue === undefined) problem(FROM_VARIABLE, 'is missing')
|
|
127
|
+
else if (parsedFrom === undefined) problem(FROM_VARIABLE, 'is invalid')
|
|
128
|
+
const fromName = presentValue(env[FROM_NAME_VARIABLE])
|
|
129
|
+
if (fromName !== undefined && !displayNameSchema.safeParse(fromName).success) problem(FROM_NAME_VARIABLE, 'is invalid')
|
|
130
|
+
else if (fromName !== undefined && parsedFrom?.name !== undefined) {
|
|
131
|
+
problem(FROM_NAME_VARIABLE, `is refused because ${FROM_VARIABLE} already carries a display name`)
|
|
132
|
+
}
|
|
133
|
+
for (const variable of provider.variables) {
|
|
134
|
+
if (variable.required && presentValue(env[variable.name]) === undefined) problem(variable.name, 'is missing')
|
|
135
|
+
}
|
|
136
|
+
const from = parsedFrom === undefined || fromName === undefined || parsedFrom.name !== undefined
|
|
137
|
+
? parsedFrom
|
|
138
|
+
: { address: parsedFrom.address, name: fromName }
|
|
139
|
+
|
|
140
|
+
// The provider checks its own values; building it does no I/O. Without a usable sender a stand-in identity still
|
|
141
|
+
// lets it report its variables, and that sender is never returned.
|
|
142
|
+
let sender: EmailSender | null = null
|
|
143
|
+
try {
|
|
144
|
+
sender = provider.create({ from: from ?? UNSET_SENDER, env, fetch })
|
|
145
|
+
} catch (error) {
|
|
146
|
+
problems.push(...providerProblems(error, problems))
|
|
147
|
+
}
|
|
148
|
+
if (from === undefined || sender === null || problems.length > 0) throw new EmailConfigurationError(problems)
|
|
149
|
+
|
|
150
|
+
return { config: { kind: 'provider', provider: providerName, from, variables: provider.variables }, sender }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A provider's refusal as problems, without those for a variable already reported. */
|
|
154
|
+
function providerProblems(error: unknown, reported: readonly ConfigurationProblem[]): ConfigurationProblem[] {
|
|
155
|
+
if (error instanceof EmailConfigurationError) {
|
|
156
|
+
return error.problems.filter((candidate) => !reported.some((problem) => problem.variable !== '' && problem.variable === candidate.variable))
|
|
157
|
+
}
|
|
158
|
+
// An error message is a fixed string by the `EmailDeliveryError` contract, so it may join the refusal.
|
|
159
|
+
if (error instanceof EmailDeliveryError && error.code === 'invalid_configuration') return [{ variable: '', text: error.message }]
|
|
160
|
+
throw error
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Deploy tooling (GitHub Actions among it) passes an unset variable as an empty string, so empty means absent. */
|
|
164
|
+
function presentValue(value: string | undefined): string | undefined {
|
|
165
|
+
return value === undefined || value === '' ? undefined : value
|
|
166
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
// The cases every `EmailSender` passes, built-in or not. An adapter's own test file calls
|
|
2
|
+
// `describeEmailSenderConformance` once with a harness: an HTTP adapter gets a stubbed fetch and says how to read its
|
|
3
|
+
// request and how its provider answers; a local sender says where it stores what it took.
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
6
|
+
import {
|
|
7
|
+
EMAIL_DELIVERY_ERRORS,
|
|
8
|
+
EmailDeliveryError,
|
|
9
|
+
type EmailDeliveryErrorCode,
|
|
10
|
+
type EmailFetch,
|
|
11
|
+
type EmailMessage,
|
|
12
|
+
type EmailSender,
|
|
13
|
+
} from './email.js'
|
|
14
|
+
|
|
15
|
+
/** What the provider received, or what a local sender stored, in the message's own terms. */
|
|
16
|
+
export interface WireMessage {
|
|
17
|
+
readonly to: string
|
|
18
|
+
readonly subject: string
|
|
19
|
+
readonly text: string
|
|
20
|
+
readonly html?: string
|
|
21
|
+
readonly replyTo?: string
|
|
22
|
+
readonly fromName?: string
|
|
23
|
+
readonly idempotencyKey?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** How the provider answers. `inProgress` and `idempotencyConflict` are required when the sender is idempotent. */
|
|
27
|
+
export interface ProviderAnswers {
|
|
28
|
+
accepted(request: Request): Response
|
|
29
|
+
malformedSuccess(): Response
|
|
30
|
+
unauthorized(): Response
|
|
31
|
+
rejected(): Response
|
|
32
|
+
rateLimited(): Response
|
|
33
|
+
unavailable(): Response
|
|
34
|
+
quotaExceeded?(): Response
|
|
35
|
+
inProgress?(): Response
|
|
36
|
+
idempotencyConflict?(): Response
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface EmailSenderHttpHarness {
|
|
40
|
+
readonly kind: 'http'
|
|
41
|
+
/** Credentials the sender holds; none may appear in an error or a log line. */
|
|
42
|
+
readonly secrets: readonly string[]
|
|
43
|
+
create(fetch: EmailFetch, options: { readonly timeoutMs: number }): EmailSender
|
|
44
|
+
readRequest(request: Request): Promise<WireMessage>
|
|
45
|
+
readonly answers: ProviderAnswers
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A local sender's I/O cannot be held open from outside, so the abort-during-I/O cases run for HTTP senders only. */
|
|
49
|
+
export interface EmailSenderLocalHarness {
|
|
50
|
+
readonly kind: 'local'
|
|
51
|
+
/** Called before each case. */
|
|
52
|
+
create(): Promise<EmailSender>
|
|
53
|
+
/** What the sender created by the last `create` stored, oldest first. */
|
|
54
|
+
stored(): Promise<readonly WireMessage[]>
|
|
55
|
+
/** Called after each case. */
|
|
56
|
+
cleanup?(): Promise<void>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type EmailSenderConformanceHarness = EmailSenderHttpHarness | EmailSenderLocalHarness
|
|
60
|
+
|
|
61
|
+
const TIMEOUT_MS = 50
|
|
62
|
+
const SUBJECT = 'Conformance subject 5d1c'
|
|
63
|
+
const TEXT = 'Conformance text body 8e2f'
|
|
64
|
+
const HTML = '<p>Conformance html body 3a7b</p>'
|
|
65
|
+
const MESSAGE_MARKERS = [SUBJECT, TEXT, HTML]
|
|
66
|
+
/** Shorter strings in a response body are field names and statuses, which an error message may share by chance. */
|
|
67
|
+
const MIN_BODY_MARKER_LENGTH = 8
|
|
68
|
+
|
|
69
|
+
const FULL_MESSAGE: EmailMessage = {
|
|
70
|
+
to: 'recipient@example.test',
|
|
71
|
+
subject: SUBJECT,
|
|
72
|
+
text: TEXT,
|
|
73
|
+
html: HTML,
|
|
74
|
+
idempotencyKey: 'conformance/full-1',
|
|
75
|
+
replyTo: 'replies@example.test',
|
|
76
|
+
fromName: 'Conformance Sender',
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const MINIMAL_MESSAGE: EmailMessage = {
|
|
80
|
+
to: 'recipient@example.test',
|
|
81
|
+
subject: SUBJECT,
|
|
82
|
+
text: TEXT,
|
|
83
|
+
idempotencyKey: 'conformance/minimal-1',
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const INVALID_MESSAGES: readonly { readonly problem: string; readonly message: EmailMessage }[] = [
|
|
87
|
+
{ problem: 'a bad recipient', message: { ...MINIMAL_MESSAGE, to: 'not-an-address' } },
|
|
88
|
+
{ problem: 'an empty subject', message: { ...MINIMAL_MESSAGE, subject: '' } },
|
|
89
|
+
{ problem: 'a subject with CRLF', message: { ...MINIMAL_MESSAGE, subject: 'Bad\r\nsubject' } },
|
|
90
|
+
{ problem: 'a blank text', message: { ...MINIMAL_MESSAGE, text: ' ' } },
|
|
91
|
+
{ problem: 'an empty key', message: { ...MINIMAL_MESSAGE, idempotencyKey: '' } },
|
|
92
|
+
{ problem: 'a key with a space', message: { ...MINIMAL_MESSAGE, idempotencyKey: 'has space' } },
|
|
93
|
+
{ problem: 'a key over 256 characters', message: { ...MINIMAL_MESSAGE, idempotencyKey: 'x'.repeat(257) } },
|
|
94
|
+
{ problem: 'a bad reply-to', message: { ...MINIMAL_MESSAGE, replyTo: 'not-an-address' } },
|
|
95
|
+
{ problem: 'a sender name with brackets', message: { ...MINIMAL_MESSAGE, fromName: 'Bad <name>' } },
|
|
96
|
+
{ problem: 'a sender name over 128 characters', message: { ...MINIMAL_MESSAGE, fromName: 'x'.repeat(129) } },
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Registers the conformance cases for one sender. HTTP harnesses also get the provider answer table, the thrown and
|
|
101
|
+
* silent fetch, and an abort or a timeout while the request or the answer body is in flight; a local harness gets
|
|
102
|
+
* only the already-aborted case, because nothing can hold its I/O open.
|
|
103
|
+
*/
|
|
104
|
+
export function describeEmailSenderConformance(name: string, harness: EmailSenderConformanceHarness): void {
|
|
105
|
+
describe(`${name} conforms to EmailSender`, () => {
|
|
106
|
+
if (harness.kind === 'http') describeHttpSender(harness)
|
|
107
|
+
else describeLocalSender(harness)
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type Handler = (request: Request, signal: AbortSignal | undefined) => Promise<Response>
|
|
112
|
+
|
|
113
|
+
interface HttpCase {
|
|
114
|
+
readonly sender: EmailSender
|
|
115
|
+
readonly requests: Request[]
|
|
116
|
+
/** Every string of length ≥ MIN_BODY_MARKER_LENGTH in the bodies the provider answered with. */
|
|
117
|
+
readonly bodyMarkers: string[]
|
|
118
|
+
/** The status of each answer, in order. */
|
|
119
|
+
readonly statuses: number[]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function describeHttpSender(harness: EmailSenderHttpHarness): void {
|
|
123
|
+
/** Read after the call settles, when the answer bodies are known. */
|
|
124
|
+
const forbidden = (current: HttpCase) => (): string[] => [...MESSAGE_MARKERS, ...harness.secrets, ...current.bodyMarkers]
|
|
125
|
+
|
|
126
|
+
/** `stalledBody`: the handler answers a body that never ends, which the stub must not read itself. */
|
|
127
|
+
function httpCase(handler: Handler, options: { readonly stalledBody?: boolean } = {}): HttpCase {
|
|
128
|
+
const requests: Request[] = []
|
|
129
|
+
const bodyMarkers: string[] = []
|
|
130
|
+
const statuses: number[] = []
|
|
131
|
+
const fetch: EmailFetch = async (input, init) => {
|
|
132
|
+
const request = new Request(input, init)
|
|
133
|
+
requests.push(request.clone())
|
|
134
|
+
const response = await handler(request, init?.signal ?? undefined)
|
|
135
|
+
statuses.push(response.status)
|
|
136
|
+
if (options.stalledBody !== true) bodyMarkers.push(...markersOf(await response.clone().text()))
|
|
137
|
+
return response
|
|
138
|
+
}
|
|
139
|
+
return { sender: harness.create(fetch, { timeoutMs: TIMEOUT_MS }), requests, bodyMarkers, statuses }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const answering = (answer: () => Response): HttpCase => httpCase(async () => answer())
|
|
143
|
+
|
|
144
|
+
test('reports its capabilities', () => {
|
|
145
|
+
expectCapabilities(answering(() => harness.answers.malformedSuccess()).sender)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('answers an accepted result', async () => {
|
|
149
|
+
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
150
|
+
expectAccepted(await current.sender.send(FULL_MESSAGE, new AbortController().signal))
|
|
151
|
+
expect(current.requests).toHaveLength(1)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('hands every field of the message to the provider', async () => {
|
|
155
|
+
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
156
|
+
await current.sender.send(FULL_MESSAGE, new AbortController().signal)
|
|
157
|
+
const wire = await harness.readRequest(only(current.requests))
|
|
158
|
+
expect(wire).toMatchObject(contentOf(FULL_MESSAGE))
|
|
159
|
+
if (current.sender.capabilities.idempotent) expect(wire.idempotencyKey).toBe(FULL_MESSAGE.idempotencyKey)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('leaves html, reply-to and sender name out when the message has none', async () => {
|
|
163
|
+
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
164
|
+
await current.sender.send(MINIMAL_MESSAGE, new AbortController().signal)
|
|
165
|
+
expectOptionalFieldsAbsent(await harness.readRequest(only(current.requests)))
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test.each(INVALID_MESSAGES)('refuses $problem as invalid_message without a request', async ({ message }) => {
|
|
169
|
+
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
170
|
+
await expectRejection(() => current.sender.send(message, new AbortController().signal), 'invalid_message', forbidden(current))
|
|
171
|
+
expect(current.requests).toHaveLength(0)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('an already aborted signal answers aborted without a request', async () => {
|
|
175
|
+
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
176
|
+
await expectRejection(() => current.sender.send(FULL_MESSAGE, AbortSignal.abort()), 'aborted', forbidden(current))
|
|
177
|
+
expect(current.requests).toHaveLength(0)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('an abort during the request answers aborted with an unknown outcome', async () => {
|
|
181
|
+
const controller = new AbortController()
|
|
182
|
+
const current = httpCase((_request, signal) => {
|
|
183
|
+
if (signal === undefined) return Promise.reject(new Error('the request carries no signal'))
|
|
184
|
+
const pending = new Promise<Response>((_resolve, reject) => {
|
|
185
|
+
signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
|
|
186
|
+
})
|
|
187
|
+
controller.abort()
|
|
188
|
+
return pending
|
|
189
|
+
})
|
|
190
|
+
const error = await expectRejection(() => current.sender.send(FULL_MESSAGE, controller.signal), 'aborted', forbidden(current))
|
|
191
|
+
expect(error.outcome).toBe('unknown')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('every provider answer maps to its code and keeps the HTTP status', async () => {
|
|
195
|
+
const probe = answering(() => harness.answers.malformedSuccess()).sender
|
|
196
|
+
const answers = harness.answers
|
|
197
|
+
const table: [string, (() => Response) | undefined, EmailDeliveryErrorCode][] = [
|
|
198
|
+
['unauthorized', () => answers.unauthorized(), 'provider_unauthorized'],
|
|
199
|
+
['rejected', () => answers.rejected(), 'provider_rejected'],
|
|
200
|
+
['rateLimited', () => answers.rateLimited(), 'rate_limited'],
|
|
201
|
+
['unavailable', () => answers.unavailable(), 'provider_unavailable'],
|
|
202
|
+
['malformedSuccess', () => answers.malformedSuccess(), 'malformed_response'],
|
|
203
|
+
['quotaExceeded', answers.quotaExceeded?.bind(answers), 'quota_exceeded'],
|
|
204
|
+
['inProgress', answers.inProgress?.bind(answers), 'request_in_progress'],
|
|
205
|
+
['idempotencyConflict', answers.idempotencyConflict?.bind(answers), 'idempotency_conflict'],
|
|
206
|
+
]
|
|
207
|
+
for (const [answerName, answer, code] of table) {
|
|
208
|
+
if (answer === undefined) {
|
|
209
|
+
const requiredByIdempotency = code === 'request_in_progress' || code === 'idempotency_conflict'
|
|
210
|
+
if (requiredByIdempotency && probe.capabilities.idempotent) throw new Error(`An idempotent sender's harness must supply ${answerName}`)
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
const current = answering(answer)
|
|
214
|
+
const error = await expectRejection(() => current.sender.send(FULL_MESSAGE, new AbortController().signal), code, forbidden(current))
|
|
215
|
+
expect(error.httpStatus).toBe(only(current.statuses))
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('a fetch that throws answers network_error', async () => {
|
|
220
|
+
const current = httpCase(() => Promise.reject(new Error(`socket closed ${harness.secrets.join(' ')} ${TEXT}`)))
|
|
221
|
+
await expectRejection(() => current.sender.send(FULL_MESSAGE, new AbortController().signal), 'network_error', forbidden(current))
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('a provider that never answers times out at the configured timeout', async () => {
|
|
225
|
+
const current = httpCase((_request, signal) =>
|
|
226
|
+
new Promise<Response>((_resolve, reject) => {
|
|
227
|
+
signal?.addEventListener('abort', () => reject(new Error('timed out')), { once: true })
|
|
228
|
+
})
|
|
229
|
+
)
|
|
230
|
+
const started = Date.now()
|
|
231
|
+
await expectRejection(() => current.sender.send(FULL_MESSAGE, new AbortController().signal), 'request_timeout', forbidden(current))
|
|
232
|
+
expect(Date.now() - started).toBeGreaterThanOrEqual(TIMEOUT_MS - 5)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('an abort while the answer body arrives answers aborted', async () => {
|
|
236
|
+
const controller = new AbortController()
|
|
237
|
+
const current = httpCase(async () => {
|
|
238
|
+
setTimeout(() => controller.abort(), 5)
|
|
239
|
+
return stalledAnswer()
|
|
240
|
+
}, { stalledBody: true })
|
|
241
|
+
const error = await expectRejection(() => current.sender.send(FULL_MESSAGE, controller.signal), 'aborted', forbidden(current))
|
|
242
|
+
expect(error.outcome).toBe('unknown')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('an answer body that never finishes times out', async () => {
|
|
246
|
+
const current = httpCase(async () => stalledAnswer(), { stalledBody: true })
|
|
247
|
+
await expectRejection(() => current.sender.send(FULL_MESSAGE, new AbortController().signal), 'request_timeout', forbidden(current))
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** A 200 whose body never ends. */
|
|
252
|
+
function stalledAnswer(): Response {
|
|
253
|
+
return new Response(new ReadableStream({ start() {} }), { status: 200 })
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function describeLocalSender(harness: EmailSenderLocalHarness): void {
|
|
257
|
+
let sender: EmailSender | null = null
|
|
258
|
+
const current = (): EmailSender => {
|
|
259
|
+
if (sender === null) throw new Error('the harness created no sender')
|
|
260
|
+
return sender
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
beforeEach(async () => {
|
|
264
|
+
sender = await harness.create()
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
afterEach(async () => {
|
|
268
|
+
sender = null
|
|
269
|
+
await harness.cleanup?.()
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('reports its capabilities', () => {
|
|
273
|
+
expectCapabilities(current())
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test('answers an accepted result', async () => {
|
|
277
|
+
expectAccepted(await current().send(FULL_MESSAGE, new AbortController().signal))
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('stores every field of the message', async () => {
|
|
281
|
+
await current().send(FULL_MESSAGE, new AbortController().signal)
|
|
282
|
+
expect(only(await harness.stored())).toMatchObject(contentOf(FULL_MESSAGE))
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('leaves html, reply-to and sender name out when the message has none', async () => {
|
|
286
|
+
await current().send(MINIMAL_MESSAGE, new AbortController().signal)
|
|
287
|
+
expectOptionalFieldsAbsent(only(await harness.stored()))
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test.each(INVALID_MESSAGES)('refuses $problem as invalid_message and stores nothing', async ({ message }) => {
|
|
291
|
+
await expectRejection(() => current().send(message, new AbortController().signal), 'invalid_message', () => MESSAGE_MARKERS)
|
|
292
|
+
expect(await harness.stored()).toHaveLength(0)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test('an already aborted signal answers aborted and stores nothing', async () => {
|
|
296
|
+
await expectRejection(() => current().send(FULL_MESSAGE, AbortSignal.abort()), 'aborted', () => MESSAGE_MARKERS)
|
|
297
|
+
expect(await harness.stored()).toHaveLength(0)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('an idempotent sender stores a repeated message once and refuses a different one under the same key', async () => {
|
|
301
|
+
const target = current()
|
|
302
|
+
if (!target.capabilities.idempotent) return
|
|
303
|
+
const first = await target.send(FULL_MESSAGE, new AbortController().signal)
|
|
304
|
+
const repeated = await target.send(FULL_MESSAGE, new AbortController().signal)
|
|
305
|
+
expect(repeated.messageId).toBe(first.messageId)
|
|
306
|
+
expect(await harness.stored()).toHaveLength(1)
|
|
307
|
+
await expectRejection(
|
|
308
|
+
() => target.send({ ...FULL_MESSAGE, text: `${TEXT} changed` }, new AbortController().signal),
|
|
309
|
+
'idempotency_conflict',
|
|
310
|
+
() => MESSAGE_MARKERS,
|
|
311
|
+
)
|
|
312
|
+
expect(await harness.stored()).toHaveLength(1)
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function expectCapabilities(sender: EmailSender): void {
|
|
317
|
+
const { idempotent, confirmation, maxBatch } = sender.capabilities
|
|
318
|
+
expect(typeof idempotent).toBe('boolean')
|
|
319
|
+
expect(['sync', 'queued']).toContain(confirmation)
|
|
320
|
+
expect(Number.isSafeInteger(maxBatch) && maxBatch > 0).toBe(true)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function expectAccepted(result: Awaited<ReturnType<EmailSender['send']>>): void {
|
|
324
|
+
expect(result.status).toBe('accepted')
|
|
325
|
+
expect(result.provider).not.toBe('')
|
|
326
|
+
expect(result.messageId).not.toBe('')
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function expectOptionalFieldsAbsent(wire: WireMessage): void {
|
|
330
|
+
expect(wire.html).toBeUndefined()
|
|
331
|
+
expect(wire.replyTo).toBeUndefined()
|
|
332
|
+
expect(wire.fromName).toBeUndefined()
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function contentOf(message: EmailMessage): WireMessage {
|
|
336
|
+
return {
|
|
337
|
+
to: message.to,
|
|
338
|
+
subject: message.subject,
|
|
339
|
+
text: message.text,
|
|
340
|
+
...(message.html === undefined ? {} : { html: message.html }),
|
|
341
|
+
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
|
|
342
|
+
...(message.fromName === undefined ? {} : { fromName: message.fromName }),
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The rejection, checked against the classification table and for anything it must never carry. */
|
|
347
|
+
async function expectRejection(
|
|
348
|
+
run: () => Promise<unknown>,
|
|
349
|
+
code: EmailDeliveryErrorCode,
|
|
350
|
+
forbidden: () => readonly string[],
|
|
351
|
+
): Promise<EmailDeliveryError> {
|
|
352
|
+
const logged: string[] = []
|
|
353
|
+
const restore = captureConsole(logged)
|
|
354
|
+
let reason: unknown
|
|
355
|
+
try {
|
|
356
|
+
reason = await run().then(() => undefined, (error: unknown) => error)
|
|
357
|
+
} finally {
|
|
358
|
+
restore()
|
|
359
|
+
}
|
|
360
|
+
if (!(reason instanceof EmailDeliveryError)) throw new Error(`expected an EmailDeliveryError with code ${code}`)
|
|
361
|
+
expect(reason.code).toBe(code)
|
|
362
|
+
expect({ retryable: reason.retryable, outcome: reason.outcome }).toEqual(EMAIL_DELIVERY_ERRORS[code])
|
|
363
|
+
const surfaces = [reason.message, String(reason), JSON.stringify(reason), ...logged]
|
|
364
|
+
for (const value of forbidden()) {
|
|
365
|
+
for (const surface of surfaces) expect(surface).not.toContain(value)
|
|
366
|
+
}
|
|
367
|
+
return reason
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function captureConsole(logged: string[]): () => void {
|
|
371
|
+
const originals = { log: console.log, info: console.info, warn: console.warn, error: console.error, debug: console.debug }
|
|
372
|
+
const record = (...args: unknown[]): void => {
|
|
373
|
+
logged.push(args.map((arg) => (arg instanceof Error ? `${arg.message} ${arg.stack ?? ''}` : safeString(arg))).join(' '))
|
|
374
|
+
}
|
|
375
|
+
console.log = record
|
|
376
|
+
console.info = record
|
|
377
|
+
console.warn = record
|
|
378
|
+
console.error = record
|
|
379
|
+
console.debug = record
|
|
380
|
+
return () => {
|
|
381
|
+
Object.assign(console, originals)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function safeString(value: unknown): string {
|
|
386
|
+
if (typeof value === 'string') return value
|
|
387
|
+
try {
|
|
388
|
+
return JSON.stringify(value) ?? String(value)
|
|
389
|
+
} catch {
|
|
390
|
+
return String(value)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function markersOf(body: string): string[] {
|
|
395
|
+
const markers: string[] = []
|
|
396
|
+
const collect = (value: unknown): void => {
|
|
397
|
+
if (typeof value === 'string' && value.length >= MIN_BODY_MARKER_LENGTH) markers.push(value)
|
|
398
|
+
else if (Array.isArray(value)) value.forEach(collect)
|
|
399
|
+
else if (typeof value === 'object' && value !== null) Object.values(value).forEach(collect)
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
collect(JSON.parse(body))
|
|
403
|
+
} catch {
|
|
404
|
+
collect(body)
|
|
405
|
+
}
|
|
406
|
+
return markers
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function only<T>(items: readonly T[]): T {
|
|
410
|
+
expect(items).toHaveLength(1)
|
|
411
|
+
const item = items[0]
|
|
412
|
+
if (item === undefined) throw new Error('expected exactly one item')
|
|
413
|
+
return item
|
|
414
|
+
}
|
package/src/content.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { EmailMessage } from './email.js'
|
|
2
|
+
|
|
3
|
+
/** What a local sender compares when one idempotency key arrives twice: everything the recipient would see. */
|
|
4
|
+
export interface EmailContent {
|
|
5
|
+
readonly to: string
|
|
6
|
+
readonly subject: string
|
|
7
|
+
readonly text: string
|
|
8
|
+
readonly html?: string
|
|
9
|
+
readonly replyTo?: string
|
|
10
|
+
readonly fromName?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function sameEmailContent(left: EmailContent, right: EmailContent): boolean {
|
|
14
|
+
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
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function emailContentOf(message: EmailMessage): EmailContent {
|
|
19
|
+
return {
|
|
20
|
+
to: message.to,
|
|
21
|
+
subject: message.subject,
|
|
22
|
+
text: message.text,
|
|
23
|
+
...(message.html === undefined ? {} : { html: message.html }),
|
|
24
|
+
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
|
|
25
|
+
...(message.fromName === undefined ? {} : { fromName: message.fromName }),
|
|
26
|
+
}
|
|
27
|
+
}
|