@fabrika/email 0.8.2 → 0.9.0-cloudflare.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +19 -13
- package/src/cloudflare.ts +142 -0
- package/src/http-sender.ts +3 -3
- package/src/providers.ts +3 -0
- package/types/src/ahasend.d.ts +26 -0
- package/types/src/cloudflare.d.ts +20 -0
- package/types/src/config.d.ts +48 -0
- package/types/src/conformance.d.ts +59 -0
- package/types/src/content.d.ts +23 -0
- package/types/src/directory.d.ts +84 -0
- package/types/src/email.d.ts +142 -0
- package/types/src/http-sender.d.ts +49 -0
- package/types/src/http.d.ts +20 -0
- package/types/src/index.d.ts +5 -0
- package/types/src/lettermint.d.ts +19 -0
- package/types/src/memory.d.ts +42 -0
- package/types/src/platform-wire.d.ts +63 -0
- package/types/src/platform.d.ts +19 -0
- package/types/src/postmark.d.ts +19 -0
- package/types/src/providers.d.ts +29 -0
- package/types/src/resend.d.ts +16 -0
- package/types/src/smartemailing.d.ts +24 -0
- package/types/src/validation.d.ts +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/email",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0-cloudflare.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -12,62 +12,68 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"src",
|
|
15
|
-
"!src/**/__tests__/**"
|
|
15
|
+
"!src/**/__tests__/**",
|
|
16
|
+
"types"
|
|
16
17
|
],
|
|
17
18
|
"exports": {
|
|
18
19
|
".": {
|
|
19
20
|
"bun": "./src/index.ts",
|
|
20
|
-
"types": "./src/index.ts",
|
|
21
|
+
"types": "./types/src/index.d.ts",
|
|
21
22
|
"default": "./src/index.ts"
|
|
22
23
|
},
|
|
23
24
|
"./resend": {
|
|
24
25
|
"bun": "./src/resend.ts",
|
|
25
|
-
"types": "./src/resend.ts",
|
|
26
|
+
"types": "./types/src/resend.d.ts",
|
|
26
27
|
"default": "./src/resend.ts"
|
|
27
28
|
},
|
|
28
29
|
"./postmark": {
|
|
29
30
|
"bun": "./src/postmark.ts",
|
|
30
|
-
"types": "./src/postmark.ts",
|
|
31
|
+
"types": "./types/src/postmark.d.ts",
|
|
31
32
|
"default": "./src/postmark.ts"
|
|
32
33
|
},
|
|
33
34
|
"./ahasend": {
|
|
34
35
|
"bun": "./src/ahasend.ts",
|
|
35
|
-
"types": "./src/ahasend.ts",
|
|
36
|
+
"types": "./types/src/ahasend.d.ts",
|
|
36
37
|
"default": "./src/ahasend.ts"
|
|
37
38
|
},
|
|
39
|
+
"./cloudflare": {
|
|
40
|
+
"bun": "./src/cloudflare.ts",
|
|
41
|
+
"types": "./types/src/cloudflare.d.ts",
|
|
42
|
+
"default": "./src/cloudflare.ts"
|
|
43
|
+
},
|
|
38
44
|
"./lettermint": {
|
|
39
45
|
"bun": "./src/lettermint.ts",
|
|
40
|
-
"types": "./src/lettermint.ts",
|
|
46
|
+
"types": "./types/src/lettermint.d.ts",
|
|
41
47
|
"default": "./src/lettermint.ts"
|
|
42
48
|
},
|
|
43
49
|
"./directory": {
|
|
44
50
|
"bun": "./src/directory.ts",
|
|
45
|
-
"types": "./src/directory.ts",
|
|
51
|
+
"types": "./types/src/directory.d.ts",
|
|
46
52
|
"default": "./src/directory.ts"
|
|
47
53
|
},
|
|
48
54
|
"./memory": {
|
|
49
55
|
"bun": "./src/memory.ts",
|
|
50
|
-
"types": "./src/memory.ts",
|
|
56
|
+
"types": "./types/src/memory.d.ts",
|
|
51
57
|
"default": "./src/memory.ts"
|
|
52
58
|
},
|
|
53
59
|
"./smartemailing": {
|
|
54
60
|
"bun": "./src/smartemailing.ts",
|
|
55
|
-
"types": "./src/smartemailing.ts",
|
|
61
|
+
"types": "./types/src/smartemailing.d.ts",
|
|
56
62
|
"default": "./src/smartemailing.ts"
|
|
57
63
|
},
|
|
58
64
|
"./providers": {
|
|
59
65
|
"bun": "./src/providers.ts",
|
|
60
|
-
"types": "./src/providers.ts",
|
|
66
|
+
"types": "./types/src/providers.d.ts",
|
|
61
67
|
"default": "./src/providers.ts"
|
|
62
68
|
},
|
|
63
69
|
"./platform": {
|
|
64
70
|
"bun": "./src/platform.ts",
|
|
65
|
-
"types": "./src/platform.ts",
|
|
71
|
+
"types": "./types/src/platform.d.ts",
|
|
66
72
|
"default": "./src/platform.ts"
|
|
67
73
|
},
|
|
68
74
|
"./conformance": {
|
|
69
75
|
"bun": "./src/conformance.ts",
|
|
70
|
-
"types": "./src/conformance.ts",
|
|
76
|
+
"types": "./types/src/conformance.d.ts",
|
|
71
77
|
"default": "./src/conformance.ts"
|
|
72
78
|
}
|
|
73
79
|
},
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Cloudflare Email Service's Email Sending REST API (https://developers.cloudflare.com/email-service/api/send-emails/rest-api/),
|
|
2
|
+
// built from its documentation in October 2026 and verified only against a stubbed fetch. It has no idempotency key,
|
|
3
|
+
// and Cloudflare takes transactional mail only. The REST API, not the Workers `send_email` binding, so the transport is
|
|
4
|
+
// one set of `FABRIKA_EMAIL_*` on every runtime that sends: a Worker, a Bun process on Zerops, an application.
|
|
5
|
+
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import { type EmailProvider, parseProviderVariables } from './config.js'
|
|
8
|
+
import { base64Of } from './content.js'
|
|
9
|
+
import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities, oneClickUnsubscribeHeaders } from './email.js'
|
|
10
|
+
import { credentialVariable, type HttpEmailRequest, HttpEmailSender, type HttpEmailSenderOptions, isCredential } from './http-sender.js'
|
|
11
|
+
import type { ProviderResponse } from './http.js'
|
|
12
|
+
|
|
13
|
+
const CLOUDFLARE_ACCOUNTS_URL = 'https://api.cloudflare.com/client/v4/accounts'
|
|
14
|
+
/** A Cloudflare account ID: 32 hexadecimal digits, the path of every request. */
|
|
15
|
+
const ACCOUNT_ID_PATTERN = /^[0-9a-fA-F]{32}$/
|
|
16
|
+
const cloudflareSuccessSchema = z.object({
|
|
17
|
+
success: z.literal(true),
|
|
18
|
+
result: z.object({
|
|
19
|
+
message_id: z.string().optional(),
|
|
20
|
+
delivered: z.array(z.string()).optional(),
|
|
21
|
+
queued: z.array(z.string()).optional(),
|
|
22
|
+
permanent_bounces: z.array(z.string()).optional(),
|
|
23
|
+
suppressed_recipients: z.array(z.string()).optional(),
|
|
24
|
+
}),
|
|
25
|
+
})
|
|
26
|
+
/** Cloudflare bounds a custom header's value at 2,048 bytes; the URL travels inside `<…>`. */
|
|
27
|
+
const MAX_HEADER_VALUE_BYTES = 2048
|
|
28
|
+
/**
|
|
29
|
+
* Cloudflare counts the whole message, attachments base64 and MIME-wrapped, against 5 MiB; 3 MiB of bytes leaves
|
|
30
|
+
* about a mebibyte for the bodies and the headers.
|
|
31
|
+
*/
|
|
32
|
+
const CLOUDFLARE_MAX_ATTACHMENT_BYTES = 3 * 1024 * 1024
|
|
33
|
+
|
|
34
|
+
export interface CloudflareEmailSenderOptions extends HttpEmailSenderOptions {
|
|
35
|
+
/** An account API token with the Email Sending Write permission and nothing else. */
|
|
36
|
+
readonly apiToken: string
|
|
37
|
+
/** The account the token sends for: the path of every request. */
|
|
38
|
+
readonly accountId: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A refusal's body carries numeric codes and machine-readable messages; only the status is read. */
|
|
42
|
+
export class CloudflareEmailSender extends HttpEmailSender {
|
|
43
|
+
readonly capabilities: EmailSenderCapabilities = {
|
|
44
|
+
idempotent: false,
|
|
45
|
+
confirmation: 'sync',
|
|
46
|
+
maxBatch: 1,
|
|
47
|
+
listUnsubscribe: true,
|
|
48
|
+
maxAttachmentBytes: CLOUDFLARE_MAX_ATTACHMENT_BYTES,
|
|
49
|
+
// Cloudflare Email Service takes transactional mail only, so it sends no broadcast.
|
|
50
|
+
}
|
|
51
|
+
readonly #apiToken: string
|
|
52
|
+
readonly #sendUrl: string
|
|
53
|
+
|
|
54
|
+
constructor(options: CloudflareEmailSenderOptions) {
|
|
55
|
+
if (!isCredential(options.apiToken)) {
|
|
56
|
+
throw new EmailDeliveryError('Cloudflare API token is invalid', { code: 'invalid_configuration' })
|
|
57
|
+
}
|
|
58
|
+
if (!ACCOUNT_ID_PATTERN.test(options.accountId)) {
|
|
59
|
+
throw new EmailDeliveryError('Cloudflare account ID is invalid', { code: 'invalid_configuration' })
|
|
60
|
+
}
|
|
61
|
+
super({ label: 'Cloudflare', provider: 'cloudflare' }, options)
|
|
62
|
+
this.#apiToken = options.apiToken
|
|
63
|
+
this.#sendUrl = `${CLOUDFLARE_ACCOUNTS_URL}/${options.accountId}/email/sending/send`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
protected request(message: EmailMessage): HttpEmailRequest {
|
|
67
|
+
if (message.unsubscribeUrl !== undefined && new TextEncoder().encode(`<${message.unsubscribeUrl}>`).byteLength > MAX_HEADER_VALUE_BYTES) {
|
|
68
|
+
throw new EmailDeliveryError('Email unsubscribe URL is invalid', { code: 'invalid_message' })
|
|
69
|
+
}
|
|
70
|
+
const senderName = message.fromName ?? this.from.name
|
|
71
|
+
const attachments = message.attachments ?? []
|
|
72
|
+
return {
|
|
73
|
+
url: this.#sendUrl,
|
|
74
|
+
headers: { Authorization: `Bearer ${this.#apiToken}` },
|
|
75
|
+
body: {
|
|
76
|
+
from: { address: this.from.address, ...(senderName === undefined ? {} : { name: senderName }) },
|
|
77
|
+
to: message.to,
|
|
78
|
+
subject: message.subject,
|
|
79
|
+
text: message.text,
|
|
80
|
+
...(message.html === undefined ? {} : { html: message.html }),
|
|
81
|
+
...(message.replyTo === undefined ? {} : { reply_to: message.replyTo }),
|
|
82
|
+
...(message.unsubscribeUrl === undefined ? {} : { headers: oneClickUnsubscribeHeaders(message.unsubscribeUrl) }),
|
|
83
|
+
...(attachments.length === 0
|
|
84
|
+
? {}
|
|
85
|
+
: {
|
|
86
|
+
attachments: attachments.map((attachment) => ({
|
|
87
|
+
content: base64Of(attachment.content),
|
|
88
|
+
filename: attachment.filename,
|
|
89
|
+
type: attachment.contentType,
|
|
90
|
+
disposition: 'attachment',
|
|
91
|
+
})),
|
|
92
|
+
}),
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
protected messageId(response: ProviderResponse, message: EmailMessage): string | undefined {
|
|
98
|
+
const result = cloudflareSuccessSchema.safeParse(response.body).data?.result
|
|
99
|
+
if (result === undefined) return undefined
|
|
100
|
+
const listed = (addresses: readonly string[] | undefined): boolean =>
|
|
101
|
+
(addresses ?? []).some((address) => address.toLowerCase() === message.to.toLowerCase())
|
|
102
|
+
// A 200 groups the recipients by outcome; the one recipient listed as bounced or suppressed got nothing. The
|
|
103
|
+
// lists name the address, so they stay out of the refusal.
|
|
104
|
+
if (listed(result.permanent_bounces) || listed(result.suppressed_recipients)) {
|
|
105
|
+
throw new EmailDeliveryError(`Cloudflare refused the recipient (${response.status})`, { code: 'provider_rejected', httpStatus: response.status })
|
|
106
|
+
}
|
|
107
|
+
if (result.message_id !== undefined && result.message_id !== '') return result.message_id
|
|
108
|
+
// The REST guide documents a 200 with the recipients' status and no `message_id`. A recipient delivered or queued
|
|
109
|
+
// was taken, and the message's own key names that send, the same on every retry of it.
|
|
110
|
+
if (listed(result.delivered) || listed(result.queued)) return `cloudflare:${message.idempotencyKey}`
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
protected override refusal(response: ProviderResponse): EmailDeliveryError | undefined {
|
|
115
|
+
// Documented as a resource not found: under this path, an account the token cannot reach.
|
|
116
|
+
if (response.status === 404) {
|
|
117
|
+
return new EmailDeliveryError('Cloudflare knows no such account (404)', { code: 'provider_unauthorized', httpStatus: response.status })
|
|
118
|
+
}
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const cloudflareVariablesSchema = z.object({
|
|
124
|
+
FABRIKA_EMAIL_CLOUDFLARE_API_TOKEN: credentialVariable,
|
|
125
|
+
FABRIKA_EMAIL_CLOUDFLARE_ACCOUNT_ID: z.string().regex(ACCOUNT_ID_PATTERN),
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
export const cloudflareEmailProvider: EmailProvider = {
|
|
129
|
+
variables: [
|
|
130
|
+
{ name: 'FABRIKA_EMAIL_CLOUDFLARE_API_TOKEN', secret: true, required: true },
|
|
131
|
+
{ name: 'FABRIKA_EMAIL_CLOUDFLARE_ACCOUNT_ID', secret: false, required: true },
|
|
132
|
+
],
|
|
133
|
+
create({ from, env, fetch }) {
|
|
134
|
+
const variables = parseProviderVariables(cloudflareVariablesSchema, env)
|
|
135
|
+
return new CloudflareEmailSender({
|
|
136
|
+
apiToken: variables.FABRIKA_EMAIL_CLOUDFLARE_API_TOKEN,
|
|
137
|
+
accountId: variables.FABRIKA_EMAIL_CLOUDFLARE_ACCOUNT_ID,
|
|
138
|
+
from,
|
|
139
|
+
fetch,
|
|
140
|
+
})
|
|
141
|
+
},
|
|
142
|
+
}
|
package/src/http-sender.ts
CHANGED
|
@@ -58,8 +58,8 @@ export abstract class HttpEmailSender implements EmailSender {
|
|
|
58
58
|
/** The request for a message that passed validation. */
|
|
59
59
|
protected abstract request(message: EmailMessage): HttpEmailRequest | Promise<HttpEmailRequest>
|
|
60
60
|
|
|
61
|
-
/** The message id of a 2xx answer
|
|
62
|
-
protected abstract messageId(response: ProviderResponse): string | undefined
|
|
61
|
+
/** The message id of a 2xx answer to `message`; undefined when the answer is not a valid success. May throw a refusal. */
|
|
62
|
+
protected abstract messageId(response: ProviderResponse, message: EmailMessage): string | undefined
|
|
63
63
|
|
|
64
64
|
/** A failed answer to the message the provider tells apart beyond its status; undefined leaves it to `refusalByStatus`. */
|
|
65
65
|
protected refusal(_response: ProviderResponse, _message: EmailMessage): EmailDeliveryError | undefined {
|
|
@@ -79,7 +79,7 @@ export abstract class HttpEmailSender implements EmailSender {
|
|
|
79
79
|
const response = await postToProvider({ label: this.#label, fetch: this.#fetch, ...request, signal, timeoutMs: this.#timeoutMs })
|
|
80
80
|
if (!response.ok) throw this.refusal(response, message) ?? refusalByStatus(this.#label, response.status)
|
|
81
81
|
|
|
82
|
-
const messageId = this.messageId(response)
|
|
82
|
+
const messageId = this.messageId(response, message)
|
|
83
83
|
if (messageId === undefined || messageId === '') {
|
|
84
84
|
throw new EmailDeliveryError(`${this.#label} returned an invalid success response`, {
|
|
85
85
|
code: 'malformed_response',
|
package/src/providers.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ahaSendEmailProvider } from './ahasend.js'
|
|
2
|
+
import { cloudflareEmailProvider } from './cloudflare.js'
|
|
2
3
|
import type { EmailProviderRegistry, EmailProviderVariable } from './config.js'
|
|
3
4
|
import { lettermintEmailProvider } from './lettermint.js'
|
|
4
5
|
import { postmarkEmailProvider } from './postmark.js'
|
|
@@ -11,6 +12,7 @@ import { smartEmailingEmailProvider } from './smartemailing.js'
|
|
|
11
12
|
*/
|
|
12
13
|
export const builtinEmailProviders = {
|
|
13
14
|
ahasend: ahaSendEmailProvider,
|
|
15
|
+
cloudflare: cloudflareEmailProvider,
|
|
14
16
|
lettermint: lettermintEmailProvider,
|
|
15
17
|
postmark: postmarkEmailProvider,
|
|
16
18
|
resend: resendEmailProvider,
|
|
@@ -30,6 +32,7 @@ export const builtinEmailProviderVariables: readonly EmailProviderVariable[] = O
|
|
|
30
32
|
*/
|
|
31
33
|
export const builtinEmailProviderSince = {
|
|
32
34
|
ahasend: '0.8.2',
|
|
35
|
+
cloudflare: '0.9.0',
|
|
33
36
|
lettermint: '0.8.2',
|
|
34
37
|
postmark: '0.8.2',
|
|
35
38
|
resend: null,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type EmailProvider } from './config.js';
|
|
2
|
+
import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities } from './email.js';
|
|
3
|
+
import { type HttpEmailRequest, HttpEmailSender, type HttpEmailSenderOptions } from './http-sender.js';
|
|
4
|
+
import type { ProviderResponse } from './http.js';
|
|
5
|
+
export interface AhaSendEmailSenderOptions extends HttpEmailSenderOptions {
|
|
6
|
+
/** An API key v2, scoped to `messages:send:{domain}` at least. */
|
|
7
|
+
readonly apiKey: string;
|
|
8
|
+
/** The account the key belongs to: the path of every request. */
|
|
9
|
+
readonly accountId: string;
|
|
10
|
+
/**
|
|
11
|
+
* The address a `broadcast` message is sent from, on a domain the account has configured as a Marketing domain:
|
|
12
|
+
* AhaSend's terms allow marketing mail through the API from such a domain only. Without it, a broadcast is refused.
|
|
13
|
+
*/
|
|
14
|
+
readonly broadcastFrom?: string;
|
|
15
|
+
}
|
|
16
|
+
/** A refusal's body is one free-text `message`, so only the status is read. */
|
|
17
|
+
export declare class AhaSendEmailSender extends HttpEmailSender {
|
|
18
|
+
#private;
|
|
19
|
+
/** `broadcast` only with `broadcastFrom`. */
|
|
20
|
+
readonly capabilities: EmailSenderCapabilities;
|
|
21
|
+
constructor(options: AhaSendEmailSenderOptions);
|
|
22
|
+
protected request(message: EmailMessage): Promise<HttpEmailRequest>;
|
|
23
|
+
protected messageId(response: ProviderResponse): string | undefined;
|
|
24
|
+
protected refusal(response: ProviderResponse): EmailDeliveryError | undefined;
|
|
25
|
+
}
|
|
26
|
+
export declare const ahaSendEmailProvider: EmailProvider;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type EmailProvider } from './config.js';
|
|
2
|
+
import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities } from './email.js';
|
|
3
|
+
import { type HttpEmailRequest, HttpEmailSender, type HttpEmailSenderOptions } from './http-sender.js';
|
|
4
|
+
import type { ProviderResponse } from './http.js';
|
|
5
|
+
export interface CloudflareEmailSenderOptions extends HttpEmailSenderOptions {
|
|
6
|
+
/** An account API token with the Email Sending Write permission and nothing else. */
|
|
7
|
+
readonly apiToken: string;
|
|
8
|
+
/** The account the token sends for: the path of every request. */
|
|
9
|
+
readonly accountId: string;
|
|
10
|
+
}
|
|
11
|
+
/** A refusal's body carries numeric codes and machine-readable messages; only the status is read. */
|
|
12
|
+
export declare class CloudflareEmailSender extends HttpEmailSender {
|
|
13
|
+
#private;
|
|
14
|
+
readonly capabilities: EmailSenderCapabilities;
|
|
15
|
+
constructor(options: CloudflareEmailSenderOptions);
|
|
16
|
+
protected request(message: EmailMessage): HttpEmailRequest;
|
|
17
|
+
protected messageId(response: ProviderResponse, message: EmailMessage): string | undefined;
|
|
18
|
+
protected refusal(response: ProviderResponse): EmailDeliveryError | undefined;
|
|
19
|
+
}
|
|
20
|
+
export declare const cloudflareEmailProvider: EmailProvider;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type EmailFetch, type EmailSender } from './email.js';
|
|
3
|
+
export type EmailEnvironment = Readonly<Record<string, string | undefined>>;
|
|
4
|
+
export interface EmailSenderIdentity {
|
|
5
|
+
readonly address: string;
|
|
6
|
+
readonly name?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface EmailProviderVariable {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly secret: boolean;
|
|
11
|
+
readonly required: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface EmailProvider {
|
|
14
|
+
/** Provider-specific variables, beside FABRIKA_EMAIL_PROVIDER/FROM/FROM_NAME. */
|
|
15
|
+
readonly variables: readonly EmailProviderVariable[];
|
|
16
|
+
/** Parses its own variables; throws `invalid_configuration` naming the variable, never its value. */
|
|
17
|
+
create(input: {
|
|
18
|
+
readonly from: EmailSenderIdentity;
|
|
19
|
+
readonly env: EmailEnvironment;
|
|
20
|
+
readonly fetch?: EmailFetch;
|
|
21
|
+
}): EmailSender;
|
|
22
|
+
}
|
|
23
|
+
export type EmailProviderRegistry = Readonly<Record<string, EmailProvider>>;
|
|
24
|
+
export type EmailTransportConfig = {
|
|
25
|
+
readonly kind: 'none';
|
|
26
|
+
} | {
|
|
27
|
+
readonly kind: 'provider';
|
|
28
|
+
readonly provider: string;
|
|
29
|
+
readonly from: EmailSenderIdentity;
|
|
30
|
+
readonly variables: readonly EmailProviderVariable[];
|
|
31
|
+
};
|
|
32
|
+
/** The variable that selects the provider. */
|
|
33
|
+
export declare const EMAIL_PROVIDER_VARIABLE = "FABRIKA_EMAIL_PROVIDER";
|
|
34
|
+
/** `FABRIKA_EMAIL_PROVIDER=none`: the installation states it has no transport, which an absent variable does not. */
|
|
35
|
+
export declare const NO_EMAIL_PROVIDER = "none";
|
|
36
|
+
/** Throws one `invalid_configuration` listing every missing or invalid variable name. */
|
|
37
|
+
export declare function readEmailTransportConfig(env: EmailEnvironment, registry: EmailProviderRegistry): EmailTransportConfig;
|
|
38
|
+
/** null for `none` or an absent provider. */
|
|
39
|
+
export declare function emailSenderFromEnv(env: EmailEnvironment, registry: EmailProviderRegistry, options?: {
|
|
40
|
+
readonly fetch?: EmailFetch;
|
|
41
|
+
}): EmailSender | null;
|
|
42
|
+
/** The non-empty string `FABRIKA_EMAIL_*` entries of any binding object (Worker bindings, process.env). */
|
|
43
|
+
export declare function pickEmailEnvironment(source: object): EmailEnvironment;
|
|
44
|
+
/**
|
|
45
|
+
* A provider's own variables through its schema. A failure is one `invalid_configuration` naming each offending
|
|
46
|
+
* variable and never its value, in the form `readEmailTransportConfig` merges into its own refusal.
|
|
47
|
+
*/
|
|
48
|
+
export declare function parseProviderVariables<Schema extends z.ZodTypeAny>(schema: Schema, env: EmailEnvironment): z.output<Schema>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type EmailAttachmentContent } from './content.js';
|
|
2
|
+
import { type EmailFetch, type EmailMessage, type EmailSender } from './email.js';
|
|
3
|
+
/** What the provider received, or what a local sender stored, in the message's own terms. */
|
|
4
|
+
export interface WireMessage {
|
|
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
|
+
readonly idempotencyKey?: string;
|
|
12
|
+
/** The URL of the `List-Unsubscribe` header, when `List-Unsubscribe-Post` says one click. */
|
|
13
|
+
readonly unsubscribeUrl?: string;
|
|
14
|
+
/** Each attachment with its bytes as base64. */
|
|
15
|
+
readonly attachments?: readonly EmailAttachmentContent[];
|
|
16
|
+
/** Whether the provider got it as broadcast mail, on its channel for them where it has one. */
|
|
17
|
+
readonly broadcast?: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** A stored or sent message in wire terms: what a local harness answers for an `EmailMessage` it kept. */
|
|
20
|
+
export declare function wireMessageOf(message: EmailMessage): WireMessage;
|
|
21
|
+
/** How the provider answers. `inProgress` and `idempotencyConflict` are required when the sender is idempotent. */
|
|
22
|
+
export interface ProviderAnswers {
|
|
23
|
+
accepted(request: Request): Response;
|
|
24
|
+
malformedSuccess(): Response;
|
|
25
|
+
unauthorized(): Response;
|
|
26
|
+
rejected(): Response;
|
|
27
|
+
rateLimited(): Response;
|
|
28
|
+
unavailable(): Response;
|
|
29
|
+
quotaExceeded?(): Response;
|
|
30
|
+
inProgress?(): Response;
|
|
31
|
+
idempotencyConflict?(): Response;
|
|
32
|
+
}
|
|
33
|
+
export interface EmailSenderHttpHarness {
|
|
34
|
+
readonly kind: 'http';
|
|
35
|
+
/** Credentials the sender holds; none may appear in an error or a log line. */
|
|
36
|
+
readonly secrets: readonly string[];
|
|
37
|
+
create(fetch: EmailFetch, options: {
|
|
38
|
+
readonly timeoutMs: number;
|
|
39
|
+
}): EmailSender;
|
|
40
|
+
readRequest(request: Request): Promise<WireMessage>;
|
|
41
|
+
readonly answers: ProviderAnswers;
|
|
42
|
+
}
|
|
43
|
+
/** A local sender's I/O cannot be held open from outside, so the abort-during-I/O cases run for HTTP senders only. */
|
|
44
|
+
export interface EmailSenderLocalHarness {
|
|
45
|
+
readonly kind: 'local';
|
|
46
|
+
/** Called before each case. */
|
|
47
|
+
create(): Promise<EmailSender>;
|
|
48
|
+
/** What the sender created by the last `create` stored, oldest first. */
|
|
49
|
+
stored(): Promise<readonly WireMessage[]>;
|
|
50
|
+
/** Called after each case. */
|
|
51
|
+
cleanup?(): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
export type EmailSenderConformanceHarness = EmailSenderHttpHarness | EmailSenderLocalHarness;
|
|
54
|
+
/**
|
|
55
|
+
* Registers the conformance cases for one sender. HTTP harnesses also get the provider answer table, the thrown and
|
|
56
|
+
* silent fetch, and an abort or a timeout while the request or the answer body is in flight; a local harness gets
|
|
57
|
+
* only the already-aborted case, because nothing can hold its I/O open.
|
|
58
|
+
*/
|
|
59
|
+
export declare function describeEmailSenderConformance(name: string, harness: EmailSenderConformanceHarness): void;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { EmailMessage } from './email.js';
|
|
2
|
+
/** An attachment as a local sender keeps and compares it: the bytes as base64. */
|
|
3
|
+
export interface EmailAttachmentContent {
|
|
4
|
+
readonly filename: string;
|
|
5
|
+
readonly contentType: string;
|
|
6
|
+
readonly content: string;
|
|
7
|
+
}
|
|
8
|
+
/** What a local sender compares when one idempotency key arrives twice: everything the recipient would see. */
|
|
9
|
+
export interface EmailContent {
|
|
10
|
+
readonly to: string;
|
|
11
|
+
readonly subject: string;
|
|
12
|
+
readonly text: string;
|
|
13
|
+
readonly html?: string;
|
|
14
|
+
readonly replyTo?: string;
|
|
15
|
+
readonly fromName?: string;
|
|
16
|
+
readonly unsubscribeUrl?: string;
|
|
17
|
+
readonly broadcast?: true;
|
|
18
|
+
readonly attachments?: readonly EmailAttachmentContent[];
|
|
19
|
+
}
|
|
20
|
+
export declare function sameEmailContent(left: EmailContent, right: EmailContent): boolean;
|
|
21
|
+
export declare function emailContentOf(message: EmailMessage): EmailContent;
|
|
22
|
+
/** `bytes` as standard base64, on every runtime: `btoa` over chunks, so a large file never overflows the call stack. */
|
|
23
|
+
export declare function base64Of(bytes: Uint8Array): string;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type EmailProvider } from './config.js';
|
|
3
|
+
import { type EmailDeliveryResult, type EmailMessage, type EmailSender, type EmailSenderCapabilities } from './email.js';
|
|
4
|
+
declare const capturedEmailSchema: z.ZodObject<{
|
|
5
|
+
messageId: z.ZodString;
|
|
6
|
+
idempotencyKey: z.ZodString;
|
|
7
|
+
to: z.ZodString;
|
|
8
|
+
subject: z.ZodString;
|
|
9
|
+
text: z.ZodString;
|
|
10
|
+
html: z.ZodOptional<z.ZodString>;
|
|
11
|
+
replyTo: z.ZodOptional<z.ZodString>;
|
|
12
|
+
fromName: z.ZodOptional<z.ZodString>;
|
|
13
|
+
unsubscribeUrl: z.ZodOptional<z.ZodString>;
|
|
14
|
+
broadcast: z.ZodOptional<z.ZodLiteral<true>>;
|
|
15
|
+
attachments: z.ZodOptional<z.ZodReadonly<z.ZodArray<z.ZodObject<{
|
|
16
|
+
filename: z.ZodString;
|
|
17
|
+
contentType: z.ZodString;
|
|
18
|
+
content: z.ZodString;
|
|
19
|
+
}, "strip", z.ZodTypeAny, {
|
|
20
|
+
filename: string;
|
|
21
|
+
contentType: string;
|
|
22
|
+
content: string;
|
|
23
|
+
}, {
|
|
24
|
+
filename: string;
|
|
25
|
+
contentType: string;
|
|
26
|
+
content: string;
|
|
27
|
+
}>, "many">>>;
|
|
28
|
+
capturedAt: z.ZodNumber;
|
|
29
|
+
}, "strip", z.ZodTypeAny, {
|
|
30
|
+
messageId: string;
|
|
31
|
+
idempotencyKey: string;
|
|
32
|
+
to: string;
|
|
33
|
+
subject: string;
|
|
34
|
+
text: string;
|
|
35
|
+
capturedAt: number;
|
|
36
|
+
html?: string | undefined;
|
|
37
|
+
replyTo?: string | undefined;
|
|
38
|
+
fromName?: string | undefined;
|
|
39
|
+
unsubscribeUrl?: string | undefined;
|
|
40
|
+
broadcast?: true | undefined;
|
|
41
|
+
attachments?: readonly {
|
|
42
|
+
filename: string;
|
|
43
|
+
contentType: string;
|
|
44
|
+
content: string;
|
|
45
|
+
}[] | undefined;
|
|
46
|
+
}, {
|
|
47
|
+
messageId: string;
|
|
48
|
+
idempotencyKey: string;
|
|
49
|
+
to: string;
|
|
50
|
+
subject: string;
|
|
51
|
+
text: string;
|
|
52
|
+
capturedAt: number;
|
|
53
|
+
html?: string | undefined;
|
|
54
|
+
replyTo?: string | undefined;
|
|
55
|
+
fromName?: string | undefined;
|
|
56
|
+
unsubscribeUrl?: string | undefined;
|
|
57
|
+
broadcast?: true | undefined;
|
|
58
|
+
attachments?: readonly {
|
|
59
|
+
filename: string;
|
|
60
|
+
contentType: string;
|
|
61
|
+
content: string;
|
|
62
|
+
}[] | undefined;
|
|
63
|
+
}>;
|
|
64
|
+
export type CapturedEmail = z.infer<typeof capturedEmailSchema>;
|
|
65
|
+
export interface DirectoryEmailSenderOptions {
|
|
66
|
+
/** An existing directory, as an absolute path. */
|
|
67
|
+
readonly directory: string;
|
|
68
|
+
readonly now?: () => number;
|
|
69
|
+
}
|
|
70
|
+
export declare class DirectoryEmailSender implements EmailSender {
|
|
71
|
+
#private;
|
|
72
|
+
readonly capabilities: EmailSenderCapabilities;
|
|
73
|
+
constructor(options: DirectoryEmailSenderOptions);
|
|
74
|
+
/** One file per idempotency key: a repeated send of the same message answers the first id, as a provider does. */
|
|
75
|
+
send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult>;
|
|
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 declare const directoryEmailProvider: EmailProvider;
|
|
82
|
+
/** Every message a `DirectoryEmailSender` captured in `directory`, oldest first. */
|
|
83
|
+
export declare function readCapturedEmails(directory: string): Promise<CapturedEmail[]>;
|
|
84
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
export type EmailFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
2
|
+
/** One file a message carries: the name the recipient sees, its media type and its bytes. */
|
|
3
|
+
export interface EmailAttachment {
|
|
4
|
+
readonly filename: string;
|
|
5
|
+
readonly contentType: string;
|
|
6
|
+
readonly content: Uint8Array;
|
|
7
|
+
}
|
|
8
|
+
export interface EmailMessage {
|
|
9
|
+
readonly to: string;
|
|
10
|
+
readonly subject: string;
|
|
11
|
+
readonly text: string;
|
|
12
|
+
readonly html?: string;
|
|
13
|
+
/** 1..256 visible ASCII characters (Resend's limit). A prefix the caller adds must fit inside it. */
|
|
14
|
+
readonly idempotencyKey: string;
|
|
15
|
+
/** One mailbox address. */
|
|
16
|
+
readonly replyTo?: string;
|
|
17
|
+
/** Replaces the transport's display name for this message. */
|
|
18
|
+
readonly fromName?: string;
|
|
19
|
+
/**
|
|
20
|
+
* An `https:` URL that unsubscribes the recipient in one click (RFC 8058): the transport writes `List-Unsubscribe:
|
|
21
|
+
* <url>` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click`. A transport without `listUnsubscribe` refuses it.
|
|
22
|
+
*/
|
|
23
|
+
readonly unsubscribeUrl?: string;
|
|
24
|
+
/**
|
|
25
|
+
* One message of a send to many recipients (a newsletter, an announcement), not a reply to what the recipient did.
|
|
26
|
+
* A provider keeps such mail apart from transactional mail, and the law and the mailbox providers ask it to carry
|
|
27
|
+
* an unsubscribe, so it needs `unsubscribeUrl`. A transport without `broadcast` refuses it.
|
|
28
|
+
*/
|
|
29
|
+
readonly broadcast?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Files the message carries, at most `MAX_EMAIL_ATTACHMENTS`, together at most the transport's `maxAttachmentBytes`.
|
|
32
|
+
* A transport without `maxAttachmentBytes` refuses a message that has any; an empty list is none.
|
|
33
|
+
*/
|
|
34
|
+
readonly attachments?: readonly EmailAttachment[];
|
|
35
|
+
}
|
|
36
|
+
export interface EmailDeliveryResult {
|
|
37
|
+
readonly status: 'accepted';
|
|
38
|
+
readonly provider: string;
|
|
39
|
+
readonly messageId: string;
|
|
40
|
+
}
|
|
41
|
+
export interface EmailSenderCapabilities {
|
|
42
|
+
/** A repeated send under one key with the same message is not delivered again. */
|
|
43
|
+
readonly idempotent: boolean;
|
|
44
|
+
/** 'sync': `accepted` means the provider took the message. 'queued': it queued the request and may still drop it. */
|
|
45
|
+
readonly confirmation: 'sync' | 'queued';
|
|
46
|
+
/** The most messages one call carries. `send` carries one, so every built-in adapter reports 1. */
|
|
47
|
+
readonly maxBatch: number;
|
|
48
|
+
/** Whether it writes a message's one-click unsubscribe headers (`unsubscribeUrl`); absent is no. */
|
|
49
|
+
readonly listUnsubscribe?: boolean;
|
|
50
|
+
/** The most attachment bytes one message carries, every file together; absent: it sends no attachments. */
|
|
51
|
+
readonly maxAttachmentBytes?: number;
|
|
52
|
+
/** File extensions it refuses an attachment for, lowercase and without the dot; absent: none. */
|
|
53
|
+
readonly refusedAttachmentExtensions?: readonly string[];
|
|
54
|
+
/** The fewest characters a text or HTML body has once trimmed; absent: any body that is not blank. */
|
|
55
|
+
readonly minBodyLength?: number;
|
|
56
|
+
/** Whether it sends a `broadcast` message, on its provider's channel for them where it has one; absent is no. */
|
|
57
|
+
readonly broadcast?: boolean;
|
|
58
|
+
}
|
|
59
|
+
export interface EmailSender {
|
|
60
|
+
readonly capabilities: EmailSenderCapabilities;
|
|
61
|
+
send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult>;
|
|
62
|
+
}
|
|
63
|
+
/** 'not_sent': the provider did not take the message. 'unknown': it may have taken it. */
|
|
64
|
+
export type EmailDeliveryOutcome = 'not_sent' | 'unknown';
|
|
65
|
+
/** The one classification of every failure: an adapter names the code, and the code decides the rest. */
|
|
66
|
+
export declare const EMAIL_DELIVERY_ERRORS: {
|
|
67
|
+
readonly invalid_configuration: {
|
|
68
|
+
readonly retryable: false;
|
|
69
|
+
readonly outcome: "not_sent";
|
|
70
|
+
};
|
|
71
|
+
readonly invalid_message: {
|
|
72
|
+
readonly retryable: false;
|
|
73
|
+
readonly outcome: "not_sent";
|
|
74
|
+
};
|
|
75
|
+
readonly mail_not_configured: {
|
|
76
|
+
readonly retryable: false;
|
|
77
|
+
readonly outcome: "not_sent";
|
|
78
|
+
};
|
|
79
|
+
readonly aborted: {
|
|
80
|
+
readonly retryable: true;
|
|
81
|
+
readonly outcome: "unknown";
|
|
82
|
+
};
|
|
83
|
+
readonly network_error: {
|
|
84
|
+
readonly retryable: true;
|
|
85
|
+
readonly outcome: "unknown";
|
|
86
|
+
};
|
|
87
|
+
readonly request_timeout: {
|
|
88
|
+
readonly retryable: true;
|
|
89
|
+
readonly outcome: "unknown";
|
|
90
|
+
};
|
|
91
|
+
readonly request_in_progress: {
|
|
92
|
+
readonly retryable: true;
|
|
93
|
+
readonly outcome: "unknown";
|
|
94
|
+
};
|
|
95
|
+
readonly idempotency_conflict: {
|
|
96
|
+
readonly retryable: false;
|
|
97
|
+
readonly outcome: "unknown";
|
|
98
|
+
};
|
|
99
|
+
readonly provider_unauthorized: {
|
|
100
|
+
readonly retryable: false;
|
|
101
|
+
readonly outcome: "not_sent";
|
|
102
|
+
};
|
|
103
|
+
readonly rate_limited: {
|
|
104
|
+
readonly retryable: true;
|
|
105
|
+
readonly outcome: "not_sent";
|
|
106
|
+
};
|
|
107
|
+
readonly quota_exceeded: {
|
|
108
|
+
readonly retryable: true;
|
|
109
|
+
readonly outcome: "not_sent";
|
|
110
|
+
};
|
|
111
|
+
readonly provider_rejected: {
|
|
112
|
+
readonly retryable: false;
|
|
113
|
+
readonly outcome: "not_sent";
|
|
114
|
+
};
|
|
115
|
+
readonly provider_unavailable: {
|
|
116
|
+
readonly retryable: true;
|
|
117
|
+
readonly outcome: "unknown";
|
|
118
|
+
};
|
|
119
|
+
readonly malformed_response: {
|
|
120
|
+
readonly retryable: true;
|
|
121
|
+
readonly outcome: "unknown";
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
export type EmailDeliveryErrorCode = keyof typeof EMAIL_DELIVERY_ERRORS;
|
|
125
|
+
export interface EmailDeliveryErrorOptions {
|
|
126
|
+
readonly code: EmailDeliveryErrorCode;
|
|
127
|
+
readonly httpStatus?: number;
|
|
128
|
+
}
|
|
129
|
+
/** Its message is a fixed string plus the HTTP status: never a credential, a response body, a subject or a body. */
|
|
130
|
+
export declare class EmailDeliveryError extends Error {
|
|
131
|
+
readonly code: EmailDeliveryErrorCode;
|
|
132
|
+
readonly retryable: boolean;
|
|
133
|
+
readonly outcome: EmailDeliveryOutcome;
|
|
134
|
+
readonly httpStatus: number | undefined;
|
|
135
|
+
constructor(message: string, options: EmailDeliveryErrorOptions);
|
|
136
|
+
}
|
|
137
|
+
/** The two headers of a one-click unsubscribe (RFC 8058), as a transport writes them for `unsubscribeUrl`. */
|
|
138
|
+
export declare function oneClickUnsubscribeHeaders(url: string): Readonly<Record<string, string>>;
|
|
139
|
+
/** The one retry rule: an unknown outcome is retried only over an idempotent transport. */
|
|
140
|
+
export declare function mayRetryDelivery(error: EmailDeliveryError, capabilities: EmailSenderCapabilities): boolean;
|
|
141
|
+
/** The bytes of every attachment of `message` together. */
|
|
142
|
+
export declare function attachmentBytes(message: EmailMessage): number;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { EmailSenderIdentity } from './config.js';
|
|
3
|
+
import { EmailDeliveryError, type EmailDeliveryResult, type EmailFetch, type EmailMessage, type EmailSender, type EmailSenderCapabilities } from './email.js';
|
|
4
|
+
import { type ProviderResponse } from './http.js';
|
|
5
|
+
/** The options every HTTP adapter takes beside its credentials. */
|
|
6
|
+
export interface HttpEmailSenderOptions {
|
|
7
|
+
readonly from: EmailSenderIdentity;
|
|
8
|
+
readonly fetch?: EmailFetch;
|
|
9
|
+
readonly timeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface HttpEmailRequest {
|
|
12
|
+
readonly url: string;
|
|
13
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
14
|
+
readonly body: unknown;
|
|
15
|
+
}
|
|
16
|
+
export declare abstract class HttpEmailSender implements EmailSender {
|
|
17
|
+
#private;
|
|
18
|
+
abstract readonly capabilities: EmailSenderCapabilities;
|
|
19
|
+
protected readonly from: EmailSenderIdentity;
|
|
20
|
+
/** `label` names the provider in every message, `provider` in every result. An adapter checks its credentials first. */
|
|
21
|
+
protected constructor(names: {
|
|
22
|
+
readonly label: string;
|
|
23
|
+
readonly provider: string;
|
|
24
|
+
}, options: HttpEmailSenderOptions);
|
|
25
|
+
/** The request for a message that passed validation. */
|
|
26
|
+
protected abstract request(message: EmailMessage): HttpEmailRequest | Promise<HttpEmailRequest>;
|
|
27
|
+
/** The message id of a 2xx answer to `message`; undefined when the answer is not a valid success. May throw a refusal. */
|
|
28
|
+
protected abstract messageId(response: ProviderResponse, message: EmailMessage): string | undefined;
|
|
29
|
+
/** A failed answer to the message the provider tells apart beyond its status; undefined leaves it to `refusalByStatus`. */
|
|
30
|
+
protected refusal(_response: ProviderResponse, _message: EmailMessage): EmailDeliveryError | undefined;
|
|
31
|
+
send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult>;
|
|
32
|
+
}
|
|
33
|
+
/** The classification of a failed answer by its status alone, for every answer an adapter does not tell apart. */
|
|
34
|
+
export declare function refusalByStatus(label: string, status: number): EmailDeliveryError;
|
|
35
|
+
/** `Name <address>` for an API that takes the sender as one header value; validation keeps the name quotable. */
|
|
36
|
+
export declare function formatMailbox(address: string, name: string | undefined): string;
|
|
37
|
+
/** An API key or token: non-empty visible ASCII, so it fits a header unchanged. */
|
|
38
|
+
export declare function isCredential(value: string): boolean;
|
|
39
|
+
/** The name of a provider's stream or route: 1..64 letters, digits, `_` and `-`. */
|
|
40
|
+
export declare function isChannelName(value: string): boolean;
|
|
41
|
+
/** A provider variable that holds a credential. */
|
|
42
|
+
export declare const credentialVariable: z.ZodEffects<z.ZodString, string, string>;
|
|
43
|
+
/** An optional provider variable: empty is absent, and a present value must pass `valid`. */
|
|
44
|
+
export declare function optionalVariable(valid: (value: string) => boolean): z.ZodEffects<z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, string | undefined>, string | undefined, string | undefined>;
|
|
45
|
+
/**
|
|
46
|
+
* The message's key as a provider that takes at most `maxLength` characters sends it. A longer key is sent as its
|
|
47
|
+
* SHA-256, which keeps one key one key: a retry sends the same digest, another message another.
|
|
48
|
+
*/
|
|
49
|
+
export declare function providerIdempotencyKey(key: string, maxLength: number): Promise<string>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type EmailFetch } from './email.js';
|
|
2
|
+
export interface ProviderRequest {
|
|
3
|
+
/** The provider's name as it appears in error messages. */
|
|
4
|
+
readonly label: string;
|
|
5
|
+
readonly fetch: EmailFetch;
|
|
6
|
+
readonly url: string;
|
|
7
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
8
|
+
readonly body: unknown;
|
|
9
|
+
readonly signal: AbortSignal;
|
|
10
|
+
readonly timeoutMs: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ProviderResponse {
|
|
13
|
+
readonly ok: boolean;
|
|
14
|
+
readonly status: number;
|
|
15
|
+
/** The parsed JSON body, or undefined when it is not JSON. Never put into an error. */
|
|
16
|
+
readonly body: unknown;
|
|
17
|
+
}
|
|
18
|
+
/** The answer with its body already read, so an abort or a timeout while the body arrives is classified as one. */
|
|
19
|
+
export declare function postToProvider(request: ProviderRequest): Promise<ProviderResponse>;
|
|
20
|
+
export declare function parseTimeout(label: string, value: number | undefined): number;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { EMAIL_PROVIDER_VARIABLE, emailSenderFromEnv, NO_EMAIL_PROVIDER, parseProviderVariables, pickEmailEnvironment, readEmailTransportConfig, } from './config.js';
|
|
2
|
+
export type { EmailEnvironment, EmailProvider, EmailProviderRegistry, EmailProviderVariable, EmailSenderIdentity, EmailTransportConfig, } from './config.js';
|
|
3
|
+
export { attachmentBytes, EMAIL_DELIVERY_ERRORS, EmailDeliveryError, mayRetryDelivery, oneClickUnsubscribeHeaders } from './email.js';
|
|
4
|
+
export type { EmailAttachment, EmailDeliveryErrorCode, EmailDeliveryErrorOptions, EmailDeliveryOutcome, EmailDeliveryResult, EmailFetch, EmailMessage, EmailSender, EmailSenderCapabilities, } from './email.js';
|
|
5
|
+
export { isUnsubscribeUrl, isValidAttachmentFilename, isValidEmailAddress, isValidEmailDisplayName, isValidEmailFrom, MAX_EMAIL_ATTACHMENTS, MAX_EMAIL_SUBJECT_LENGTH, MAX_UNSUBSCRIBE_URL_LENGTH, validateEmailAttachments, validateEmailBodies, validateEmailBroadcast, validateEmailMessage, } from './validation.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type EmailProvider } from './config.js';
|
|
2
|
+
import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities } from './email.js';
|
|
3
|
+
import { type HttpEmailRequest, HttpEmailSender, type HttpEmailSenderOptions } from './http-sender.js';
|
|
4
|
+
import type { ProviderResponse } from './http.js';
|
|
5
|
+
export interface LettermintEmailSenderOptions extends HttpEmailSenderOptions {
|
|
6
|
+
/** A project API token: it sends through that project's default route. */
|
|
7
|
+
readonly projectToken: string;
|
|
8
|
+
/** The slug of the project's broadcast route, which a `broadcast` message goes through; `broadcast` by default. */
|
|
9
|
+
readonly broadcastRoute?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare class LettermintEmailSender extends HttpEmailSender {
|
|
12
|
+
#private;
|
|
13
|
+
readonly capabilities: EmailSenderCapabilities;
|
|
14
|
+
constructor(options: LettermintEmailSenderOptions);
|
|
15
|
+
protected request(message: EmailMessage): Promise<HttpEmailRequest>;
|
|
16
|
+
protected messageId(response: ProviderResponse): string | undefined;
|
|
17
|
+
protected refusal(response: ProviderResponse, message: EmailMessage): EmailDeliveryError | undefined;
|
|
18
|
+
}
|
|
19
|
+
export declare const lettermintEmailProvider: EmailProvider;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type EmailDeliveryErrorCode, type EmailDeliveryResult, type EmailMessage, type EmailSender, type EmailSenderCapabilities } from './email.js';
|
|
2
|
+
export interface MemoryEmail {
|
|
3
|
+
readonly message: EmailMessage;
|
|
4
|
+
readonly messageId: string;
|
|
5
|
+
readonly acceptedAt: number;
|
|
6
|
+
}
|
|
7
|
+
export interface MemoryHandoff {
|
|
8
|
+
readonly message: EmailMessage;
|
|
9
|
+
readonly at: number;
|
|
10
|
+
/** Whether this call added the message to `accepted`. */
|
|
11
|
+
readonly taken: boolean;
|
|
12
|
+
readonly answer: 'accepted' | EmailDeliveryErrorCode;
|
|
13
|
+
}
|
|
14
|
+
export type MemoryRefusal = 'provider_rejected' | 'provider_unauthorized' | 'rate_limited' | 'quota_exceeded';
|
|
15
|
+
export interface MemoryEmailSenderOptions {
|
|
16
|
+
readonly capabilities?: Partial<EmailSenderCapabilities>;
|
|
17
|
+
readonly provider?: string;
|
|
18
|
+
readonly now?: () => number;
|
|
19
|
+
}
|
|
20
|
+
type MemoryHook = (message: EmailMessage, signal: AbortSignal) => Promise<void>;
|
|
21
|
+
export declare class MemoryEmailSender implements EmailSender {
|
|
22
|
+
#private;
|
|
23
|
+
readonly capabilities: EmailSenderCapabilities;
|
|
24
|
+
constructor(options?: MemoryEmailSenderOptions);
|
|
25
|
+
/** What reached the inbox, oldest first. */
|
|
26
|
+
get accepted(): readonly MemoryEmail[];
|
|
27
|
+
/** Every call that passed validation and the abort check. */
|
|
28
|
+
get handoffs(): readonly MemoryHandoff[];
|
|
29
|
+
/** Every following call is refused with `code` and takes nothing. */
|
|
30
|
+
refuse(code?: MemoryRefusal): void;
|
|
31
|
+
/** Every following call answers `provider_unavailable` and takes nothing. */
|
|
32
|
+
unavailable(): void;
|
|
33
|
+
/** Every following call answers `network_error` and takes nothing. */
|
|
34
|
+
unreachable(): void;
|
|
35
|
+
/** The next `count` calls take the message, then answer `network_error`. */
|
|
36
|
+
loseAnswers(count: number): void;
|
|
37
|
+
/** Runs after the handoff is recorded and before the answer; a rejection becomes `aborted` once the signal aborted, else `network_error`. */
|
|
38
|
+
during(hook: MemoryHook | null): void;
|
|
39
|
+
heal(): void;
|
|
40
|
+
send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult>;
|
|
41
|
+
}
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type EmailDeliveryErrorCode, type EmailMessage, type EmailSenderCapabilities } from './email.js';
|
|
2
|
+
/** The relay's path under the receiver's URL (`FABRIKA_PLATFORM_MAIL_URL`). */
|
|
3
|
+
export declare const PLATFORM_MAIL_SEND_PATH = "/platform/mail/send";
|
|
4
|
+
/** What the relay promises: it records the message under its key and dispatches it later, perhaps after a person releases it. */
|
|
5
|
+
export declare const PLATFORM_MAIL_CAPABILITIES: EmailSenderCapabilities;
|
|
6
|
+
/**
|
|
7
|
+
* The request body as JSON text. It is canonical: two messages with the same content encode to the same text, so the
|
|
8
|
+
* relay fingerprints and stores this form.
|
|
9
|
+
*/
|
|
10
|
+
export declare function encodePlatformMailRequest(message: EmailMessage): string;
|
|
11
|
+
/** The SHA-256 (hex) of the canonical request body: what tells a repeated send from a different one under the same key. */
|
|
12
|
+
export declare function platformMailFingerprint(message: EmailMessage): Promise<string>;
|
|
13
|
+
/** The SHA-256 (hex) of a transport key: the only form the receiver stores. */
|
|
14
|
+
export declare function hashPlatformKey(key: string): Promise<string>;
|
|
15
|
+
/**
|
|
16
|
+
* The key an installation service (IAM) sends to the relay with: derived from the installation secret both it and
|
|
17
|
+
* Control already hold, so an existing installation needs no new secret.
|
|
18
|
+
*/
|
|
19
|
+
export declare function installationMailKey(installationSecret: string): Promise<string>;
|
|
20
|
+
export type PlatformMailRequestResult = {
|
|
21
|
+
readonly ok: true;
|
|
22
|
+
readonly message: EmailMessage;
|
|
23
|
+
}
|
|
24
|
+
/** `reason` is a fixed text that names no recipient, subject or body. */
|
|
25
|
+
| {
|
|
26
|
+
readonly ok: false;
|
|
27
|
+
readonly code: 'invalid_message';
|
|
28
|
+
readonly reason: string;
|
|
29
|
+
};
|
|
30
|
+
/** The relay's one parser of a request body (parsed JSON): a message the package's own validation accepts, or a refusal. */
|
|
31
|
+
export declare function parsePlatformMailRequest(body: unknown): PlatformMailRequestResult;
|
|
32
|
+
/** The HTTP status of each error the relay answers. */
|
|
33
|
+
export declare const PLATFORM_MAIL_ERROR_STATUS: {
|
|
34
|
+
readonly invalid_message: 400;
|
|
35
|
+
readonly provider_unauthorized: 401;
|
|
36
|
+
readonly idempotency_conflict: 409;
|
|
37
|
+
readonly mail_not_configured: 503;
|
|
38
|
+
readonly provider_unavailable: 503;
|
|
39
|
+
};
|
|
40
|
+
export type PlatformMailErrorCode = keyof typeof PLATFORM_MAIL_ERROR_STATUS;
|
|
41
|
+
export interface PlatformMailAccepted {
|
|
42
|
+
readonly status: 'accepted';
|
|
43
|
+
readonly messageId: string;
|
|
44
|
+
}
|
|
45
|
+
/** The relay's answer as a sender reads it: any `EmailDeliveryErrorCode`, so its `retryable` and `outcome` carry over. */
|
|
46
|
+
export type PlatformMailAnswer = PlatformMailAccepted | {
|
|
47
|
+
readonly error: {
|
|
48
|
+
readonly code: EmailDeliveryErrorCode;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
/** The relay's answer as it writes one: an acceptance, or an error that has a status. */
|
|
52
|
+
export type PlatformMailReply = PlatformMailAccepted | {
|
|
53
|
+
readonly error: {
|
|
54
|
+
readonly code: PlatformMailErrorCode;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
/** `200 { status: 'accepted', messageId }`, or the error's status with `{ error: { code } }`. */
|
|
58
|
+
export declare function platformMailAnswerResponse(reply: PlatformMailReply): Response;
|
|
59
|
+
/**
|
|
60
|
+
* The relay's answer from its status and parsed JSON body, or `null` when it is no answer the relay writes: an
|
|
61
|
+
* acceptance under any status but 200, an error code under a success status, or anything else.
|
|
62
|
+
*/
|
|
63
|
+
export declare function parsePlatformMailAnswer(status: number, body: unknown): PlatformMailAnswer | null;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type EmailDeliveryResult, type EmailFetch, type EmailMessage, type EmailSender, type EmailSenderCapabilities } from './email.js';
|
|
2
|
+
export { encodePlatformMailRequest, hashPlatformKey, installationMailKey, parsePlatformMailAnswer, parsePlatformMailRequest, PLATFORM_MAIL_CAPABILITIES, PLATFORM_MAIL_ERROR_STATUS, PLATFORM_MAIL_SEND_PATH, platformMailAnswerResponse, platformMailFingerprint, } from './platform-wire.js';
|
|
3
|
+
export type { PlatformMailAccepted, PlatformMailAnswer, PlatformMailErrorCode, PlatformMailReply, PlatformMailRequestResult, } from './platform-wire.js';
|
|
4
|
+
/** `FABRIKA_EMAIL_PROVIDER=platform`: the application sends through the platform's relay, not a provider of its own. */
|
|
5
|
+
export declare const PLATFORM_EMAIL_PROVIDER = "platform";
|
|
6
|
+
export interface PlatformEmailSenderOptions {
|
|
7
|
+
/** The receiver's http(s) origin (`FABRIKA_PLATFORM_MAIL_URL`); the send path is appended to it. */
|
|
8
|
+
readonly url: string;
|
|
9
|
+
readonly key: string;
|
|
10
|
+
readonly fetch?: EmailFetch;
|
|
11
|
+
readonly timeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
/** Hands each message to the platform's relay, which records it under its key and dispatches it later. */
|
|
14
|
+
export declare class PlatformEmailSender implements EmailSender {
|
|
15
|
+
#private;
|
|
16
|
+
readonly capabilities: EmailSenderCapabilities;
|
|
17
|
+
constructor(options: PlatformEmailSenderOptions);
|
|
18
|
+
send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type EmailProvider } from './config.js';
|
|
2
|
+
import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities } from './email.js';
|
|
3
|
+
import { type HttpEmailRequest, HttpEmailSender, type HttpEmailSenderOptions } from './http-sender.js';
|
|
4
|
+
import type { ProviderResponse } from './http.js';
|
|
5
|
+
export interface PostmarkEmailSenderOptions extends HttpEmailSenderOptions {
|
|
6
|
+
/** A server token: it sends through that server's default transactional stream. */
|
|
7
|
+
readonly serverToken: string;
|
|
8
|
+
/** The ID of the server's broadcast stream, which a `broadcast` message goes through; `broadcast` by default. */
|
|
9
|
+
readonly broadcastStream?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare class PostmarkEmailSender extends HttpEmailSender {
|
|
12
|
+
#private;
|
|
13
|
+
readonly capabilities: EmailSenderCapabilities;
|
|
14
|
+
constructor(options: PostmarkEmailSenderOptions);
|
|
15
|
+
protected request(message: EmailMessage): HttpEmailRequest;
|
|
16
|
+
protected messageId(response: ProviderResponse): string | undefined;
|
|
17
|
+
protected refusal(response: ProviderResponse): EmailDeliveryError | undefined;
|
|
18
|
+
}
|
|
19
|
+
export declare const postmarkEmailProvider: EmailProvider;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { EmailProviderVariable } from './config.js';
|
|
2
|
+
/**
|
|
3
|
+
* The providers `FABRIKA_EMAIL_PROVIDER` can name in a stock installation, sorted by name: the order every help text
|
|
4
|
+
* and refusal lists them in. Memory and Directory are composition inputs.
|
|
5
|
+
*/
|
|
6
|
+
export declare const builtinEmailProviders: {
|
|
7
|
+
ahasend: import("./config.js").EmailProvider;
|
|
8
|
+
cloudflare: import("./config.js").EmailProvider;
|
|
9
|
+
lettermint: import("./config.js").EmailProvider;
|
|
10
|
+
postmark: import("./config.js").EmailProvider;
|
|
11
|
+
resend: import("./config.js").EmailProvider;
|
|
12
|
+
smartemailing: import("./config.js").EmailProvider;
|
|
13
|
+
};
|
|
14
|
+
/** Every built-in provider's own variables, in registry order: what the installers write and the workflow passes on. */
|
|
15
|
+
export declare const builtinEmailProviderVariables: readonly EmailProviderVariable[];
|
|
16
|
+
/**
|
|
17
|
+
* The first framework release whose registry holds each built-in provider. An application on an older release builds
|
|
18
|
+
* its sender from its own registry, so it cannot be handed one it lacks; Control refuses that deploy. `null`: every
|
|
19
|
+
* release that sends through the installation (`mailer: 'installation'`, from 0.5.0) holds it. A new provider names
|
|
20
|
+
* the release after the newest one published without it.
|
|
21
|
+
*/
|
|
22
|
+
export declare const builtinEmailProviderSince: {
|
|
23
|
+
readonly ahasend: "0.8.2";
|
|
24
|
+
readonly cloudflare: "0.9.0";
|
|
25
|
+
readonly lettermint: "0.8.2";
|
|
26
|
+
readonly postmark: "0.8.2";
|
|
27
|
+
readonly resend: null;
|
|
28
|
+
readonly smartemailing: null;
|
|
29
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type EmailProvider } from './config.js';
|
|
2
|
+
import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities } from './email.js';
|
|
3
|
+
import { type HttpEmailRequest, HttpEmailSender, type HttpEmailSenderOptions } from './http-sender.js';
|
|
4
|
+
import type { ProviderResponse } from './http.js';
|
|
5
|
+
export interface ResendEmailSenderOptions extends HttpEmailSenderOptions {
|
|
6
|
+
readonly apiKey: string;
|
|
7
|
+
}
|
|
8
|
+
export declare class ResendEmailSender extends HttpEmailSender {
|
|
9
|
+
#private;
|
|
10
|
+
readonly capabilities: EmailSenderCapabilities;
|
|
11
|
+
constructor(options: ResendEmailSenderOptions);
|
|
12
|
+
protected request(message: EmailMessage): HttpEmailRequest;
|
|
13
|
+
protected messageId(response: ProviderResponse): string | undefined;
|
|
14
|
+
protected refusal(response: ProviderResponse): EmailDeliveryError | undefined;
|
|
15
|
+
}
|
|
16
|
+
export declare const resendEmailProvider: EmailProvider;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type EmailProvider } from './config.js';
|
|
2
|
+
import { type EmailMessage, type EmailSenderCapabilities } from './email.js';
|
|
3
|
+
import { type HttpEmailRequest, HttpEmailSender, type HttpEmailSenderOptions } from './http-sender.js';
|
|
4
|
+
import type { ProviderResponse } from './http.js';
|
|
5
|
+
export interface SmartEmailingEmailSenderOptions extends HttpEmailSenderOptions {
|
|
6
|
+
/** The account's user name or e-mail: the first half of the Basic credential. */
|
|
7
|
+
readonly username: string;
|
|
8
|
+
readonly apiKey: string;
|
|
9
|
+
/** Groups the messages in SmartEmailing's statistics. */
|
|
10
|
+
readonly tag?: string;
|
|
11
|
+
}
|
|
12
|
+
/** Its error body's `message` is free text that may quote the request, so a refusal is classified by status alone. */
|
|
13
|
+
export declare class SmartEmailingEmailSender extends HttpEmailSender {
|
|
14
|
+
#private;
|
|
15
|
+
/**
|
|
16
|
+
* The API takes up to five tasks a request, but `send` carries one. Its API documents no message headers, so a
|
|
17
|
+
* one-click unsubscribe is refused rather than sent without one.
|
|
18
|
+
*/
|
|
19
|
+
readonly capabilities: EmailSenderCapabilities;
|
|
20
|
+
constructor(options: SmartEmailingEmailSenderOptions);
|
|
21
|
+
protected request(message: EmailMessage): HttpEmailRequest;
|
|
22
|
+
protected messageId(response: ProviderResponse): string | undefined;
|
|
23
|
+
}
|
|
24
|
+
export declare const smartEmailingEmailProvider: EmailProvider;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { EmailSenderIdentity } from './config.js';
|
|
2
|
+
import { type EmailMessage, type EmailSenderCapabilities } from './email.js';
|
|
3
|
+
/** The longest subject a message may have, in UTF-16 code units: RFC 5322's line limit. */
|
|
4
|
+
export declare const MAX_EMAIL_SUBJECT_LENGTH = 998;
|
|
5
|
+
export declare function isValidEmailAddress(value: string): boolean;
|
|
6
|
+
/** A mailbox address, or the legacy `Name <address>` form. */
|
|
7
|
+
export declare function isValidEmailFrom(value: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Splits a bare address or the legacy `Name <address>` form. A name in one pair of double quotes loses them, since a
|
|
10
|
+
* transport quotes the name itself when it has to.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseEmailFrom(value: string): EmailSenderIdentity | null;
|
|
13
|
+
/** 1..128 characters, no control characters and none of `<>"\`, so a transport can always quote it. */
|
|
14
|
+
export declare function isValidEmailDisplayName(value: string): boolean;
|
|
15
|
+
export declare function isValidEmailSenderIdentity(identity: EmailSenderIdentity): boolean;
|
|
16
|
+
export declare function validateEmailMessage(message: EmailMessage): void;
|
|
17
|
+
/**
|
|
18
|
+
* Refuses a message whose attachments `capabilities` does not carry: any attachment where the transport sends none,
|
|
19
|
+
* more bytes together than its `maxAttachmentBytes`, or a file whose extension it refuses. `label` names the transport
|
|
20
|
+
* in the message.
|
|
21
|
+
*/
|
|
22
|
+
export declare function validateEmailAttachments(message: EmailMessage, capabilities: EmailSenderCapabilities, label: string): void;
|
|
23
|
+
/** Refuses a `broadcast` message where the transport sends none. */
|
|
24
|
+
export declare function validateEmailBroadcast(message: EmailMessage, capabilities: EmailSenderCapabilities, label: string): void;
|
|
25
|
+
/** Refuses a text or HTML body shorter than the transport's `minBodyLength`, counted in characters once trimmed. */
|
|
26
|
+
export declare function validateEmailBodies(message: EmailMessage, capabilities: EmailSenderCapabilities, label: string): void;
|
|
27
|
+
/** The most files one message carries. */
|
|
28
|
+
export declare const MAX_EMAIL_ATTACHMENTS = 20;
|
|
29
|
+
/** 1..255 characters, no control characters, no path separator and no `"`, so every transport can quote it. */
|
|
30
|
+
export declare function isValidAttachmentFilename(value: string): boolean;
|
|
31
|
+
/** The longest unsubscribe URL a header carries: one header line, with room for its name. */
|
|
32
|
+
export declare const MAX_UNSUBSCRIBE_URL_LENGTH = 2048;
|
|
33
|
+
/** An absolute `https:` URL as it is written, which a `<…>` header value carries unchanged. */
|
|
34
|
+
export declare function isUnsubscribeUrl(value: string): boolean;
|
|
35
|
+
export declare function isVisibleAscii(value: string): boolean;
|