@every-app/sdk 0.1.14 → 0.3.0-rc.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/README.md +608 -2
- package/dist/cloudflare/index.d.ts +21 -1
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +21 -1
- package/dist/cloudflare/sentinel.d.ts +69 -0
- package/dist/cloudflare/sentinel.d.ts.map +1 -0
- package/dist/cloudflare/sentinel.js +50 -0
- package/dist/cloudflare/worker.d.ts +511 -0
- package/dist/cloudflare/worker.d.ts.map +1 -0
- package/dist/cloudflare/worker.js +738 -0
- package/dist/dev/index.d.ts +8 -0
- package/dist/dev/index.d.ts.map +1 -0
- package/dist/dev/index.js +7 -0
- package/dist/dev/keypair.d.ts +24 -0
- package/dist/dev/keypair.d.ts.map +1 -0
- package/dist/dev/keypair.js +60 -0
- package/dist/internal/aiProviders.d.ts +67 -0
- package/dist/internal/aiProviders.d.ts.map +1 -0
- package/dist/internal/aiProviders.js +220 -0
- package/dist/internal/appSettings.d.ts +237 -0
- package/dist/internal/appSettings.d.ts.map +1 -0
- package/dist/internal/appSettings.js +374 -0
- package/dist/internal/capabilities.d.ts +1344 -0
- package/dist/internal/capabilities.d.ts.map +1 -0
- package/dist/internal/capabilities.js +1521 -0
- package/dist/internal/devProviders.d.ts +49 -0
- package/dist/internal/devProviders.d.ts.map +1 -0
- package/dist/internal/devProviders.js +62 -0
- package/dist/internal/email.d.ts +280 -0
- package/dist/internal/email.d.ts.map +1 -0
- package/dist/internal/email.js +613 -0
- package/dist/internal/events.d.ts +182 -0
- package/dist/internal/events.d.ts.map +1 -0
- package/dist/internal/events.js +324 -0
- package/dist/internal/gatewaySettings.d.ts +82 -0
- package/dist/internal/gatewaySettings.d.ts.map +1 -0
- package/dist/internal/gatewaySettings.js +209 -0
- package/dist/internal/identity.d.ts +88 -0
- package/dist/internal/identity.d.ts.map +1 -0
- package/dist/internal/identity.js +231 -0
- package/dist/internal/index.d.ts +19 -0
- package/dist/internal/index.d.ts.map +1 -0
- package/dist/internal/index.js +18 -0
- package/dist/internal/members.d.ts +35 -0
- package/dist/internal/members.d.ts.map +1 -0
- package/dist/internal/members.js +7 -0
- package/dist/internal/oauthClient.d.ts +63 -0
- package/dist/internal/oauthClient.d.ts.map +1 -0
- package/dist/internal/oauthClient.js +194 -0
- package/dist/internal/oauthScopes.d.ts +117 -0
- package/dist/internal/oauthScopes.d.ts.map +1 -0
- package/dist/internal/oauthScopes.js +416 -0
- package/dist/internal/organizationName.d.ts +18 -0
- package/dist/internal/organizationName.d.ts.map +1 -0
- package/dist/internal/organizationName.js +24 -0
- package/dist/internal/protocol.d.ts +195 -0
- package/dist/internal/protocol.d.ts.map +1 -0
- package/dist/internal/protocol.js +250 -0
- package/dist/internal/providerApps.d.ts +75 -0
- package/dist/internal/providerApps.d.ts.map +1 -0
- package/dist/internal/providerApps.js +59 -0
- package/dist/internal/providerRegistry.d.ts +147 -0
- package/dist/internal/providerRegistry.d.ts.map +1 -0
- package/dist/internal/providerRegistry.js +262 -0
- package/dist/internal/providerSpec.d.ts +55 -0
- package/dist/internal/providerSpec.d.ts.map +1 -0
- package/dist/internal/providerSpec.js +746 -0
- package/dist/internal/remoteMcp.d.ts +188 -0
- package/dist/internal/remoteMcp.d.ts.map +1 -0
- package/dist/internal/remoteMcp.js +543 -0
- package/dist/internal/tools.d.ts +115 -0
- package/dist/internal/tools.d.ts.map +1 -0
- package/dist/internal/tools.js +80 -0
- package/dist/server/attribution.d.ts +39 -0
- package/dist/server/attribution.d.ts.map +1 -0
- package/dist/server/attribution.js +67 -0
- package/dist/server/authorizeUrl.d.ts +43 -0
- package/dist/server/authorizeUrl.d.ts.map +1 -0
- package/dist/server/authorizeUrl.js +110 -0
- package/dist/server/emit.d.ts +63 -0
- package/dist/server/emit.d.ts.map +1 -0
- package/dist/server/emit.js +101 -0
- package/dist/server/everyApp.d.ts +81 -0
- package/dist/server/everyApp.d.ts.map +1 -0
- package/dist/server/everyApp.js +185 -0
- package/dist/server/gatewayFetch.d.ts +37 -0
- package/dist/server/gatewayFetch.d.ts.map +1 -0
- package/dist/server/gatewayFetch.js +115 -0
- package/dist/server/index.d.ts +18 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +15 -0
- package/dist/server/integrationFetch.d.ts +131 -0
- package/dist/server/integrationFetch.d.ts.map +1 -0
- package/dist/server/integrationFetch.js +526 -0
- package/dist/server/listMembers.d.ts +32 -0
- package/dist/server/listMembers.d.ts.map +1 -0
- package/dist/server/listMembers.js +74 -0
- package/dist/server/nodeServer.d.ts +48 -0
- package/dist/server/nodeServer.d.ts.map +1 -0
- package/dist/server/nodeServer.js +45 -0
- package/dist/server/sendEmail.d.ts +39 -0
- package/dist/server/sendEmail.d.ts.map +1 -0
- package/dist/server/sendEmail.js +114 -0
- package/dist/server/tools.d.ts +184 -0
- package/dist/server/tools.d.ts.map +1 -0
- package/dist/server/tools.js +347 -0
- package/dist/server/toolsEndpoint.d.ts +32 -0
- package/dist/server/toolsEndpoint.d.ts.map +1 -0
- package/dist/server/toolsEndpoint.js +231 -0
- package/package.json +21 -31
- package/dist/cloudflare/getLocalD1Url.d.ts +0 -2
- package/dist/cloudflare/getLocalD1Url.d.ts.map +0 -1
- package/dist/cloudflare/getLocalD1Url.js +0 -40
- package/dist/cloudflare/lazyInit.d.ts +0 -22
- package/dist/cloudflare/lazyInit.d.ts.map +0 -1
- package/dist/cloudflare/lazyInit.js +0 -65
- package/dist/cloudflare/server/gateway.d.ts +0 -29
- package/dist/cloudflare/server/gateway.d.ts.map +0 -1
- package/dist/cloudflare/server/gateway.js +0 -69
- package/dist/cloudflare/server/index.d.ts +0 -3
- package/dist/cloudflare/server/index.d.ts.map +0 -1
- package/dist/cloudflare/server/index.js +0 -2
- package/dist/core/authenticatedFetch.d.ts +0 -9
- package/dist/core/authenticatedFetch.d.ts.map +0 -1
- package/dist/core/authenticatedFetch.js +0 -31
- package/dist/core/index.d.ts +0 -4
- package/dist/core/index.d.ts.map +0 -1
- package/dist/core/index.js +0 -2
- package/dist/core/sessionManager.d.ts +0 -58
- package/dist/core/sessionManager.d.ts.map +0 -1
- package/dist/core/sessionManager.js +0 -357
- package/dist/shared/bypassGatewayLocalOnly.d.ts +0 -15
- package/dist/shared/bypassGatewayLocalOnly.d.ts.map +0 -1
- package/dist/shared/bypassGatewayLocalOnly.js +0 -45
- package/dist/shared/parseMessagePayload.d.ts +0 -3
- package/dist/shared/parseMessagePayload.d.ts.map +0 -1
- package/dist/shared/parseMessagePayload.js +0 -18
- package/dist/tanstack/EmbeddedAppProvider.d.ts +0 -30
- package/dist/tanstack/EmbeddedAppProvider.d.ts.map +0 -1
- package/dist/tanstack/EmbeddedAppProvider.js +0 -57
- package/dist/tanstack/GatewayRequiredError.d.ts +0 -20
- package/dist/tanstack/GatewayRequiredError.d.ts.map +0 -1
- package/dist/tanstack/GatewayRequiredError.js +0 -91
- package/dist/tanstack/_internal/useEveryAppSession.d.ts +0 -18
- package/dist/tanstack/_internal/useEveryAppSession.d.ts.map +0 -1
- package/dist/tanstack/_internal/useEveryAppSession.js +0 -47
- package/dist/tanstack/index.d.ts +0 -4
- package/dist/tanstack/index.d.ts.map +0 -1
- package/dist/tanstack/index.js +0 -3
- package/dist/tanstack/server/authConfig.d.ts +0 -3
- package/dist/tanstack/server/authConfig.d.ts.map +0 -1
- package/dist/tanstack/server/authConfig.js +0 -13
- package/dist/tanstack/server/authenticateRequest.d.ts +0 -31
- package/dist/tanstack/server/authenticateRequest.d.ts.map +0 -1
- package/dist/tanstack/server/authenticateRequest.js +0 -154
- package/dist/tanstack/server/index.d.ts +0 -4
- package/dist/tanstack/server/index.d.ts.map +0 -1
- package/dist/tanstack/server/index.js +0 -2
- package/dist/tanstack/server/types.d.ts +0 -5
- package/dist/tanstack/server/types.d.ts.map +0 -1
- package/dist/tanstack/server/types.js +0 -1
- package/dist/tanstack/useEveryAppRouter.d.ts +0 -7
- package/dist/tanstack/useEveryAppRouter.d.ts.map +0 -1
- package/dist/tanstack/useEveryAppRouter.js +0 -66
- package/dist/tanstack/useSessionTokenClientMiddleware.d.ts +0 -2
- package/dist/tanstack/useSessionTokenClientMiddleware.d.ts.map +0 -1
- package/dist/tanstack/useSessionTokenClientMiddleware.js +0 -29
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `email` capability's wire contract (docs/capabilities.md "Gateway
|
|
3
|
+
* services"): what an app sends to `POST /api/integrations/email/send`,
|
|
4
|
+
* how the request is checked, which senders and inbound addresses an
|
|
5
|
+
* installation may hold (`everyapp email set`), which provider a gateway
|
|
6
|
+
* sends with (Cloudflare Email Sending or Resend), and how the provider's
|
|
7
|
+
* answers map onto the gateway's refusals.
|
|
8
|
+
*
|
|
9
|
+
* The gateway enforces these, the CLI's `email set` checks the lists with
|
|
10
|
+
* the same validator, and the SDK's local-dev fallback applies the same
|
|
11
|
+
* checks and the same REST body, so a refusal in `everyapp dev` is the one
|
|
12
|
+
* production gives. Pure functions only: no env, no I/O.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The providers a gateway sends mail with, in the order it prefers them
|
|
16
|
+
* (docs/runbooks/email-sending-setup.md, "Choosing a provider"). One serves
|
|
17
|
+
* the whole gateway: its own mail and every app's.
|
|
18
|
+
*/
|
|
19
|
+
export const EMAIL_PROVIDER_IDS = ["cloudflare", "resend"];
|
|
20
|
+
/** Each provider's name, as messages give it. */
|
|
21
|
+
export const EMAIL_PROVIDER_LABELS = {
|
|
22
|
+
cloudflare: "Cloudflare Email Sending",
|
|
23
|
+
resend: "Resend",
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Which provider sends. EMAIL_PROVIDER (trimmed, any case), when set, names
|
|
27
|
+
* it, and it must be set up. Without it: the first provider, in
|
|
28
|
+
* {@link EMAIL_PROVIDER_IDS} order, the operator gave settings to, else one
|
|
29
|
+
* a binding alone sets up. The EMAIL binding is declared on every gateway
|
|
30
|
+
* Worker, so it can't be what makes Cloudflare win over a RESEND_API_KEY
|
|
31
|
+
* the operator set.
|
|
32
|
+
*/
|
|
33
|
+
export function chooseEmailProvider(emailProvider, setup) {
|
|
34
|
+
const requested = typeof emailProvider === "string" ? emailProvider.trim() : "";
|
|
35
|
+
if (requested) {
|
|
36
|
+
const provider = EMAIL_PROVIDER_IDS.find((id) => id === requested.toLowerCase());
|
|
37
|
+
if (!provider)
|
|
38
|
+
return { ok: false, error: "unknown_provider", requested };
|
|
39
|
+
return setup(provider)
|
|
40
|
+
? { ok: true, provider }
|
|
41
|
+
: { ok: false, error: "not_configured", requested: provider };
|
|
42
|
+
}
|
|
43
|
+
const provider = EMAIL_PROVIDER_IDS.find((id) => setup(id) === "settings") ??
|
|
44
|
+
EMAIL_PROVIDER_IDS.find((id) => setup(id) !== null);
|
|
45
|
+
return provider
|
|
46
|
+
? { ok: true, provider }
|
|
47
|
+
: { ok: false, error: "not_configured", requested: null };
|
|
48
|
+
}
|
|
49
|
+
/** Cloudflare Email Sending's limits the gateway checks before sending, and the lists an installation keeps on the gateway. */
|
|
50
|
+
export const EMAIL_LIMITS = {
|
|
51
|
+
/** The request body, read with a counting stream. */
|
|
52
|
+
bodyBytes: 5 * 1024 * 1024,
|
|
53
|
+
recipients: 50,
|
|
54
|
+
subjectChars: 998,
|
|
55
|
+
headers: 20,
|
|
56
|
+
headerValueBytes: 2048,
|
|
57
|
+
headerNameBytes: 100,
|
|
58
|
+
headersTotalBytes: 16 * 1024,
|
|
59
|
+
attachments: 32,
|
|
60
|
+
/** Sender patterns an installation may hold (`everyapp email set --sender`). */
|
|
61
|
+
senders: 20,
|
|
62
|
+
/** Inbound addresses an installation may hold (Email Routing allows 200 rules a domain). */
|
|
63
|
+
inbound: 50,
|
|
64
|
+
/** Email Routing's limit on a rule's address (its matcher's value). */
|
|
65
|
+
inboundAddressChars: 90,
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* A sender: `*@<domain>` (any address on exactly that domain) or
|
|
69
|
+
* `<local>@<domain>`, in lowercase. Set per installed app with
|
|
70
|
+
* `everyapp email set --sender`; the gateway reads them from its registry.
|
|
71
|
+
*/
|
|
72
|
+
export const EMAIL_SENDER_PATTERN = /^(\*|[a-z0-9._+-]+)@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*[a-z0-9]$/;
|
|
73
|
+
/**
|
|
74
|
+
* An inbound address Email Routing delivers to the app's Worker: `*@<domain>`,
|
|
75
|
+
* the catch-all of a zone's apex, or one address, in lowercase. The CLI finds
|
|
76
|
+
* the zone; a catch-all on a subdomain is refused there.
|
|
77
|
+
*/
|
|
78
|
+
export const EMAIL_INBOUND_PATTERN = /^(\*|[a-z0-9._+-]+)@([a-z0-9-]+\.)+[a-z]{2,}$/;
|
|
79
|
+
/** The command that sets an installation's senders and inbound addresses, for messages. */
|
|
80
|
+
export const EMAIL_SET_COMMAND = "everyapp email set";
|
|
81
|
+
/**
|
|
82
|
+
* Why `values` can't be an installation's senders or inbound addresses
|
|
83
|
+
* (`everyapp email set`), one sentence each; empty when they can. The
|
|
84
|
+
* rules the manifest's `capabilities.email.senders` and `email.inbound`
|
|
85
|
+
* had: the patterns above, no duplicates, at most EMAIL_LIMITS.senders or
|
|
86
|
+
* EMAIL_LIMITS.inbound entries, an inbound address at most
|
|
87
|
+
* EMAIL_LIMITS.inboundAddressChars long.
|
|
88
|
+
*/
|
|
89
|
+
export function emailListProblems(kind, values) {
|
|
90
|
+
const problems = [];
|
|
91
|
+
const limit = kind === "senders" ? EMAIL_LIMITS.senders : EMAIL_LIMITS.inbound;
|
|
92
|
+
if (values.length > limit) {
|
|
93
|
+
problems.push(`At most ${limit} ${kind === "senders" ? "senders" : "inbound addresses"}, not ${values.length}.`);
|
|
94
|
+
}
|
|
95
|
+
for (const value of values) {
|
|
96
|
+
if (kind === "inbound" && value.length > EMAIL_LIMITS.inboundAddressChars) {
|
|
97
|
+
problems.push(`${value} is longer than Email Routing's ${EMAIL_LIMITS.inboundAddressChars} characters.`);
|
|
98
|
+
}
|
|
99
|
+
else if (!(kind === "senders" ? EMAIL_SENDER_PATTERN : EMAIL_INBOUND_PATTERN).test(value)) {
|
|
100
|
+
problems.push(kind === "senders"
|
|
101
|
+
? `"${value}" isn't a sender: a sender is "*@<domain>" or "<local>@<domain>", in lowercase.`
|
|
102
|
+
: `"${value}" isn't an inbound address: an inbound address is "*@<domain>" or "<local>@<domain>", in lowercase.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const twice of new Set(values.filter((value, index) => values.indexOf(value) !== index))) {
|
|
106
|
+
problems.push(`${twice} is listed twice.`);
|
|
107
|
+
}
|
|
108
|
+
return problems;
|
|
109
|
+
}
|
|
110
|
+
/** A list as the row stores it: JSON, or null for none. */
|
|
111
|
+
export function storedEmailList(values) {
|
|
112
|
+
return values.length === 0 ? null : JSON.stringify(values);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The 403 `email_sender_not_allowed` sentence. `where` says what sets the
|
|
116
|
+
* senders: the gateway's registry row, or `.dev.vars` in local dev.
|
|
117
|
+
*/
|
|
118
|
+
export function emailSenderRefusal(address, where) {
|
|
119
|
+
const domain = `*@${emailDomain(address)}`;
|
|
120
|
+
return where === "gateway"
|
|
121
|
+
? `This app may not send as ${address}: list it, or ${domain}, among the app's senders with \`${EMAIL_SET_COMMAND} --sender\` in the app's directory.`
|
|
122
|
+
: `This app may not send as ${address}: in dev, list it, or ${domain}, in EVERYAPP_EMAIL_SENDERS in .dev.vars; deployed, \`${EMAIL_SET_COMMAND} --sender\` sets the app's senders.`;
|
|
123
|
+
}
|
|
124
|
+
/** An address the provider will take: printable ASCII, one `@`, a dotted domain. */
|
|
125
|
+
const ADDRESS = /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
|
|
126
|
+
const MAX_ADDRESS_LENGTH = 254;
|
|
127
|
+
const MAX_NAME_LENGTH = 256;
|
|
128
|
+
const MAX_FILENAME_LENGTH = 255;
|
|
129
|
+
const LINE_BREAK = /[\r\n]/;
|
|
130
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
131
|
+
const HEADER_NAME = /^[A-Za-z0-9-]+$/;
|
|
132
|
+
const MIME_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i;
|
|
133
|
+
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
134
|
+
/**
|
|
135
|
+
* Headers the message's own fields set, or the transport does: an app may
|
|
136
|
+
* not set them as headers (`From` would get past the sender rule).
|
|
137
|
+
*/
|
|
138
|
+
const RESERVED_HEADERS = new Set([
|
|
139
|
+
"from",
|
|
140
|
+
"sender",
|
|
141
|
+
"to",
|
|
142
|
+
"cc",
|
|
143
|
+
"bcc",
|
|
144
|
+
"reply-to",
|
|
145
|
+
"subject",
|
|
146
|
+
"return-path",
|
|
147
|
+
"date",
|
|
148
|
+
"mime-version",
|
|
149
|
+
"content-type",
|
|
150
|
+
"content-transfer-encoding",
|
|
151
|
+
"dkim-signature",
|
|
152
|
+
]);
|
|
153
|
+
const MESSAGE_FIELDS = new Set([
|
|
154
|
+
"to",
|
|
155
|
+
"cc",
|
|
156
|
+
"bcc",
|
|
157
|
+
"subject",
|
|
158
|
+
"text",
|
|
159
|
+
"html",
|
|
160
|
+
"from",
|
|
161
|
+
"replyTo",
|
|
162
|
+
"headers",
|
|
163
|
+
"attachments",
|
|
164
|
+
]);
|
|
165
|
+
/**
|
|
166
|
+
* Check a send request's JSON body against what Email Sending takes, so an
|
|
167
|
+
* app hears "bcc has 51 recipients" from the gateway rather than a bare
|
|
168
|
+
* provider error. Unknown fields are refused (a `reply_to` typo would
|
|
169
|
+
* otherwise vanish).
|
|
170
|
+
*/
|
|
171
|
+
export function parseSendEmailMessage(input) {
|
|
172
|
+
try {
|
|
173
|
+
return { ok: true, message: checkMessage(input) };
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (error instanceof EmailMessageProblem) {
|
|
177
|
+
return { ok: false, problem: error.message };
|
|
178
|
+
}
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
class EmailMessageProblem extends Error {
|
|
183
|
+
}
|
|
184
|
+
function problem(message) {
|
|
185
|
+
throw new EmailMessageProblem(message);
|
|
186
|
+
}
|
|
187
|
+
function checkMessage(input) {
|
|
188
|
+
if (!isRecord(input))
|
|
189
|
+
problem("The body is a JSON object.");
|
|
190
|
+
for (const field of Object.keys(input)) {
|
|
191
|
+
if (!MESSAGE_FIELDS.has(field)) {
|
|
192
|
+
problem(`Unknown field "${field}"; a message takes ${[...MESSAGE_FIELDS].join(", ")}.`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const to = recipients(input["to"], "to");
|
|
196
|
+
const cc = recipients(input["cc"] ?? [], "cc");
|
|
197
|
+
const bcc = recipients(input["bcc"] ?? [], "bcc");
|
|
198
|
+
if (to.length === 0)
|
|
199
|
+
problem("to names at least one recipient.");
|
|
200
|
+
const count = to.length + cc.length + bcc.length;
|
|
201
|
+
if (count > EMAIL_LIMITS.recipients) {
|
|
202
|
+
problem(`A message goes to at most ${EMAIL_LIMITS.recipients} recipients across to, cc and bcc (this one: ${count}).`);
|
|
203
|
+
}
|
|
204
|
+
const subject = input["subject"];
|
|
205
|
+
if (typeof subject !== "string")
|
|
206
|
+
problem("subject is a string.");
|
|
207
|
+
if (LINE_BREAK.test(subject))
|
|
208
|
+
problem("subject is one line.");
|
|
209
|
+
if (subject.length > EMAIL_LIMITS.subjectChars) {
|
|
210
|
+
problem(`subject is at most ${EMAIL_LIMITS.subjectChars} characters.`);
|
|
211
|
+
}
|
|
212
|
+
const text = optionalString(input["text"], "text");
|
|
213
|
+
const html = optionalString(input["html"], "html");
|
|
214
|
+
if (!text && !html)
|
|
215
|
+
problem("A message has a text or an html body.");
|
|
216
|
+
const message = { to, cc, bcc, subject };
|
|
217
|
+
if (text)
|
|
218
|
+
message.text = text;
|
|
219
|
+
if (html)
|
|
220
|
+
message.html = html;
|
|
221
|
+
if (input["from"] !== undefined) {
|
|
222
|
+
message.from = addressInput(input["from"], "from");
|
|
223
|
+
}
|
|
224
|
+
if (input["replyTo"] !== undefined) {
|
|
225
|
+
message.replyTo = addressInput(input["replyTo"], "replyTo");
|
|
226
|
+
}
|
|
227
|
+
if (input["headers"] !== undefined) {
|
|
228
|
+
message.headers = headers(input["headers"]);
|
|
229
|
+
}
|
|
230
|
+
if (input["attachments"] !== undefined) {
|
|
231
|
+
message.attachments = attachments(input["attachments"]);
|
|
232
|
+
}
|
|
233
|
+
return message;
|
|
234
|
+
}
|
|
235
|
+
function recipients(value, field) {
|
|
236
|
+
const list = typeof value === "string" ? [value] : value;
|
|
237
|
+
if (!Array.isArray(list)) {
|
|
238
|
+
problem(`${field} is an address or a list of addresses.`);
|
|
239
|
+
}
|
|
240
|
+
return list.map((entry, index) => {
|
|
241
|
+
if (typeof entry !== "string") {
|
|
242
|
+
problem(`${field}[${index}] is an address string.`);
|
|
243
|
+
}
|
|
244
|
+
return address(entry, `${field}[${index}]`);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
function address(value, field) {
|
|
248
|
+
const trimmed = value.trim();
|
|
249
|
+
if (trimmed.length > MAX_ADDRESS_LENGTH || !ADDRESS.test(trimmed)) {
|
|
250
|
+
problem(`${field} isn't an email address: "${value.slice(0, 80)}".`);
|
|
251
|
+
}
|
|
252
|
+
return trimmed;
|
|
253
|
+
}
|
|
254
|
+
function addressInput(value, field) {
|
|
255
|
+
if (typeof value === "string")
|
|
256
|
+
return { address: address(value, field) };
|
|
257
|
+
if (!isRecord(value)) {
|
|
258
|
+
problem(`${field} is an address, or { address, name }.`);
|
|
259
|
+
}
|
|
260
|
+
for (const key of Object.keys(value)) {
|
|
261
|
+
if (key !== "address" && key !== "name") {
|
|
262
|
+
problem(`${field} takes address and name, not "${key}".`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (typeof value["address"] !== "string") {
|
|
266
|
+
problem(`${field}.address is an address string.`);
|
|
267
|
+
}
|
|
268
|
+
const result = {
|
|
269
|
+
address: address(value["address"], `${field}.address`),
|
|
270
|
+
};
|
|
271
|
+
const name = value["name"];
|
|
272
|
+
if (name !== undefined && name !== null) {
|
|
273
|
+
if (typeof name !== "string")
|
|
274
|
+
problem(`${field}.name is a string.`);
|
|
275
|
+
if (CONTROL.test(name) || name.length > MAX_NAME_LENGTH) {
|
|
276
|
+
problem(`${field}.name is one line of at most ${MAX_NAME_LENGTH} characters.`);
|
|
277
|
+
}
|
|
278
|
+
if (name.trim())
|
|
279
|
+
result.name = name.trim();
|
|
280
|
+
}
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
function headers(value) {
|
|
284
|
+
if (!isRecord(value))
|
|
285
|
+
problem("headers is an object of names to values.");
|
|
286
|
+
const entries = Object.entries(value);
|
|
287
|
+
if (entries.length > EMAIL_LIMITS.headers) {
|
|
288
|
+
problem(`A message sets at most ${EMAIL_LIMITS.headers} headers.`);
|
|
289
|
+
}
|
|
290
|
+
let total = 0;
|
|
291
|
+
const result = {};
|
|
292
|
+
for (const [name, headerValue] of entries) {
|
|
293
|
+
if (!HEADER_NAME.test(name) ||
|
|
294
|
+
byteLength(name) > EMAIL_LIMITS.headerNameBytes) {
|
|
295
|
+
problem(`"${name.slice(0, 80)}" isn't a header name.`);
|
|
296
|
+
}
|
|
297
|
+
if (RESERVED_HEADERS.has(name.toLowerCase())) {
|
|
298
|
+
problem(`The ${name} header comes from the message's own fields or the transport; it can't be set as a header.`);
|
|
299
|
+
}
|
|
300
|
+
if (typeof headerValue !== "string") {
|
|
301
|
+
problem(`The ${name} header's value is a string.`);
|
|
302
|
+
}
|
|
303
|
+
if (CONTROL.test(headerValue)) {
|
|
304
|
+
problem(`The ${name} header's value is one line.`);
|
|
305
|
+
}
|
|
306
|
+
const bytes = byteLength(headerValue);
|
|
307
|
+
if (bytes > EMAIL_LIMITS.headerValueBytes) {
|
|
308
|
+
problem(`The ${name} header's value is at most ${EMAIL_LIMITS.headerValueBytes} bytes.`);
|
|
309
|
+
}
|
|
310
|
+
total += byteLength(name) + bytes;
|
|
311
|
+
result[name] = headerValue;
|
|
312
|
+
}
|
|
313
|
+
if (total > EMAIL_LIMITS.headersTotalBytes) {
|
|
314
|
+
problem(`A message's headers are at most ${EMAIL_LIMITS.headersTotalBytes / 1024} KB together.`);
|
|
315
|
+
}
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
function attachments(value) {
|
|
319
|
+
if (!Array.isArray(value))
|
|
320
|
+
problem("attachments is a list.");
|
|
321
|
+
if (value.length > EMAIL_LIMITS.attachments) {
|
|
322
|
+
problem(`A message has at most ${EMAIL_LIMITS.attachments} attachments.`);
|
|
323
|
+
}
|
|
324
|
+
return value.map((entry, index) => {
|
|
325
|
+
const field = `attachments[${index}]`;
|
|
326
|
+
if (!isRecord(entry))
|
|
327
|
+
problem(`${field} is an object.`);
|
|
328
|
+
const { filename, contentType, content, disposition, contentId } = entry;
|
|
329
|
+
for (const key of Object.keys(entry)) {
|
|
330
|
+
if (![
|
|
331
|
+
"filename",
|
|
332
|
+
"contentType",
|
|
333
|
+
"content",
|
|
334
|
+
"disposition",
|
|
335
|
+
"contentId",
|
|
336
|
+
].includes(key)) {
|
|
337
|
+
problem(`${field} has an unknown field "${key}".`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (typeof filename !== "string" ||
|
|
341
|
+
!filename.trim() ||
|
|
342
|
+
CONTROL.test(filename) ||
|
|
343
|
+
filename.length > MAX_FILENAME_LENGTH) {
|
|
344
|
+
problem(`${field}.filename is a file name on one line.`);
|
|
345
|
+
}
|
|
346
|
+
if (typeof contentType !== "string" || !MIME_TYPE.test(contentType)) {
|
|
347
|
+
problem(`${field}.contentType is a MIME type such as application/pdf.`);
|
|
348
|
+
}
|
|
349
|
+
if (typeof content !== "string" || !BASE64.test(content)) {
|
|
350
|
+
problem(`${field}.content is the file's bytes in base64.`);
|
|
351
|
+
}
|
|
352
|
+
if (disposition !== undefined &&
|
|
353
|
+
disposition !== "attachment" &&
|
|
354
|
+
disposition !== "inline") {
|
|
355
|
+
problem(`${field}.disposition is "attachment" or "inline".`);
|
|
356
|
+
}
|
|
357
|
+
const checked = {
|
|
358
|
+
filename: filename.trim(),
|
|
359
|
+
contentType,
|
|
360
|
+
content,
|
|
361
|
+
disposition: disposition ?? "attachment",
|
|
362
|
+
};
|
|
363
|
+
if (checked.disposition === "inline") {
|
|
364
|
+
if (typeof contentId !== "string" ||
|
|
365
|
+
!contentId.trim() ||
|
|
366
|
+
CONTROL.test(contentId)) {
|
|
367
|
+
problem(`${field} is inline, so it needs a contentId.`);
|
|
368
|
+
}
|
|
369
|
+
checked.contentId = contentId.trim();
|
|
370
|
+
}
|
|
371
|
+
else if (contentId !== undefined) {
|
|
372
|
+
problem(`${field}.contentId is for inline attachments only.`);
|
|
373
|
+
}
|
|
374
|
+
return checked;
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
function optionalString(value, field) {
|
|
378
|
+
if (value === undefined || value === null)
|
|
379
|
+
return undefined;
|
|
380
|
+
if (typeof value !== "string")
|
|
381
|
+
problem(`${field} is a string.`);
|
|
382
|
+
return value;
|
|
383
|
+
}
|
|
384
|
+
function isRecord(value) {
|
|
385
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
386
|
+
}
|
|
387
|
+
function byteLength(value) {
|
|
388
|
+
return new TextEncoder().encode(value).byteLength;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Whether `address` may be a message's From under `patterns` (the
|
|
392
|
+
* installation's senders, `apps.email_senders`): `*@example.com` takes any
|
|
393
|
+
* address on exactly example.com (not its subdomains), anything else that
|
|
394
|
+
* address alone. Case never matters.
|
|
395
|
+
*/
|
|
396
|
+
export function senderAllowed(address, patterns) {
|
|
397
|
+
const candidate = address.trim().toLowerCase();
|
|
398
|
+
const at = candidate.lastIndexOf("@");
|
|
399
|
+
if (at <= 0)
|
|
400
|
+
return false;
|
|
401
|
+
const domain = candidate.slice(at + 1);
|
|
402
|
+
return patterns.some((pattern) => {
|
|
403
|
+
const wanted = pattern.trim().toLowerCase();
|
|
404
|
+
return wanted.startsWith("*@")
|
|
405
|
+
? wanted.slice(2) === domain
|
|
406
|
+
: wanted === candidate;
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
/** The domain of an address, lowercased. */
|
|
410
|
+
export function emailDomain(address) {
|
|
411
|
+
return address.slice(address.lastIndexOf("@") + 1).toLowerCase();
|
|
412
|
+
}
|
|
413
|
+
/** The distinct recipients of a message, lowercased: what budgets count. */
|
|
414
|
+
export function distinctRecipients(message) {
|
|
415
|
+
return [
|
|
416
|
+
...new Set([...message.to, ...message.cc, ...message.bcc].map((recipient) => recipient.toLowerCase())),
|
|
417
|
+
];
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Codes for a message the provider found malformed: the binding's `E_*`
|
|
421
|
+
* (Resend's validation errors map onto `E_VALIDATION_ERROR`).
|
|
422
|
+
*/
|
|
423
|
+
const INVALID_MESSAGE_CODES = new Set([
|
|
424
|
+
"E_VALIDATION_ERROR",
|
|
425
|
+
"E_FIELD_MISSING",
|
|
426
|
+
"E_TOO_MANY_RECIPIENTS",
|
|
427
|
+
"E_TOO_MANY_ATTACHMENTS",
|
|
428
|
+
"E_CONTENT_TOO_LARGE",
|
|
429
|
+
"E_HEADER_NOT_ALLOWED",
|
|
430
|
+
"E_HEADER_USE_API_FIELD",
|
|
431
|
+
"E_HEADER_VALUE_INVALID",
|
|
432
|
+
"E_HEADER_VALUE_TOO_LONG",
|
|
433
|
+
"E_HEADER_NAME_INVALID",
|
|
434
|
+
"E_HEADERS_TOO_LARGE",
|
|
435
|
+
"E_HEADERS_TOO_MANY",
|
|
436
|
+
// REST: invalid request, too big, invalid email.
|
|
437
|
+
"10001",
|
|
438
|
+
"10200",
|
|
439
|
+
"10202",
|
|
440
|
+
]);
|
|
441
|
+
/**
|
|
442
|
+
* The refusal for an error the provider answered with, by its code:
|
|
443
|
+
* Cloudflare's binding `E_*` or REST numeric one, or the `E_*` code the
|
|
444
|
+
* gateway's Resend provider maps Resend's errors onto. `senderDomain` is
|
|
445
|
+
* the From's domain, for the step that verifies it with `provider`.
|
|
446
|
+
*/
|
|
447
|
+
export function emailProviderRefusal(providerCode, senderDomain, provider = "cloudflare") {
|
|
448
|
+
const label = EMAIL_PROVIDER_LABELS[provider];
|
|
449
|
+
const suffix = providerCode ? ` (${providerCode})` : "";
|
|
450
|
+
switch (providerCode) {
|
|
451
|
+
case "E_SENDER_NOT_VERIFIED":
|
|
452
|
+
case "E_SENDER_DOMAIN_NOT_AVAILABLE":
|
|
453
|
+
return {
|
|
454
|
+
status: 422,
|
|
455
|
+
code: "email_sender_not_verified",
|
|
456
|
+
message: provider === "resend"
|
|
457
|
+
? `Resend won't send from ${senderDomain}${suffix}: the domain must be verified in the gateway's Resend account. Its operator adds ${senderDomain} at https://resend.com/domains and publishes the DNS records Resend lists.`
|
|
458
|
+
: `Cloudflare Email Sending won't send from ${senderDomain}${suffix}: the domain must be onboarded in the gateway's Cloudflare account. Its operator runs: cf email-sending subdomains create -z ${senderDomain} --name ${senderDomain} (or Compute > Email Service > Email Sending > Onboard Domain in the dashboard).`,
|
|
459
|
+
};
|
|
460
|
+
case "E_RATE_LIMIT_EXCEEDED":
|
|
461
|
+
case "E_DAILY_LIMIT_EXCEEDED":
|
|
462
|
+
case "10004":
|
|
463
|
+
return {
|
|
464
|
+
status: 429,
|
|
465
|
+
code: "email_provider_limited",
|
|
466
|
+
message: `${label} refused the message: the gateway's account has reached its sending limit${suffix}. Try again later.`,
|
|
467
|
+
};
|
|
468
|
+
default:
|
|
469
|
+
if (providerCode && INVALID_MESSAGE_CODES.has(providerCode)) {
|
|
470
|
+
return {
|
|
471
|
+
status: 400,
|
|
472
|
+
code: "email_invalid_message",
|
|
473
|
+
message: `${label} refused the message as invalid${suffix}.`,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
status: 502,
|
|
478
|
+
code: "email_send_failed",
|
|
479
|
+
message: `${label} failed to send the message${suffix}.`,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/** The URL of Email Sending's REST `send` for an account. */
|
|
484
|
+
export function emailRestUrl(accountId) {
|
|
485
|
+
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/email/sending/send`;
|
|
486
|
+
}
|
|
487
|
+
/** A message as Email Sending's REST `send` takes it (snake_case fields). */
|
|
488
|
+
export function emailRestBody(message) {
|
|
489
|
+
const body = {
|
|
490
|
+
to: message.to.length === 1 ? message.to[0] : message.to,
|
|
491
|
+
from: restAddress(message.from),
|
|
492
|
+
// A reply goes to the sender unless the message says otherwise.
|
|
493
|
+
reply_to: restAddress(message.replyTo ?? message.from),
|
|
494
|
+
subject: message.subject,
|
|
495
|
+
};
|
|
496
|
+
if (message.cc.length > 0)
|
|
497
|
+
body["cc"] = message.cc;
|
|
498
|
+
if (message.bcc.length > 0)
|
|
499
|
+
body["bcc"] = message.bcc;
|
|
500
|
+
if (message.text)
|
|
501
|
+
body["text"] = message.text;
|
|
502
|
+
if (message.html)
|
|
503
|
+
body["html"] = message.html;
|
|
504
|
+
if (message.headers && Object.keys(message.headers).length > 0) {
|
|
505
|
+
body["headers"] = message.headers;
|
|
506
|
+
}
|
|
507
|
+
if (message.attachments && message.attachments.length > 0) {
|
|
508
|
+
body["attachments"] = message.attachments.map((attachment) => ({
|
|
509
|
+
content: attachment.content,
|
|
510
|
+
filename: attachment.filename,
|
|
511
|
+
type: attachment.contentType,
|
|
512
|
+
disposition: attachment.disposition,
|
|
513
|
+
...(attachment.contentId ? { content_id: attachment.contentId } : {}),
|
|
514
|
+
}));
|
|
515
|
+
}
|
|
516
|
+
return body;
|
|
517
|
+
}
|
|
518
|
+
function restAddress(address) {
|
|
519
|
+
return address.name
|
|
520
|
+
? { address: address.address, name: address.name }
|
|
521
|
+
: address.address;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Read Email Sending's REST answer: the message id and whether delivery is
|
|
525
|
+
* queued, or the provider's error code. A message whose every recipient
|
|
526
|
+
* bounced permanently failed; one that reached some of them did not.
|
|
527
|
+
*/
|
|
528
|
+
export function readEmailRestResponse(httpStatus, body) {
|
|
529
|
+
const parsed = (isRecord(body) ? body : {});
|
|
530
|
+
const error = parsed.errors?.[0];
|
|
531
|
+
if (httpStatus < 200 || httpStatus >= 300 || parsed.success === false) {
|
|
532
|
+
const code = error?.code !== undefined
|
|
533
|
+
? String(error.code)
|
|
534
|
+
: httpStatus === 429
|
|
535
|
+
? "10004"
|
|
536
|
+
: undefined;
|
|
537
|
+
return {
|
|
538
|
+
ok: false,
|
|
539
|
+
...(code ? { providerCode: code } : {}),
|
|
540
|
+
message: error?.message ?? `Email API returned ${httpStatus}.`,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
const result = parsed.result;
|
|
544
|
+
if (!result) {
|
|
545
|
+
return { ok: false, message: "Email API returned no delivery result." };
|
|
546
|
+
}
|
|
547
|
+
const delivered = result.delivered ?? [];
|
|
548
|
+
const queued = result.queued ?? [];
|
|
549
|
+
if (delivered.length === 0 &&
|
|
550
|
+
queued.length === 0 &&
|
|
551
|
+
(result.permanent_bounces ?? []).length > 0) {
|
|
552
|
+
return {
|
|
553
|
+
ok: false,
|
|
554
|
+
message: "Email API permanently bounced every recipient.",
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
ok: true,
|
|
559
|
+
...(result.message_id ? { messageId: result.message_id } : {}),
|
|
560
|
+
status: queued.length > 0 ? "queued" : "delivered",
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* The sender EMAIL_FROM and EMAIL_FROM_NAME configure: EMAIL_FROM is an
|
|
565
|
+
* address, or `Name <address>` with the name in it. Null unless both an
|
|
566
|
+
* address and a name are set.
|
|
567
|
+
*/
|
|
568
|
+
export function parseEmailSender(from, name) {
|
|
569
|
+
const configuredFrom = from?.trim();
|
|
570
|
+
if (!configuredFrom)
|
|
571
|
+
return null;
|
|
572
|
+
const combined = /^(.*?)\s*<([^<>]+)>$/.exec(configuredFrom);
|
|
573
|
+
const address = (combined?.[2] ?? configuredFrom).trim();
|
|
574
|
+
const combinedName = combined?.[1]
|
|
575
|
+
?.trim()
|
|
576
|
+
.replace(/^(["'])(.*)\1$/, "$2")
|
|
577
|
+
.trim();
|
|
578
|
+
const resolvedName = name?.trim() || combinedName;
|
|
579
|
+
return address && resolvedName ? { address, name: resolvedName } : null;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* The binding an app's Workers get in local dev when `.dev.vars` sets it
|
|
583
|
+
* (`everyAppWorker()` declares it like the other dev keys): the senders a
|
|
584
|
+
* gateway would hold for this installation, a comma- or whitespace-
|
|
585
|
+
* separated list or a JSON array. Deployed, the gateway reads them from the
|
|
586
|
+
* app's registry row (`apps.email_senders`, `everyapp email set --sender`).
|
|
587
|
+
*/
|
|
588
|
+
export const EMAIL_SENDERS_ENV = "EVERYAPP_EMAIL_SENDERS";
|
|
589
|
+
/**
|
|
590
|
+
* A list of addresses or patterns: an `apps` row's `email_senders` or
|
|
591
|
+
* `email_inbound` (JSON), or {@link EMAIL_SENDERS_ENV} (JSON, or separated
|
|
592
|
+
* by commas or whitespace). None when unset, or when JSON that isn't an
|
|
593
|
+
* array; other text is read as a comma or whitespace list, so a word no
|
|
594
|
+
* address matches allows no sender. Only `everyapp email set` writes the
|
|
595
|
+
* columns, as JSON; the admin page reads them as JSON only.
|
|
596
|
+
*/
|
|
597
|
+
export function parseEmailList(value) {
|
|
598
|
+
if (typeof value !== "string" || !value.trim())
|
|
599
|
+
return [];
|
|
600
|
+
const text = value.trim();
|
|
601
|
+
if (text.startsWith("[")) {
|
|
602
|
+
try {
|
|
603
|
+
const parsed = JSON.parse(text);
|
|
604
|
+
return Array.isArray(parsed)
|
|
605
|
+
? parsed.filter((entry) => typeof entry === "string")
|
|
606
|
+
: [];
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
return [];
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return text.split(/[\s,]+/).filter(Boolean);
|
|
613
|
+
}
|