@fabrika/email 0.4.8 → 0.5.1
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/src/directory.ts
CHANGED
|
@@ -6,15 +6,20 @@ import { createHash, randomUUID } from 'node:crypto'
|
|
|
6
6
|
import { link, readdir, readFile, unlink, writeFile } from 'node:fs/promises'
|
|
7
7
|
import { isAbsolute, join } from 'node:path'
|
|
8
8
|
import { z } from 'zod'
|
|
9
|
-
import {
|
|
9
|
+
import { type EmailProvider, parseProviderVariables } from './config.js'
|
|
10
|
+
import { emailContentOf, sameEmailContent } from './content.js'
|
|
11
|
+
import { EmailDeliveryError, type EmailDeliveryResult, type EmailMessage, type EmailSender, type EmailSenderCapabilities } from './email.js'
|
|
10
12
|
import { validateEmailMessage } from './validation.js'
|
|
11
13
|
|
|
12
14
|
const capturedEmailSchema = z.object({
|
|
13
15
|
messageId: z.string().min(1),
|
|
16
|
+
idempotencyKey: z.string(),
|
|
14
17
|
to: z.string(),
|
|
15
18
|
subject: z.string(),
|
|
16
19
|
text: z.string(),
|
|
17
20
|
html: z.string().optional(),
|
|
21
|
+
replyTo: z.string().optional(),
|
|
22
|
+
fromName: z.string().optional(),
|
|
18
23
|
capturedAt: z.number().int(),
|
|
19
24
|
})
|
|
20
25
|
|
|
@@ -27,52 +32,68 @@ export interface DirectoryEmailSenderOptions {
|
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
export class DirectoryEmailSender implements EmailSender {
|
|
35
|
+
readonly capabilities: EmailSenderCapabilities = { idempotent: true, confirmation: 'sync', maxBatch: 1 }
|
|
30
36
|
readonly #directory: string
|
|
31
37
|
readonly #now: () => number
|
|
32
38
|
|
|
33
39
|
constructor(options: DirectoryEmailSenderOptions) {
|
|
34
40
|
if (!isAbsolute(options.directory)) {
|
|
35
|
-
throw new EmailDeliveryError('The mail capture directory must be an absolute path', { code: 'invalid_configuration'
|
|
41
|
+
throw new EmailDeliveryError('The mail capture directory must be an absolute path', { code: 'invalid_configuration' })
|
|
36
42
|
}
|
|
37
43
|
this.#directory = options.directory
|
|
38
44
|
this.#now = options.now ?? Date.now
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
/** One file per idempotency key: a repeated send
|
|
42
|
-
async send(message: EmailMessage): Promise<EmailDeliveryResult> {
|
|
47
|
+
/** One file per idempotency key: a repeated send of the same message answers the first id, as a provider does. */
|
|
48
|
+
async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
|
|
43
49
|
validateEmailMessage(message)
|
|
50
|
+
if (signal.aborted) throw new EmailDeliveryError('The mail capture was aborted by the caller', { code: 'aborted' })
|
|
44
51
|
const messageId = createHash('sha256').update(message.idempotencyKey).digest('hex')
|
|
45
|
-
const captured: CapturedEmail = {
|
|
46
|
-
|
|
47
|
-
to: message.to,
|
|
48
|
-
subject: message.subject,
|
|
49
|
-
text: message.text,
|
|
50
|
-
...(message.html === undefined ? {} : { html: message.html }),
|
|
51
|
-
capturedAt: this.#now(),
|
|
52
|
-
}
|
|
52
|
+
const captured: CapturedEmail = { messageId, idempotencyKey: message.idempotencyKey, ...emailContentOf(message), capturedAt: this.#now() }
|
|
53
|
+
const path = join(this.#directory, `${messageId}.json`)
|
|
53
54
|
// Written aside and linked in, so a reader never sees a partial file and an existing one is never replaced.
|
|
54
55
|
const pending = join(this.#directory, `.${messageId}.${randomUUID()}.tmp`)
|
|
56
|
+
let existing: CapturedEmail | null
|
|
55
57
|
try {
|
|
56
58
|
await writeFile(pending, JSON.stringify(captured), { flag: 'wx' })
|
|
57
|
-
await link(pending,
|
|
59
|
+
existing = await link(pending, path).then(() => null, async (error: unknown) => {
|
|
58
60
|
if (!isAlreadyCaptured(error)) throw error
|
|
61
|
+
return readCapturedEmail(path)
|
|
59
62
|
})
|
|
60
63
|
} catch {
|
|
61
|
-
throw new EmailDeliveryError('The mail capture directory could not be written', { code: 'provider_unavailable'
|
|
64
|
+
throw new EmailDeliveryError('The mail capture directory could not be written', { code: 'provider_unavailable' })
|
|
62
65
|
} finally {
|
|
63
66
|
await unlink(pending).catch(() => undefined)
|
|
64
67
|
}
|
|
68
|
+
if (existing !== null && !sameEmailContent(existing, captured)) {
|
|
69
|
+
throw new EmailDeliveryError('A different message was already captured under this idempotency key', { code: 'idempotency_conflict' })
|
|
70
|
+
}
|
|
65
71
|
return { status: 'accepted', provider: 'directory', messageId }
|
|
66
72
|
}
|
|
67
73
|
}
|
|
68
74
|
|
|
75
|
+
const directoryVariablesSchema = z.object({ FABRIKA_EMAIL_DIRECTORY: z.string() })
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `FABRIKA_EMAIL_PROVIDER=directory`: a local composition's capture. Never in `builtinEmailProviders`; a composition
|
|
79
|
+
* registers it. The capture records content only, so `FABRIKA_EMAIL_FROM` is not used.
|
|
80
|
+
*/
|
|
81
|
+
export const directoryEmailProvider: EmailProvider = {
|
|
82
|
+
variables: [{ name: 'FABRIKA_EMAIL_DIRECTORY', secret: false, required: true }],
|
|
83
|
+
create: ({ env }) => new DirectoryEmailSender({ directory: parseProviderVariables(directoryVariablesSchema, env).FABRIKA_EMAIL_DIRECTORY }),
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
/** Every message a `DirectoryEmailSender` captured in `directory`, oldest first. */
|
|
70
87
|
export async function readCapturedEmails(directory: string): Promise<CapturedEmail[]> {
|
|
71
88
|
const names = (await readdir(directory)).filter((name) => name.endsWith('.json') && !name.startsWith('.'))
|
|
72
|
-
const emails = await Promise.all(names.map(
|
|
89
|
+
const emails = await Promise.all(names.map((name) => readCapturedEmail(join(directory, name))))
|
|
73
90
|
return emails.sort((left, right) => left.capturedAt - right.capturedAt || left.messageId.localeCompare(right.messageId))
|
|
74
91
|
}
|
|
75
92
|
|
|
93
|
+
async function readCapturedEmail(path: string): Promise<CapturedEmail> {
|
|
94
|
+
return capturedEmailSchema.parse(JSON.parse(await readFile(path, 'utf8')))
|
|
95
|
+
}
|
|
96
|
+
|
|
76
97
|
function isAlreadyCaptured(error: unknown): boolean {
|
|
77
98
|
return error instanceof Error && 'code' in error && error.code === 'EEXIST'
|
|
78
99
|
}
|
package/src/email.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
export type EmailFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
2
|
+
|
|
1
3
|
export interface EmailMessage {
|
|
2
4
|
readonly to: string
|
|
3
5
|
readonly subject: string
|
|
4
6
|
readonly text: string
|
|
5
7
|
readonly html?: string
|
|
8
|
+
/** 1..256 visible ASCII characters (Resend's limit). A prefix the caller adds must fit inside it. */
|
|
6
9
|
readonly idempotencyKey: string
|
|
10
|
+
/** One mailbox address. */
|
|
11
|
+
readonly replyTo?: string
|
|
12
|
+
/** Replaces the transport's display name for this message. */
|
|
13
|
+
readonly fromName?: string
|
|
7
14
|
}
|
|
8
15
|
|
|
9
16
|
export interface EmailDeliveryResult {
|
|
@@ -12,37 +19,67 @@ export interface EmailDeliveryResult {
|
|
|
12
19
|
readonly messageId: string
|
|
13
20
|
}
|
|
14
21
|
|
|
22
|
+
export interface EmailSenderCapabilities {
|
|
23
|
+
/** A repeated send under one key with the same message is not delivered again. */
|
|
24
|
+
readonly idempotent: boolean
|
|
25
|
+
/** 'sync': `accepted` means the provider took the message. 'queued': it queued the request and may still drop it. */
|
|
26
|
+
readonly confirmation: 'sync' | 'queued'
|
|
27
|
+
/** The most messages one call carries. `send` carries one, so every built-in adapter reports 1. */
|
|
28
|
+
readonly maxBatch: number
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
export interface EmailSender {
|
|
16
|
-
|
|
32
|
+
readonly capabilities: EmailSenderCapabilities
|
|
33
|
+
send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult>
|
|
17
34
|
}
|
|
18
35
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
/** 'not_sent': the provider did not take the message. 'unknown': it may have taken it. */
|
|
37
|
+
export type EmailDeliveryOutcome = 'not_sent' | 'unknown'
|
|
38
|
+
|
|
39
|
+
/** The one classification of every failure: an adapter names the code, and the code decides the rest. */
|
|
40
|
+
export const EMAIL_DELIVERY_ERRORS = {
|
|
41
|
+
invalid_configuration: { retryable: false, outcome: 'not_sent' },
|
|
42
|
+
invalid_message: { retryable: false, outcome: 'not_sent' },
|
|
43
|
+
// The installation has no transport (`FABRIKA_EMAIL_PROVIDER=none`): the message waits for one, it is not refused.
|
|
44
|
+
mail_not_configured: { retryable: false, outcome: 'not_sent' },
|
|
45
|
+
aborted: { retryable: true, outcome: 'unknown' },
|
|
46
|
+
network_error: { retryable: true, outcome: 'unknown' },
|
|
47
|
+
request_timeout: { retryable: true, outcome: 'unknown' },
|
|
48
|
+
request_in_progress: { retryable: true, outcome: 'unknown' },
|
|
49
|
+
idempotency_conflict: { retryable: false, outcome: 'unknown' },
|
|
50
|
+
provider_unauthorized: { retryable: false, outcome: 'not_sent' },
|
|
51
|
+
rate_limited: { retryable: true, outcome: 'not_sent' },
|
|
52
|
+
quota_exceeded: { retryable: true, outcome: 'not_sent' },
|
|
53
|
+
provider_rejected: { retryable: false, outcome: 'not_sent' },
|
|
54
|
+
provider_unavailable: { retryable: true, outcome: 'unknown' },
|
|
55
|
+
malformed_response: { retryable: true, outcome: 'unknown' },
|
|
56
|
+
} as const satisfies Record<string, { readonly retryable: boolean; readonly outcome: EmailDeliveryOutcome }>
|
|
57
|
+
|
|
58
|
+
export type EmailDeliveryErrorCode = keyof typeof EMAIL_DELIVERY_ERRORS
|
|
29
59
|
|
|
30
60
|
export interface EmailDeliveryErrorOptions {
|
|
31
61
|
readonly code: EmailDeliveryErrorCode
|
|
32
|
-
readonly retryable: boolean
|
|
33
62
|
readonly httpStatus?: number
|
|
34
63
|
}
|
|
35
64
|
|
|
65
|
+
/** Its message is a fixed string plus the HTTP status: never a credential, a response body, a subject or a body. */
|
|
36
66
|
export class EmailDeliveryError extends Error {
|
|
37
67
|
readonly code: EmailDeliveryErrorCode
|
|
38
68
|
readonly retryable: boolean
|
|
69
|
+
readonly outcome: EmailDeliveryOutcome
|
|
39
70
|
readonly httpStatus: number | undefined
|
|
40
71
|
|
|
41
72
|
constructor(message: string, options: EmailDeliveryErrorOptions) {
|
|
42
73
|
super(message)
|
|
43
74
|
this.name = 'EmailDeliveryError'
|
|
44
75
|
this.code = options.code
|
|
45
|
-
this.retryable = options.retryable
|
|
76
|
+
this.retryable = EMAIL_DELIVERY_ERRORS[options.code].retryable
|
|
77
|
+
this.outcome = EMAIL_DELIVERY_ERRORS[options.code].outcome
|
|
46
78
|
this.httpStatus = options.httpStatus
|
|
47
79
|
}
|
|
48
80
|
}
|
|
81
|
+
|
|
82
|
+
/** The one retry rule: an unknown outcome is retried only over an idempotent transport. */
|
|
83
|
+
export function mayRetryDelivery(error: EmailDeliveryError, capabilities: EmailSenderCapabilities): boolean {
|
|
84
|
+
return error.retryable && (error.outcome === 'not_sent' || capabilities.idempotent)
|
|
85
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The request half both HTTP adapters share: the caller's signal, the adapter's own timeout, and how a thrown fetch
|
|
2
|
+
// is classified. Messages name the provider and nothing the request carried.
|
|
3
|
+
|
|
4
|
+
import { EmailDeliveryError, type EmailFetch } from './email.js'
|
|
5
|
+
|
|
6
|
+
export interface ProviderRequest {
|
|
7
|
+
/** The provider's name as it appears in error messages. */
|
|
8
|
+
readonly label: string
|
|
9
|
+
readonly fetch: EmailFetch
|
|
10
|
+
readonly url: string
|
|
11
|
+
readonly headers: Readonly<Record<string, string>>
|
|
12
|
+
readonly body: unknown
|
|
13
|
+
readonly signal: AbortSignal
|
|
14
|
+
readonly timeoutMs: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ProviderResponse {
|
|
18
|
+
readonly ok: boolean
|
|
19
|
+
readonly status: number
|
|
20
|
+
/** The parsed JSON body, or undefined when it is not JSON. Never put into an error. */
|
|
21
|
+
readonly body: unknown
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The answer with its body already read, so an abort or a timeout while the body arrives is classified as one. */
|
|
25
|
+
export async function postToProvider(request: ProviderRequest): Promise<ProviderResponse> {
|
|
26
|
+
if (request.signal.aborted) throw abortedError(request.label)
|
|
27
|
+
const timeout = AbortSignal.timeout(request.timeoutMs)
|
|
28
|
+
const signal = AbortSignal.any([request.signal, timeout])
|
|
29
|
+
try {
|
|
30
|
+
const response = await request.fetch(request.url, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { ...request.headers, 'Content-Type': 'application/json' },
|
|
33
|
+
signal,
|
|
34
|
+
body: JSON.stringify(request.body),
|
|
35
|
+
})
|
|
36
|
+
const text = await untilAborted(response.text(), signal)
|
|
37
|
+
return { ok: response.ok, status: response.status, body: parseJson(text) }
|
|
38
|
+
} catch {
|
|
39
|
+
if (request.signal.aborted) throw abortedError(request.label)
|
|
40
|
+
if (timeout.aborted) throw new EmailDeliveryError(`${request.label} request timed out`, { code: 'request_timeout' })
|
|
41
|
+
throw new EmailDeliveryError(`${request.label} request failed`, { code: 'network_error' })
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseTimeout(label: string, value: number | undefined): number {
|
|
46
|
+
const timeoutMs = value ?? 10_000
|
|
47
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 120_000) {
|
|
48
|
+
throw new EmailDeliveryError(`${label} timeout is invalid`, { code: 'invalid_configuration' })
|
|
49
|
+
}
|
|
50
|
+
return timeoutMs
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** An injected fetch's body need not follow the signal, so the read races it. */
|
|
54
|
+
function untilAborted<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
55
|
+
return new Promise<T>((resolve, reject) => {
|
|
56
|
+
const onAbort = (): void => reject(signal.reason)
|
|
57
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
58
|
+
pending.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort))
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseJson(text: string): unknown {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(text)
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function abortedError(label: string): EmailDeliveryError {
|
|
71
|
+
return new EmailDeliveryError(`${label} request was aborted by the caller`, { code: 'aborted' })
|
|
72
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
export {
|
|
2
|
+
EMAIL_PROVIDER_VARIABLE,
|
|
3
|
+
emailSenderFromEnv,
|
|
4
|
+
NO_EMAIL_PROVIDER,
|
|
5
|
+
parseProviderVariables,
|
|
6
|
+
pickEmailEnvironment,
|
|
7
|
+
readEmailTransportConfig,
|
|
8
|
+
} from './config.js'
|
|
9
|
+
export type {
|
|
10
|
+
EmailEnvironment,
|
|
11
|
+
EmailProvider,
|
|
12
|
+
EmailProviderRegistry,
|
|
13
|
+
EmailProviderVariable,
|
|
14
|
+
EmailSenderIdentity,
|
|
15
|
+
EmailTransportConfig,
|
|
16
|
+
} from './config.js'
|
|
17
|
+
export { EMAIL_DELIVERY_ERRORS, EmailDeliveryError, mayRetryDelivery } from './email.js'
|
|
18
|
+
export type {
|
|
19
|
+
EmailDeliveryErrorCode,
|
|
20
|
+
EmailDeliveryErrorOptions,
|
|
21
|
+
EmailDeliveryOutcome,
|
|
22
|
+
EmailDeliveryResult,
|
|
23
|
+
EmailFetch,
|
|
24
|
+
EmailMessage,
|
|
25
|
+
EmailSender,
|
|
26
|
+
EmailSenderCapabilities,
|
|
27
|
+
} from './email.js'
|
|
28
|
+
export { isValidEmailAddress, isValidEmailDisplayName, isValidEmailFrom, MAX_EMAIL_SUBJECT_LENGTH, validateEmailMessage } from './validation.js'
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// A sender that keeps every message in memory and delivers nothing: the stand-in for tests and local development.
|
|
2
|
+
// Runtime-neutral. Its faults let a test reproduce each answer a provider can give, including the one where the
|
|
3
|
+
// provider took the message and the answer was lost.
|
|
4
|
+
|
|
5
|
+
import { type EmailContent, emailContentOf, sameEmailContent } from './content.js'
|
|
6
|
+
import {
|
|
7
|
+
EmailDeliveryError,
|
|
8
|
+
type EmailDeliveryErrorCode,
|
|
9
|
+
type EmailDeliveryResult,
|
|
10
|
+
type EmailMessage,
|
|
11
|
+
type EmailSender,
|
|
12
|
+
type EmailSenderCapabilities,
|
|
13
|
+
} from './email.js'
|
|
14
|
+
import { validateEmailMessage } from './validation.js'
|
|
15
|
+
|
|
16
|
+
export interface MemoryEmail {
|
|
17
|
+
readonly message: EmailMessage
|
|
18
|
+
readonly messageId: string
|
|
19
|
+
readonly acceptedAt: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MemoryHandoff {
|
|
23
|
+
readonly message: EmailMessage
|
|
24
|
+
readonly at: number
|
|
25
|
+
/** Whether this call added the message to `accepted`. */
|
|
26
|
+
readonly taken: boolean
|
|
27
|
+
readonly answer: 'accepted' | EmailDeliveryErrorCode
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type MemoryRefusal = 'provider_rejected' | 'provider_unauthorized' | 'rate_limited' | 'quota_exceeded'
|
|
31
|
+
|
|
32
|
+
export interface MemoryEmailSenderOptions {
|
|
33
|
+
readonly capabilities?: Partial<EmailSenderCapabilities>
|
|
34
|
+
readonly provider?: string
|
|
35
|
+
readonly now?: () => number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type MemoryHook = (message: EmailMessage, signal: AbortSignal) => Promise<void>
|
|
39
|
+
type MemoryFault = MemoryRefusal | 'provider_unavailable' | 'network_error'
|
|
40
|
+
type MemoryFailure = MemoryFault | 'aborted' | 'idempotency_conflict'
|
|
41
|
+
|
|
42
|
+
interface MutableHandoff {
|
|
43
|
+
readonly message: EmailMessage
|
|
44
|
+
readonly at: number
|
|
45
|
+
taken: boolean
|
|
46
|
+
answer: 'accepted' | EmailDeliveryErrorCode
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface KeyedEmail {
|
|
50
|
+
readonly content: EmailContent
|
|
51
|
+
readonly messageId: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const FAULT_MESSAGES: Readonly<Record<MemoryFault, string>> = {
|
|
55
|
+
provider_rejected: 'The memory sender rejected the message',
|
|
56
|
+
provider_unauthorized: 'The memory sender refused the credentials',
|
|
57
|
+
rate_limited: 'The memory sender rate limit was exceeded',
|
|
58
|
+
quota_exceeded: 'The memory sender quota was exceeded',
|
|
59
|
+
provider_unavailable: 'The memory sender is unavailable',
|
|
60
|
+
network_error: 'The memory sender could not be reached',
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class MemoryEmailSender implements EmailSender {
|
|
64
|
+
readonly capabilities: EmailSenderCapabilities
|
|
65
|
+
readonly #provider: string
|
|
66
|
+
readonly #now: () => number
|
|
67
|
+
readonly #accepted: MemoryEmail[] = []
|
|
68
|
+
readonly #handoffs: MutableHandoff[] = []
|
|
69
|
+
readonly #byKey = new Map<string, KeyedEmail>()
|
|
70
|
+
#fault: MemoryFault | null = null
|
|
71
|
+
#lostAnswers = 0
|
|
72
|
+
#during: MemoryHook | null = null
|
|
73
|
+
#taken = 0
|
|
74
|
+
|
|
75
|
+
constructor(options: MemoryEmailSenderOptions = {}) {
|
|
76
|
+
this.capabilities = { idempotent: true, confirmation: 'sync', maxBatch: 1, ...options.capabilities }
|
|
77
|
+
if (!Number.isSafeInteger(this.capabilities.maxBatch) || this.capabilities.maxBatch < 1) {
|
|
78
|
+
throw new EmailDeliveryError('The memory sender batch size is invalid', { code: 'invalid_configuration' })
|
|
79
|
+
}
|
|
80
|
+
this.#provider = options.provider ?? 'memory'
|
|
81
|
+
this.#now = options.now ?? Date.now
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What reached the inbox, oldest first. */
|
|
85
|
+
get accepted(): readonly MemoryEmail[] {
|
|
86
|
+
return [...this.#accepted]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Every call that passed validation and the abort check. */
|
|
90
|
+
get handoffs(): readonly MemoryHandoff[] {
|
|
91
|
+
return this.#handoffs.map((handoff) => ({ ...handoff }))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Every following call is refused with `code` and takes nothing. */
|
|
95
|
+
refuse(code: MemoryRefusal = 'provider_rejected'): void {
|
|
96
|
+
this.#fault = code
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Every following call answers `provider_unavailable` and takes nothing. */
|
|
100
|
+
unavailable(): void {
|
|
101
|
+
this.#fault = 'provider_unavailable'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Every following call answers `network_error` and takes nothing. */
|
|
105
|
+
unreachable(): void {
|
|
106
|
+
this.#fault = 'network_error'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The next `count` calls take the message, then answer `network_error`. */
|
|
110
|
+
loseAnswers(count: number): void {
|
|
111
|
+
this.#lostAnswers = count
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Runs after the handoff is recorded and before the answer; a rejection becomes `aborted` once the signal aborted, else `network_error`. */
|
|
115
|
+
during(hook: MemoryHook | null): void {
|
|
116
|
+
this.#during = hook
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
heal(): void {
|
|
120
|
+
this.#fault = null
|
|
121
|
+
this.#lostAnswers = 0
|
|
122
|
+
this.#during = null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
|
|
126
|
+
validateEmailMessage(message)
|
|
127
|
+
if (signal.aborted) throw failure('aborted')
|
|
128
|
+
const handoff: MutableHandoff = { message, at: this.#now(), taken: false, answer: 'network_error' }
|
|
129
|
+
this.#handoffs.push(handoff)
|
|
130
|
+
const answer = (code: MemoryFailure): EmailDeliveryError => {
|
|
131
|
+
handoff.answer = code
|
|
132
|
+
return failure(code)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (this.#during !== null) {
|
|
136
|
+
try {
|
|
137
|
+
await this.#during(message, signal)
|
|
138
|
+
} catch {
|
|
139
|
+
throw answer(signal.aborted ? 'aborted' : 'network_error')
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (signal.aborted) throw answer('aborted')
|
|
143
|
+
if (this.#fault !== null) throw answer(this.#fault)
|
|
144
|
+
|
|
145
|
+
const content = emailContentOf(message)
|
|
146
|
+
const previous = this.capabilities.idempotent ? this.#byKey.get(message.idempotencyKey) : undefined
|
|
147
|
+
if (previous !== undefined && !sameEmailContent(previous.content, content)) throw answer('idempotency_conflict')
|
|
148
|
+
const messageId = previous?.messageId ?? this.#take(message, content)
|
|
149
|
+
handoff.taken = previous === undefined
|
|
150
|
+
|
|
151
|
+
if (this.#lostAnswers > 0) {
|
|
152
|
+
this.#lostAnswers -= 1
|
|
153
|
+
throw answer('network_error')
|
|
154
|
+
}
|
|
155
|
+
handoff.answer = 'accepted'
|
|
156
|
+
return { status: 'accepted', provider: this.#provider, messageId }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#take(message: EmailMessage, content: EmailContent): string {
|
|
160
|
+
this.#taken += 1
|
|
161
|
+
const messageId = `memory-${this.#taken}`
|
|
162
|
+
this.#accepted.push({ message, messageId, acceptedAt: this.#now() })
|
|
163
|
+
if (this.capabilities.idempotent) this.#byKey.set(message.idempotencyKey, { content, messageId })
|
|
164
|
+
return messageId
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function failure(code: MemoryFailure): EmailDeliveryError {
|
|
169
|
+
if (code === 'aborted') return new EmailDeliveryError('The memory sender call was aborted by the caller', { code })
|
|
170
|
+
if (code === 'idempotency_conflict') {
|
|
171
|
+
return new EmailDeliveryError('A different message was already accepted under this idempotency key', { code })
|
|
172
|
+
}
|
|
173
|
+
return new EmailDeliveryError(FAULT_MESSAGES[code], { code })
|
|
174
|
+
}
|
package/src/providers.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { EmailProviderRegistry } from './config.js'
|
|
2
|
+
import { resendEmailProvider } from './resend.js'
|
|
3
|
+
import { smartEmailingEmailProvider } from './smartemailing.js'
|
|
4
|
+
|
|
5
|
+
/** The providers `FABRIKA_EMAIL_PROVIDER` can name in a stock installation. Memory and Directory are composition inputs. */
|
|
6
|
+
export const builtinEmailProviders = {
|
|
7
|
+
resend: resendEmailProvider,
|
|
8
|
+
smartemailing: smartEmailingEmailProvider,
|
|
9
|
+
} satisfies EmailProviderRegistry
|