void 0.20.0 → 0.20.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.
Files changed (93) hide show
  1. package/README.md +5 -1
  2. package/dist/{auth-W9WII-mN.mjs → auth-DPl6kck4.mjs} +46 -26
  3. package/dist/{auth-cmd-CYVhSNFy.mjs → auth-cmd-gniL2fNt.mjs} +5 -4
  4. package/dist/auth-link-NZdjCmSc.mjs +28 -0
  5. package/dist/{build-cmd-CkVD1uOh.mjs → build-cmd-sI18tX_O.mjs} +3 -3
  6. package/dist/{cache-QcUfR-ff.mjs → cache-IHn5MwBC.mjs} +3 -3
  7. package/dist/{cancel-deploy-DsvWKFTe.mjs → cancel-deploy-C5qTOdLi.mjs} +3 -3
  8. package/dist/cf-access-DRsQRe6k.mjs +75 -0
  9. package/dist/cli/cli.mjs +364 -1883
  10. package/dist/cli/env-schema-probe.mjs +11 -2
  11. package/dist/{client-CyCHWSO_.mjs → client-dHfSJvAN.mjs} +336 -58
  12. package/dist/{cloudflare-auth-DRkGe7-s.mjs → cloudflare-auth-6M5llVPC.mjs} +53 -6
  13. package/dist/{cloudflare-cmd-D3ME2GAe.mjs → cloudflare-cmd-4RPGN3KB.mjs} +2 -2
  14. package/dist/{cloudflare-connect-BivMefMA.mjs → cloudflare-connect-t1UU5svD.mjs} +2 -2
  15. package/dist/{cloudflare-operations-CPTpRW6d.mjs → cloudflare-operations-BzWnlC1_.mjs} +1 -1
  16. package/dist/{config-BQFq7QvD.mjs → config-uNGuFsI2.mjs} +1 -1
  17. package/dist/{connect-D9Yr-T5f.mjs → connect-Bfk31O_8.mjs} +6 -6
  18. package/dist/{create-project-DMV-csEm.mjs → create-project-Bk9Z0-Jg.mjs} +6 -5
  19. package/dist/{db-BxoUWL0F.mjs → db-BkRoptAt.mjs} +75 -48
  20. package/dist/{delete-iJOqxBz1.mjs → delete-DouASY9P.mjs} +3 -3
  21. package/dist/{deploy-CcDoeBIf.mjs → deploy-DTaWUS1S.mjs} +137 -128
  22. package/dist/{dev-inbox-DkgRWLkW.mjs → dev-inbox-P0u4tM8Y.mjs} +1 -1
  23. package/dist/{domain-gu_iaHmn.mjs → domain-1RhhOVrC.mjs} +4 -4
  24. package/dist/email-C-lGh51B.mjs +795 -0
  25. package/dist/{env-D4Emu-M_.mjs → env-DBKmK4vc.mjs} +1 -0
  26. package/dist/{env-DmU2To0C.mjs → env-DJHsPE7Z.mjs} +5 -5
  27. package/dist/{env-validation-ENpMy6Ez.mjs → env-validation-CF6KvTRf.mjs} +3 -1
  28. package/dist/{gen-BKw6qHIg.mjs → gen-B_wPnVTK.mjs} +3 -3
  29. package/dist/generate-RTK8_kK1.mjs +47 -0
  30. package/dist/{github-cmd-DJS5Ab-H.mjs → github-cmd-PW7ZnWTp.mjs} +3 -3
  31. package/dist/{headers-D8QfRX9Y.mjs → headers-BAHwgHdW.mjs} +1 -1
  32. package/dist/help-CwOX-zmI.mjs +2216 -0
  33. package/dist/{inbound-BJ70in1n.d.mts → inbound-CH5Mksyy.d.mts} +37 -42
  34. package/dist/{inbound-CNKxb3FY.mjs → inbound-aVHEUhKo.mjs} +143 -101
  35. package/dist/index.mjs +69 -30
  36. package/dist/{init-Dx5cgKqK.mjs → init-BWZ7q5Z4.mjs} +11 -11
  37. package/dist/{link-D_rm5sRH.mjs → link-Rmvu2Wl_.mjs} +4 -4
  38. package/dist/{list-M8XShti7.mjs → list-DEE2S6mY.mjs} +4 -4
  39. package/dist/{local-d1-BE8KBbMy.mjs → local-d1-D2I6Ox5F.mjs} +1 -1
  40. package/dist/{login-C8-UuLnp.mjs → login-Uvferzmm.mjs} +28 -10
  41. package/dist/{logs-DwFU7dMW.mjs → logs-27FenuiC.mjs} +4 -4
  42. package/dist/{mime-BJD7d_qL.mjs → mime-D5Nmdzf7.mjs} +23 -9
  43. package/dist/{node-BkaXcpAc.mjs → node-Ez5KW5rn.mjs} +3 -3
  44. package/dist/operator-auth-B3e08unv.mjs +52 -0
  45. package/dist/operator-client-LUZnlnYk.mjs +82 -0
  46. package/dist/{operator-cmd-DYWRbWUA.mjs → operator-cmd-CjOTmAYE.mjs} +35 -55
  47. package/dist/{output-tFQLLj26.mjs → output-B0cfNSx5.mjs} +316 -2
  48. package/dist/pages/index.mjs +2 -2
  49. package/dist/platform-auth-config-DrbQXXiW.mjs +368 -0
  50. package/dist/platform-auth-protection-Bhtvp0B_.mjs +219 -0
  51. package/dist/platform-auth-recovery-CeOKGVeJ.mjs +310 -0
  52. package/dist/{platform-cmd-BEOVv1PN.mjs → platform-cmd-BFhieCdV.mjs} +16 -6
  53. package/dist/{platform-domain-BszUfiS8.mjs → platform-domain-C74PULqV.mjs} +4 -4
  54. package/dist/{platform-lifecycle-CjSr6xf0.mjs → platform-lifecycle-BwAIgz-t.mjs} +1667 -191
  55. package/dist/{platform-management-W30iCaTL.mjs → platform-management-COogu_Se.mjs} +33 -7
  56. package/dist/{platform-recovery-pHHv4ZSg.mjs → platform-recovery-ewqLefp1.mjs} +6 -5
  57. package/dist/{prepare-CBetXvsN.mjs → prepare-CtDJjoOj.mjs} +2 -2
  58. package/dist/{prepare-BfJvFUtJ.mjs → prepare-blNRQvQl.mjs} +2 -2
  59. package/dist/prerender-render.d.mts +11 -0
  60. package/dist/prerender-render.mjs +111 -0
  61. package/dist/{project-cmd-DCpk1cDt.mjs → project-cmd-DmZK9Hxf.mjs} +32 -14
  62. package/dist/project-team-D8jOJMUJ.mjs +130 -0
  63. package/dist/project-token-DA34bf-C.mjs +75 -0
  64. package/dist/{provision-Blnstcm2.mjs → provision-CSJOjjQk.mjs} +2 -0
  65. package/dist/{requests-Dn8Vheh1.mjs → requests-CUExwGQQ.mjs} +3 -3
  66. package/dist/{rollback-CtlPXEBi.mjs → rollback-CDNGU1gr.mjs} +4 -4
  67. package/dist/{runner-mysql-CgRFl3s6.mjs → runner-mysql-7BPUNGmL.mjs} +1 -1
  68. package/dist/{runner-pg-DCkWPsWS.mjs → runner-pg-BkEza-dX.mjs} +1 -1
  69. package/dist/runtime/ai.mjs +3 -2
  70. package/dist/runtime/email/testing.d.mts +1 -1
  71. package/dist/runtime/email/testing.mjs +3 -3
  72. package/dist/runtime/email-protocol.d.mts +15 -0
  73. package/dist/runtime/email-protocol.mjs +70 -0
  74. package/dist/runtime/email.d.mts +2 -2
  75. package/dist/runtime/email.mjs +189 -96
  76. package/dist/runtime/remote/index.mjs +5 -3
  77. package/dist/{secret-BqTxGqki.mjs → secret-Bzzi2e9E.mjs} +5 -5
  78. package/dist/{skills-Q46GZMO-.mjs → skills-C0RvGjeE.mjs} +1 -1
  79. package/dist/sqlite-validation-BzKMWnO4.mjs +25 -0
  80. package/dist/{subcommand-prompt-WfySCQ7S.mjs → subcommand-prompt-Bmyn5Rlc.mjs} +1 -1
  81. package/dist/{validate-Bihr8WBi.mjs → validate-tBBN_dXH.mjs} +1 -0
  82. package/package.json +12 -7
  83. package/skills/void/SKILL.md +35 -4
  84. package/skills/void/docs/guide/deployment.md +2 -0
  85. package/skills/void/docs/guide/email.md +121 -98
  86. package/skills/void/docs/guide/platform-administration.md +214 -2
  87. package/skills/void/docs/guide/platform-development.md +93 -2
  88. package/skills/void/docs/guide/project-collaboration.md +94 -0
  89. package/skills/void/docs/guide/self-hosted-platform.md +184 -30
  90. package/skills/void/docs/reference/cli.md +294 -15
  91. package/dist/cf-access-AJ1ehiFR.mjs +0 -42
  92. package/dist/cf-access-DsSsZUPr.mjs +0 -67
  93. package/dist/email-r6DHJyAB.mjs +0 -262
@@ -36,6 +36,12 @@ interface SendEmailOptions {
36
36
  bcc?: Address | Array<Address>;
37
37
  headers?: Record<string, string>;
38
38
  attachments?: Array<Attachment>;
39
+ /**
40
+ * Deduplicates a platform send for 30 days. Reuse the same key and message
41
+ * when checking an uncertain attempt; a changed message is a conflict.
42
+ * Native Cloudflare bindings do not provide this guarantee.
43
+ */
44
+ idempotencyKey?: string;
39
45
  /**
40
46
  * Per-call escape hatch for local development. When true, bypasses the
41
47
  * `void dev` in-memory inbox for this one send.
@@ -52,7 +58,7 @@ interface SendEmailOptions {
52
58
  */
53
59
  sendInDev?: boolean;
54
60
  }
55
- type SendEmailErrorCode = "BINDING_MISSING" | "INVALID_FROM" | "INVALID_TO" | "UNVERIFIED_DESTINATION" | "MIME_ERROR" | "QUOTA_EXCEEDED" | "UPSTREAM_ERROR";
61
+ type SendEmailErrorCode = "BINDING_MISSING" | "INVALID_FROM" | "INVALID_TO" | "UNVERIFIED_DESTINATION" | "MIME_ERROR" | "QUOTA_EXCEEDED" | "IDEMPOTENCY_CONFLICT" | "OUTCOME_UNKNOWN" | "UPSTREAM_ERROR";
56
62
  interface SendEmailError {
57
63
  code: SendEmailErrorCode;
58
64
  message: string;
@@ -66,7 +72,9 @@ interface SendEmailError {
66
72
  interface SendEmailDelivery {
67
73
  recipient: string;
68
74
  messageId: string;
75
+ state?: "provider_accepted";
69
76
  }
77
+ type EmailDeliveryState = "rejected" | "reserved" | "attempt_started" | "provider_accepted" | "failed" | "outcome_unknown" | "cancelled";
70
78
  /**
71
79
  * Per-recipient outcome for a multi-recipient send. `ok: true` carries the
72
80
  * CF-issued messageId; `ok: false` carries the error for that specific
@@ -76,10 +84,12 @@ type SendEmailRecipientResult = {
76
84
  recipient: string;
77
85
  ok: true;
78
86
  messageId: string;
87
+ state?: "provider_accepted";
79
88
  } | {
80
89
  recipient: string;
81
90
  ok: false;
82
91
  error: SendEmailError;
92
+ state?: EmailDeliveryState;
83
93
  };
84
94
  /**
85
95
  * Result of `sendEmail`.
@@ -89,25 +99,27 @@ type SendEmailRecipientResult = {
89
99
  * `'deliveries' in result` (per-recipient failure, some may have succeeded):
90
100
  *
91
101
  * - `{ ok: true, ids }` — every envelope succeeded.
92
- * - `{ ok: false, error }` — pre-flight failure (validation, MIME,
93
- * missing binding); no envelopes attempted.
102
+ * - `{ ok: false, error }` — validation or transport failure. An
103
+ * OUTCOME_UNKNOWN may have been submitted;
104
+ * never retry it with a new idempotency key.
94
105
  * - `{ ok: false, deliveries }` — sends were attempted; at least one failed.
95
106
  * `deliveries` carries the per-recipient
96
- * outcome so callers can safely retry only
97
- * the failed ones. There is no aggregate
98
- * top-level error — distinct recipients can
99
- * fail with distinct errors, and picking one
100
- * would be arbitrary.
107
+ * outcome. Provider acceptance does not
108
+ * establish mailbox delivery. Unknown
109
+ * outcomes must not be blindly retried.
101
110
  */
102
111
  type SendEmailResult = {
103
112
  ok: true;
104
113
  ids: Array<SendEmailDelivery>;
114
+ operationId?: string;
105
115
  } | {
106
116
  ok: false;
107
117
  error: SendEmailError;
118
+ operationId?: string;
108
119
  } | {
109
120
  ok: false;
110
121
  deliveries: Array<SendEmailRecipientResult>;
122
+ operationId?: string;
111
123
  };
112
124
  /**
113
125
  * Normalized representation produced by validation, consumed by both the
@@ -197,31 +209,24 @@ declare class ParseEmailError extends Error {
197
209
  * one-shot.
198
210
  */
199
211
  declare function parseEmail(message: ForwardableEmailMessage): Promise<ParsedEmail>;
200
- /**
201
- * Serialized inbound email message — sent from the platform router to the
202
- * user worker over RPC. Every field is in workerd's RPC `BaseType`
203
- * allowlist (string, plain object, ReadableStream<Uint8Array>, number).
204
- */
212
+ /** Inbound message decoded from the authenticated gateway request. */
205
213
  interface EmailEnvelope {
206
214
  from: string;
207
215
  to: string;
208
- /** Headers serialized as a plain object so they cross RPC. */
216
+ /** Decoded header metadata. */
209
217
  headers: Record<string, string>;
210
218
  /**
211
- * Raw RFC 5322 bytes. Pre-buffered by the router so it's replayable on the
212
- * receiving side (`parseEmail` consumes the stream once; if the handler
213
- * also calls `parseEmail` directly, the platform tee'd a fresh stream per
214
- * delivery).
219
+ * Raw RFC 5322 bytes. The message proxy caches them for repeated reads.
215
220
  */
216
221
  raw: ReadableStream<Uint8Array> | Uint8Array;
217
222
  rawSize: number;
223
+ /**
224
+ * Exact custom domain selected by the gateway. Custom-domain recipients
225
+ * have no project-slug prefix; replies default to the received address.
226
+ */
227
+ viaDomain?: string;
218
228
  }
219
- /**
220
- * What the user handler decided to do with a message. Returned from the
221
- * worker's `email(envelope)` RPC method as an array (handlers may chain
222
- * multiple actions, e.g. forward then reply); the router enacts each on the
223
- * real `ForwardableEmailMessage` in order.
224
- */
229
+ /** Handler intents, validated and admitted by the gateway before delivery. */
225
230
  type EmailAction = {
226
231
  type: "reject";
227
232
  reason: string;
@@ -261,7 +266,7 @@ interface CreateMessageProxyOptions {
261
266
  * accept a native `EmailMessage` from `cloudflare:email`: that class exposes
262
267
  * only `from` and `to` (workers-types declares nothing else, and the body
263
268
  * has no public accessor), so its bytes cannot be recorded here, and nothing
264
- * could carry them over RPC to the platform router. The reply MIME bytes are
269
+ * could encode them for the gateway. The reply MIME bytes are
265
270
  * buffered before being recorded so the action is fully self-contained.
266
271
  *
267
272
  * When `opts.slug` is provided, `message.to` is rewritten to remove the
@@ -314,8 +319,8 @@ interface ReplyEmailOptions {
314
319
  /**
315
320
  * Send a threading-aware reply to an inbound message. Builds the reply MIME
316
321
  * (correct `In-Reply-To` / `References` / `Subject: Re: …`) and records a
317
- * reply intent on the message proxy. The platform router enacts the reply
318
- * via the real `ForwardableEmailMessage.reply()` once the handler returns.
322
+ * reply intent on the message proxy. Delivery follows handler completion and,
323
+ * on the platform, authoritative admission.
319
324
  *
320
325
  * The threading headers are best-effort, since the inbound values they are
321
326
  * built from are remote-sender controlled: an id no header line can carry
@@ -324,10 +329,8 @@ interface ReplyEmailOptions {
324
329
  */
325
330
  declare function replyEmail(message: ForwardableEmailMessage, opts: ReplyEmailOptions): Promise<void>;
326
331
  /**
327
- * Whether `value` is a real `ForwardableEmailMessage` — one Cloudflare
328
- * delivered to this worker's `email()` directly — rather than an
329
- * `EmailEnvelope` from the platform router. Functions do not survive RPC,
330
- * so their presence is the discriminator.
332
+ * Recognize native email operations. This structural check is not an
333
+ * authentication boundary; managed entrypoints require receipt authentication.
331
334
  */
332
335
  declare function isForwardableEmailMessage(value: unknown): value is ForwardableEmailMessage;
333
336
  /**
@@ -338,17 +341,9 @@ declare function isForwardableEmailMessage(value: unknown): value is Forwardable
338
341
  * duck-typed object — so `cloudflare:email` is loaded here, and only when
339
342
  * a reply was recorded.
340
343
  *
341
- * The `new Headers()` below is the one step this loop constructs from
342
- * handler input, and it cannot fail on a recorded action: the proxy's
343
- * `forward()` already ran the record through that constructor, so a bad
344
- * record failed inside the handler, before any replay. (`EmailMessage`
345
- * construction neither validates nor reads its stream — measured under
346
- * workerd.) A reject's reason reaches the native `setReject()` as the proxy
347
- * recorded it — already sliced and stripped the way the platform router
348
- * would (`normalizeRejectReason`), so the SMTP reply is the same in both
349
- * deployments. What Cloudflare itself refuses at `forward()` / `reply()`
350
- * still throws out of the loop, as any native call would.
344
+ * The proxy already validated forward headers and normalized reject reasons.
345
+ * Provider refusals still propagate to the native event caller.
351
346
  */
352
347
  declare function enactEmailActions(message: ForwardableEmailMessage, actions: ReadonlyArray<EmailAction>): Promise<void>;
353
348
  //#endregion
354
- export { SendEmailRecipientResult as C, SendEmailOptions as S, Attachment as _, MessageProxy as a, SendEmailError as b, ParsedEmail as c, defineEmail as d, enactEmailActions as f, Address as g, replyEmail as h, EmailHandlerInfo as i, ReplyEmailOptions as l, parseEmail as m, EmailEnvelope as n, ParseEmailError as o, isForwardableEmailMessage as p, EmailHandler as r, ParseEmailErrorCode as s, EmailAction as t, createMessageProxy as u, NormalizedEmail as v, SendEmailResult as w, SendEmailErrorCode as x, SendEmailDelivery as y };
349
+ export { SendEmailOptions as C, SendEmailErrorCode as S, SendEmailResult as T, Attachment as _, MessageProxy as a, SendEmailDelivery as b, ParsedEmail as c, defineEmail as d, enactEmailActions as f, Address as g, replyEmail as h, EmailHandlerInfo as i, ReplyEmailOptions as l, parseEmail as m, EmailEnvelope as n, ParseEmailError as o, isForwardableEmailMessage as p, EmailHandler as r, ParseEmailErrorCode as s, EmailAction as t, createMessageProxy as u, EmailDeliveryState as v, SendEmailRecipientResult as w, SendEmailError as x, NormalizedEmail as y };
@@ -1,58 +1,28 @@
1
- import { i as normalizeEmail, n as MimeError, o as validateAddressParts, r as buildMime, t as MAX_MESSAGE_BYTES } from "./mime-BJD7d_qL.mjs";
1
+ import { i as normalizeEmail, n as MimeError, o as validateAddressParts, r as buildMime } from "./mime-D5Nmdzf7.mjs";
2
2
  import { a as getRawRuntimeEnv } from "./env-raw-Cx8ElDdj.mjs";
3
+ import { EMAIL_MAX_RESPONSE_BYTES } from "@void/platform/email-protocol";
3
4
  //#region src/runtime/email/inbound.ts
4
5
  const VALID_SLUG = /^[a-z0-9](?:[a-z0-9-]{0,54}[a-z0-9])?$/;
5
- /**
6
- * Guardrails on what one handler may queue.
7
- *
8
- * These are NOT the security boundary. Recording happens inside the tenant's
9
- * own worker, and on the platform a Workers-for-Platforms tenant can ship any
10
- * `WorkerEntrypoint` whose `email()` returns any array at all, bypassing this
11
- * file entirely — so the load-bearing caps are the matching ones in the
12
- * platform router (`MAX_ACTIONS` / `MAX_ACTION_ADDRESS_LENGTH` in
13
- * `email-router.ts`). What these buy is a fast, precise failure at the call
14
- * that went wrong, instead of a whole action list silently dropped at the
15
- * router with only a platform-side log to show for it.
16
- */
17
6
  const MAX_ACTIONS = 100;
18
7
  const MAX_ACTION_ADDRESS_LENGTH = 320;
8
+ const MAX_ACTION_METADATA_BYTES = 8192;
19
9
  const MAX_FORWARD_HEADER_LENGTH = 1024;
20
- /**
21
- * Cap on a reject reason, restated from the router's
22
- * `MAX_REJECT_REASON_LENGTH` in `email-router.ts` for the same reason as
23
- * above, and pinned by the same test. See `normalizeRejectReason`.
24
- */
10
+ /** Maximum normalized SMTP rejection reason length. */
25
11
  const MAX_REJECT_REASON_LENGTH = 1e3;
26
- /**
27
- * The router's ADMISSION bounds — what it refuses BEFORE the handler runs
28
- * (`MAX_INBOUND_BYTES` / `MAX_INBOUND_HEADERS` in `email-router.ts`,
29
- * restated for the same reason as above and pinned by the same test).
30
- * `routeInbound` rejects a message over either one and never dispatches it,
31
- * so a handler's behaviour on such a message is unreachable after a managed
32
- * deploy; the test harness applies both before it invokes the handler.
33
- *
34
- * `MAX_INBOUND_BYTES` is NOT `MAX_MESSAGE_BYTES`, though the numbers agree
35
- * today. That one is Cloudflare's cap on an OUTBOUND message (and the
36
- * `parseEmail` guard); this one is the router's own heap headroom on an
37
- * inbound one, set well under Cloudflare's ~25 MB inbound ceiling, and the
38
- * two can move independently.
39
- */
12
+ /** Inbound admission limits also applied by the handler test harness. */
40
13
  const MAX_INBOUND_BYTES = 10485760;
41
14
  const MAX_INBOUND_HEADERS = 1024;
42
- /**
43
- * Cumulative reply-byte budget for ONE dispatch.
44
- *
45
- * `MAX_MESSAGE_BYTES` bounds a single `reply()` call, not the queue: N replies
46
- * at the cap is N x 10 MiB of `Uint8Array` riding one RPC response. workerd
47
- * caps a serialized RPC payload at 32 MiB, so the queue that clears our
48
- * per-reply check still fails at the boundary — opaquely. The router cannot
49
- * tell that failure from any other dispatch error, so the whole message comes
50
- * back to the sender as a generic "project handler error" reject with nothing
51
- * in it a handler author could act on. Two full-size replies is already well
52
- * past any legitimate use, and failing here names the exact call that
53
- * overran.
54
- */
55
- const MAX_REPLY_BYTES_TOTAL = 2 * MAX_MESSAGE_BYTES;
15
+ const MAX_REPLY_BYTES = 5242880;
16
+ const EMAIL_STREAM_DEADLINE_MS = 15e3;
17
+ const EMAIL_ACTION_TERMINAL_BYTES = 5;
18
+ const actionEncoder = new TextEncoder();
19
+ function emailActionWireSize(action) {
20
+ return 5 + actionEncoder.encode(JSON.stringify(action.type === "reply" ? {
21
+ type: action.type,
22
+ from: action.from,
23
+ to: action.to
24
+ } : action)).byteLength + (action.type === "reply" ? 5 + action.raw.byteLength : 0);
25
+ }
56
26
  function readRawProjectSlug() {
57
27
  let raw;
58
28
  try {
@@ -75,6 +45,10 @@ function tryReadEmailDomain() {
75
45
  }
76
46
  return typeof raw === "string" && raw.length > 0 ? raw : void 0;
77
47
  }
48
+ function readRelayDomain(message) {
49
+ const raw = message.__voidRelayDomain;
50
+ return typeof raw === "string" && raw.length > 0 ? raw : void 0;
51
+ }
78
52
  /**
79
53
  * Identity helper that types the inbound email handler. Use as the default
80
54
  * export of `email/<address>.ts` or `email/_default.ts`.
@@ -104,7 +78,7 @@ var ParseEmailError = class extends Error {
104
78
  * one-shot.
105
79
  */
106
80
  async function parseEmail(message) {
107
- if (message.rawSize > 10485760) throw new ParseEmailError("INBOUND_TOO_LARGE", `parseEmail: message size ${message.rawSize} bytes exceeds maximum of ${MAX_MESSAGE_BYTES} bytes.`);
81
+ if (message.rawSize > 10485760) throw new ParseEmailError("INBOUND_TOO_LARGE", `parseEmail: message size ${message.rawSize} bytes exceeds maximum of ${MAX_INBOUND_BYTES} bytes.`);
108
82
  const { default: PostalMime } = await import("postal-mime");
109
83
  const cached = proxyRawCache.get(message);
110
84
  try {
@@ -134,10 +108,10 @@ function stripSlugFromAddress(address, slug) {
134
108
  return address;
135
109
  }
136
110
  function replyTooLarge(size) {
137
- return new MimeError("MIME_ERROR", `reply: message size ${size} bytes exceeds Cloudflare limit of ${MAX_MESSAGE_BYTES} bytes.`);
111
+ return new MimeError("MIME_ERROR", `reply: message size ${size} bytes exceeds Void's sending limit of ${MAX_REPLY_BYTES} bytes.`);
138
112
  }
139
113
  function replyBudgetExhausted(size) {
140
- return new MimeError("MIME_ERROR", `reply: cumulative reply size ${size} bytes exceeds this delivery's budget of ${MAX_REPLY_BYTES_TOTAL} bytes.`);
114
+ return new MimeError("MIME_ERROR", `reply: serialized actions would use ${size} bytes, over this delivery's ${EMAIL_MAX_RESPONSE_BYTES}-byte response budget.`);
141
115
  }
142
116
  function queueFull() {
143
117
  return new MimeError("MIME_ERROR", `email: handler queued more than ${MAX_ACTIONS} actions for one message.`);
@@ -200,15 +174,29 @@ async function readStreamToBytes(stream, limit = Infinity, onOverflow = replyToo
200
174
  const reader = stream.getReader();
201
175
  const chunks = [];
202
176
  let total = 0;
203
- for (;;) {
204
- const { value, done } = await reader.read();
205
- if (done) break;
206
- total += value.byteLength;
207
- if (total > limit) {
208
- await reader.cancel().catch(() => {});
209
- throw onOverflow(total);
177
+ const deadline = Date.now() + EMAIL_STREAM_DEADLINE_MS;
178
+ try {
179
+ for (;;) {
180
+ const remaining = deadline - Date.now();
181
+ if (remaining <= 0) throw new MimeError("MIME_ERROR", "email stream timed out");
182
+ let timer;
183
+ const { value, done } = await Promise.race([reader.read(), new Promise((_, reject) => {
184
+ timer = setTimeout(() => reject(new MimeError("MIME_ERROR", "email stream timed out")), remaining);
185
+ })]).finally(() => {
186
+ if (timer !== void 0) clearTimeout(timer);
187
+ });
188
+ if (done) break;
189
+ total += value.byteLength;
190
+ if (total > limit) throw onOverflow(total);
191
+ chunks.push(value);
210
192
  }
211
- chunks.push(value);
193
+ } catch (error) {
194
+ reader.cancel().catch(() => {});
195
+ throw error;
196
+ } finally {
197
+ try {
198
+ reader.releaseLock();
199
+ } catch {}
212
200
  }
213
201
  const out = new Uint8Array(total);
214
202
  let offset = 0;
@@ -235,7 +223,7 @@ function bytesToReadableStream(bytes) {
235
223
  * accept a native `EmailMessage` from `cloudflare:email`: that class exposes
236
224
  * only `from` and `to` (workers-types declares nothing else, and the body
237
225
  * has no public accessor), so its bytes cannot be recorded here, and nothing
238
- * could carry them over RPC to the platform router. The reply MIME bytes are
226
+ * could encode them for the gateway. The reply MIME bytes are
239
227
  * buffered before being recorded so the action is fully self-contained.
240
228
  *
241
229
  * When `opts.slug` is provided, `message.to` is rewritten to remove the
@@ -246,7 +234,8 @@ function bytesToReadableStream(bytes) {
246
234
  function createMessageProxy(envelope, opts = {}) {
247
235
  const actions = [];
248
236
  const headers = new Headers(envelope.headers);
249
- const userVisibleTo = stripSlugFromAddress(envelope.to, opts.slug);
237
+ const slug = envelope.viaDomain === void 0 || envelope.viaDomain === "" ? opts.slug : void 0;
238
+ const userVisibleTo = stripSlugFromAddress(envelope.to, slug);
250
239
  let cachedRaw;
251
240
  let bufferingPromise;
252
241
  function readRawOnce() {
@@ -265,9 +254,20 @@ function createMessageProxy(envelope, opts = {}) {
265
254
  }
266
255
  if (envelope.raw instanceof Uint8Array) cachedRaw = envelope.raw;
267
256
  let rejected = false;
268
- let replyBytesUsed = 0;
269
- function assertQueueHasRoom() {
257
+ let actionWireBytesUsed = EMAIL_ACTION_TERMINAL_BYTES;
258
+ let pendingReplies = 0;
259
+ let replyRecordingTail = Promise.resolve();
260
+ function recordAction(action) {
261
+ if (rejected) return false;
270
262
  if (actions.length >= MAX_ACTIONS) throw queueFull();
263
+ const nextSize = actionWireBytesUsed + emailActionWireSize(action);
264
+ if (nextSize > EMAIL_MAX_RESPONSE_BYTES) throw replyBudgetExhausted(nextSize);
265
+ actions.push(action);
266
+ actionWireBytesUsed = nextSize;
267
+ return true;
268
+ }
269
+ function assertQueueHasRoom() {
270
+ if (actions.length + pendingReplies >= MAX_ACTIONS) throw queueFull();
271
271
  }
272
272
  const message = {
273
273
  from: envelope.from,
@@ -295,10 +295,12 @@ function createMessageProxy(envelope, opts = {}) {
295
295
  }
296
296
  }
297
297
  if (dropped > 0) console.warn(`[void/email] setReject() after ${dropped} forward/reply call(s) — those actions were dropped. CF rejects are terminal: anything queued before setReject() never runs. Move the reject earlier in the handler.`);
298
- actions.push({
298
+ const action = {
299
299
  type: "reject",
300
300
  reason: normalizeRejectReason(reason)
301
- });
301
+ };
302
+ actions.push(action);
303
+ actionWireBytesUsed = EMAIL_ACTION_TERMINAL_BYTES + emailActionWireSize(action);
302
304
  },
303
305
  async forward(to, fwdHeaders) {
304
306
  if (rejected) {
@@ -319,11 +321,13 @@ function createMessageProxy(envelope, opts = {}) {
319
321
  headersObj = { ...fwdHeaders };
320
322
  }
321
323
  if (headersObj) assertForwardHeadersWithinBounds(headersObj);
322
- actions.push({
324
+ const action = {
323
325
  type: "forward",
324
326
  to,
325
327
  ...headersObj ? { headers: headersObj } : {}
326
- });
328
+ };
329
+ if (new TextEncoder().encode(JSON.stringify(action)).byteLength > MAX_ACTION_METADATA_BYTES) throw new MimeError("MIME_ERROR", `forward: action metadata exceeds ${MAX_ACTION_METADATA_BYTES} bytes.`);
330
+ recordAction(action);
327
331
  },
328
332
  async reply(reply) {
329
333
  if (rejected) {
@@ -335,25 +339,47 @@ function createMessageProxy(envelope, opts = {}) {
335
339
  if (reply.to.length > MAX_ACTION_ADDRESS_LENGTH) throw addressTooLong("to", reply.to.length);
336
340
  validateAddressParts("from", reply.from);
337
341
  validateAddressParts("to", reply.to);
338
- const remaining = MAX_REPLY_BYTES_TOTAL - replyBytesUsed;
339
- let rawBytes;
340
- if (reply.raw instanceof Uint8Array) {
341
- if (reply.raw.byteLength > 10485760) throw replyTooLarge(reply.raw.byteLength);
342
- if (reply.raw.byteLength > remaining) throw replyBudgetExhausted(replyBytesUsed + reply.raw.byteLength);
343
- rawBytes = reply.raw;
344
- } else {
345
- const raw = reply.raw;
346
- if (typeof raw !== "object" || raw === null || typeof raw.getReader !== "function") throw new MimeError("MIME_ERROR", "reply: expected { from, to, raw } with raw as a Uint8Array or ReadableStream. A native EmailMessage cannot be replayed from a Void handler — its body has no public accessor. Use replyEmail() from void/email, or pass the MIME bytes as raw.");
347
- const limit = Math.min(MAX_MESSAGE_BYTES, remaining);
348
- rawBytes = await readStreamToBytes(reply.raw, limit, (total) => limit === 10485760 ? replyTooLarge(total) : replyBudgetExhausted(replyBytesUsed + total));
349
- }
350
- replyBytesUsed += rawBytes.byteLength;
351
- actions.push({
352
- type: "reply",
353
- from: reply.from,
354
- to: reply.to,
355
- raw: rawBytes
342
+ pendingReplies++;
343
+ const previousReply = replyRecordingTail;
344
+ let releaseReply;
345
+ replyRecordingTail = new Promise((resolve) => {
346
+ releaseReply = resolve;
356
347
  });
348
+ await previousReply;
349
+ try {
350
+ if (rejected) {
351
+ console.warn("[void/email] reply() completed after setReject() — ignored. CF rejects are terminal: nothing else runs once setReject fires.");
352
+ return;
353
+ }
354
+ const replyFrameBytes = emailActionWireSize({
355
+ type: "reply",
356
+ from: reply.from,
357
+ to: reply.to,
358
+ raw: /* @__PURE__ */ new Uint8Array()
359
+ });
360
+ const remaining = EMAIL_MAX_RESPONSE_BYTES - actionWireBytesUsed - replyFrameBytes;
361
+ if (remaining < 0) throw replyBudgetExhausted(EMAIL_MAX_RESPONSE_BYTES - remaining);
362
+ let rawBytes;
363
+ if (reply.raw instanceof Uint8Array) {
364
+ if (reply.raw.byteLength > MAX_REPLY_BYTES) throw replyTooLarge(reply.raw.byteLength);
365
+ if (reply.raw.byteLength > remaining) throw replyBudgetExhausted(actionWireBytesUsed + replyFrameBytes + reply.raw.byteLength);
366
+ rawBytes = reply.raw;
367
+ } else {
368
+ const raw = reply.raw;
369
+ if (typeof raw !== "object" || raw === null || typeof raw.getReader !== "function") throw new MimeError("MIME_ERROR", "reply: expected { from, to, raw } with raw as a Uint8Array or ReadableStream. A native EmailMessage cannot be replayed from a Void handler — its body has no public accessor. Use replyEmail() from void/email, or pass the MIME bytes as raw.");
370
+ const limit = Math.min(MAX_REPLY_BYTES, remaining);
371
+ rawBytes = await readStreamToBytes(reply.raw, limit, (total) => limit === MAX_REPLY_BYTES ? replyTooLarge(total) : replyBudgetExhausted(actionWireBytesUsed + replyFrameBytes + total));
372
+ }
373
+ if (!recordAction({
374
+ type: "reply",
375
+ from: reply.from,
376
+ to: reply.to,
377
+ raw: rawBytes
378
+ })) console.warn("[void/email] reply() completed after setReject() — ignored. CF rejects are terminal: nothing else runs once setReject fires.");
379
+ } finally {
380
+ pendingReplies--;
381
+ releaseReply();
382
+ }
357
383
  }
358
384
  };
359
385
  if (envelope.to !== userVisibleTo) Object.defineProperty(message, "__voidPlatformTo", {
@@ -362,6 +388,12 @@ function createMessageProxy(envelope, opts = {}) {
362
388
  writable: false,
363
389
  configurable: false
364
390
  });
391
+ if (typeof envelope.viaDomain === "string" && envelope.viaDomain.length > 0) Object.defineProperty(message, "__voidRelayDomain", {
392
+ value: envelope.viaDomain,
393
+ enumerable: false,
394
+ writable: false,
395
+ configurable: false
396
+ });
365
397
  proxyRawCache.set(message, { read: readRawOnce });
366
398
  return {
367
399
  message,
@@ -592,6 +624,7 @@ async function buildReplyMessage(message, opts) {
592
624
  const originalReferences = headers.get("References") ?? "";
593
625
  let fromAddr;
594
626
  if (opts.from !== void 0) fromAddr = opts.from;
627
+ else if (readRelayDomain(message) !== void 0) fromAddr = message.to;
595
628
  else {
596
629
  const slug = tryReadProjectSlug();
597
630
  if (slug) {
@@ -629,8 +662,8 @@ async function buildReplyMessage(message, opts) {
629
662
  /**
630
663
  * Send a threading-aware reply to an inbound message. Builds the reply MIME
631
664
  * (correct `In-Reply-To` / `References` / `Subject: Re: …`) and records a
632
- * reply intent on the message proxy. The platform router enacts the reply
633
- * via the real `ForwardableEmailMessage.reply()` once the handler returns.
665
+ * reply intent on the message proxy. Delivery follows handler completion and,
666
+ * on the platform, authoritative admission.
634
667
  *
635
668
  * The threading headers are best-effort, since the inbound values they are
636
669
  * built from are remote-sender controlled: an id no header line can carry
@@ -646,16 +679,32 @@ async function replyEmail(message, opts) {
646
679
  });
647
680
  }
648
681
  /**
649
- * Whether `value` is a real `ForwardableEmailMessage` — one Cloudflare
650
- * delivered to this worker's `email()` directly — rather than an
651
- * `EmailEnvelope` from the platform router. Functions do not survive RPC,
652
- * so their presence is the discriminator.
682
+ * Recognize native email operations. This structural check is not an
683
+ * authentication boundary; managed entrypoints require receipt authentication.
653
684
  */
654
685
  function isForwardableEmailMessage(value) {
655
686
  if (typeof value !== "object" || value === null) return false;
656
687
  const m = value;
657
688
  return typeof m.setReject === "function" && typeof m.forward === "function" && typeof m.reply === "function";
658
689
  }
690
+ const INBOUND_PROVIDER_DEADLINE_MS = 3e4;
691
+ const INBOUND_ACTION_BATCH_DEADLINE_MS = 6e4;
692
+ function createInboundProviderDeadline() {
693
+ const deadline = Date.now() + INBOUND_ACTION_BATCH_DEADLINE_MS;
694
+ return { async run(submit) {
695
+ const remaining = Math.min(INBOUND_PROVIDER_DEADLINE_MS, deadline - Date.now());
696
+ if (remaining <= 0) throw new Error("email action batch deadline elapsed before provider submission");
697
+ const work = submit();
698
+ let timer;
699
+ try {
700
+ return await Promise.race([work, new Promise((_, reject) => {
701
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error("email action provider timed out; outcome unknown, do not retry automatically")), remaining);
702
+ })]);
703
+ } finally {
704
+ if (timer !== void 0) clearTimeout(timer);
705
+ }
706
+ } };
707
+ }
659
708
  /**
660
709
  * Replay recorded `EmailAction`s on a real `ForwardableEmailMessage`, in
661
710
  * order. The proxy already made `setReject` terminal (earlier forward/reply
@@ -664,28 +713,21 @@ function isForwardableEmailMessage(value) {
664
713
  * duck-typed object — so `cloudflare:email` is loaded here, and only when
665
714
  * a reply was recorded.
666
715
  *
667
- * The `new Headers()` below is the one step this loop constructs from
668
- * handler input, and it cannot fail on a recorded action: the proxy's
669
- * `forward()` already ran the record through that constructor, so a bad
670
- * record failed inside the handler, before any replay. (`EmailMessage`
671
- * construction neither validates nor reads its stream — measured under
672
- * workerd.) A reject's reason reaches the native `setReject()` as the proxy
673
- * recorded it — already sliced and stripped the way the platform router
674
- * would (`normalizeRejectReason`), so the SMTP reply is the same in both
675
- * deployments. What Cloudflare itself refuses at `forward()` / `reply()`
676
- * still throws out of the loop, as any native call would.
716
+ * The proxy already validated forward headers and normalized reject reasons.
717
+ * Provider refusals still propagate to the native event caller.
677
718
  */
678
719
  async function enactEmailActions(message, actions) {
720
+ const providerDeadline = createInboundProviderDeadline();
679
721
  for (const action of actions) switch (action.type) {
680
722
  case "reject":
681
723
  message.setReject(action.reason);
682
724
  break;
683
725
  case "forward":
684
- await message.forward(action.to, action.headers ? new Headers(action.headers) : void 0);
726
+ await providerDeadline.run(() => message.forward(action.to, action.headers ? new Headers(action.headers) : void 0));
685
727
  break;
686
728
  case "reply": {
687
729
  const { EmailMessage } = await import("cloudflare:email");
688
- await message.reply(new EmailMessage(action.from, action.to, bytesToReadableStream(action.raw)));
730
+ await providerDeadline.run(() => message.reply(new EmailMessage(action.from, action.to, bytesToReadableStream(action.raw))));
689
731
  break;
690
732
  }
691
733
  }