void 0.20.1 → 0.20.3

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 (109) 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-CAH62yDU.mjs → auth-cmd-CzwquNiP.mjs} +5 -4
  4. package/dist/auth-link-ElDTgF7j.mjs +28 -0
  5. package/dist/{build-cmd-CJvZvPQO.mjs → build-cmd-BpJe6boP.mjs} +3 -3
  6. package/dist/{cache-BlNeQjuP.mjs → cache-D98YTqeE.mjs} +3 -3
  7. package/dist/{cancel-deploy-CmlAZ9P6.mjs → cancel-deploy-CPbEQLMG.mjs} +3 -3
  8. package/dist/cf-access-DRsQRe6k.mjs +75 -0
  9. package/dist/cli/cli.mjs +309 -1958
  10. package/dist/cli/env-schema-probe.mjs +11 -2
  11. package/dist/client-BQBrZoCX.mjs +989 -0
  12. package/dist/{cloudflare-auth-B1QtTO1b.mjs → cloudflare-auth-6M5llVPC.mjs} +2 -2
  13. package/dist/{cloudflare-cmd-B6_OZx2V.mjs → cloudflare-cmd-4RPGN3KB.mjs} +2 -2
  14. package/dist/{cloudflare-connect-j5D4hhrG.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-C04Wdy_h.mjs → connect-WCQZ_u3m.mjs} +6 -6
  18. package/dist/{create-project-ChGZ1DFd.mjs → create-project-D0oXA090.mjs} +7 -7
  19. package/dist/{db-D2d_mUsB.mjs → db-DJ-9qs3S.mjs} +47 -30
  20. package/dist/{delete-D8GigDk8.mjs → delete-BZ4-WaGm.mjs} +3 -3
  21. package/dist/{deploy-iXZ3F0N6.mjs → deploy-BAhhcg5q.mjs} +135 -117
  22. package/dist/{dev-inbox-DkgRWLkW.mjs → dev-inbox-P0u4tM8Y.mjs} +1 -1
  23. package/dist/{domain-B1VmoSr0.mjs → domain-BxAyhxXN.mjs} +4 -4
  24. package/dist/email-Bj7Cvdwp.mjs +795 -0
  25. package/dist/{env-D4Emu-M_.mjs → env-DBKmK4vc.mjs} +1 -0
  26. package/dist/{env-BcQzYgoG.mjs → env-DP_EErve.mjs} +5 -5
  27. package/dist/{env-validation-ENpMy6Ez.mjs → env-validation-CF6KvTRf.mjs} +3 -1
  28. package/dist/{gen-DI2YwdBM.mjs → gen-B_wPnVTK.mjs} +2 -2
  29. package/dist/{github-cmd-xItS5Zwf.mjs → github-cmd-C-z_xRrQ.mjs} +15 -21
  30. package/dist/{headers-D8QfRX9Y.mjs → headers-BAHwgHdW.mjs} +1 -1
  31. package/dist/help-CwOX-zmI.mjs +2216 -0
  32. package/dist/{inbound-afAcWeQ9.d.mts → inbound-CH5Mksyy.d.mts} +34 -48
  33. package/dist/{inbound-2d0zi2yS.mjs → inbound-aVHEUhKo.mjs} +130 -100
  34. package/dist/index.mjs +54 -17
  35. package/dist/{init-BD-9THgn.mjs → init-BGktCXgA.mjs} +11 -11
  36. package/dist/{link-RMdgjF1v.mjs → link-D2kbqhWb.mjs} +4 -4
  37. package/dist/{list-3F52R_yO.mjs → list-CvkK_G7k.mjs} +4 -4
  38. package/dist/{login-pV69H-ZO.mjs → login-WIjNc77c.mjs} +28 -10
  39. package/dist/{logs-DFHHD6wE.mjs → logs-dLUFCapG.mjs} +4 -4
  40. package/dist/{mime-BJD7d_qL.mjs → mime-D5Nmdzf7.mjs} +23 -9
  41. package/dist/{node-Dk3H2jmU.mjs → node-Ez5KW5rn.mjs} +2 -2
  42. package/dist/operator-auth-B3e08unv.mjs +52 -0
  43. package/dist/operator-client-LUZnlnYk.mjs +82 -0
  44. package/dist/{operator-cmd-DYWRbWUA.mjs → operator-cmd-CKJ7xIRs.mjs} +35 -55
  45. package/dist/{output-tFQLLj26.mjs → output-B0cfNSx5.mjs} +316 -2
  46. package/dist/pages/index.mjs +2 -2
  47. package/dist/platform-auth-config-CdVWRRJr.mjs +368 -0
  48. package/dist/platform-auth-protection-Drl0qhrn.mjs +219 -0
  49. package/dist/platform-auth-recovery-CmKEWpDo.mjs +310 -0
  50. package/dist/{platform-cmd-DxJ2FRwR.mjs → platform-cmd-5q_k56xS.mjs} +16 -6
  51. package/dist/{platform-domain-ChvbJkdy.mjs → platform-domain-4GiDlcqx.mjs} +4 -4
  52. package/dist/{platform-lifecycle-DN4MzJF_.mjs → platform-lifecycle-R9xAxvtG.mjs} +1716 -204
  53. package/dist/{platform-management-Db2PXw0B.mjs → platform-management-BfWsXHEW.mjs} +35 -7
  54. package/dist/{platform-recovery-C_YO-tIs.mjs → platform-recovery-CbK-I1FB.mjs} +6 -5
  55. package/dist/{prepare-CBetXvsN.mjs → prepare-CtDJjoOj.mjs} +2 -2
  56. package/dist/{prepare-BfJvFUtJ.mjs → prepare-blNRQvQl.mjs} +2 -2
  57. package/dist/prerender-render.d.mts +11 -0
  58. package/dist/prerender-render.mjs +111 -0
  59. package/dist/{project-cmd-Mo0V9yKS.mjs → project-cmd-CTdmnzvc.mjs} +32 -14
  60. package/dist/project-team-CGxsQe3_.mjs +132 -0
  61. package/dist/project-token-Cirx7uwZ.mjs +75 -0
  62. package/dist/{provision-Blnstcm2.mjs → provision-CSJOjjQk.mjs} +2 -0
  63. package/dist/{requests-BcKOVpRg.mjs → requests-4Nq59hOr.mjs} +3 -3
  64. package/dist/{rollback-Bx85-0xh.mjs → rollback-Dr7u0Ljx.mjs} +4 -4
  65. package/dist/runtime/ai.mjs +3 -2
  66. package/dist/runtime/email/testing.d.mts +1 -1
  67. package/dist/runtime/email/testing.mjs +3 -3
  68. package/dist/runtime/email-protocol.d.mts +15 -0
  69. package/dist/runtime/email-protocol.mjs +70 -0
  70. package/dist/runtime/email.d.mts +2 -2
  71. package/dist/runtime/email.mjs +189 -96
  72. package/dist/runtime/remote/index.mjs +54 -11
  73. package/dist/runtime/sandbox.d.mts +4 -56
  74. package/dist/runtime/sandbox.mjs +81 -220
  75. package/dist/{secret-ByhJ9AMl.mjs → secret-AcPi-FoA.mjs} +5 -5
  76. package/dist/{skills-Q46GZMO-.mjs → skills-C0RvGjeE.mjs} +1 -1
  77. package/dist/{subcommand-prompt-WfySCQ7S.mjs → subcommand-prompt-Bmyn5Rlc.mjs} +1 -1
  78. package/package.json +12 -7
  79. package/skills/void/SKILL.md +35 -4
  80. package/skills/void/docs/guide/deployment.md +16 -14
  81. package/skills/void/docs/guide/email.md +114 -120
  82. package/skills/void/docs/guide/platform/administration/access.md +163 -0
  83. package/skills/void/docs/guide/platform/administration/email.md +121 -0
  84. package/skills/void/docs/guide/platform/administration/operations.md +97 -0
  85. package/skills/void/docs/guide/platform/administration/projects.md +54 -0
  86. package/skills/void/docs/guide/platform/development/local.md +119 -0
  87. package/skills/void/docs/guide/platform/development/runtime.md +124 -0
  88. package/skills/void/docs/guide/platform/development/schema-ci.md +95 -0
  89. package/skills/void/docs/guide/platform/installation/ci.md +55 -0
  90. package/skills/void/docs/guide/platform/installation/credentials.md +80 -0
  91. package/skills/void/docs/guide/platform/installation/domains.md +68 -0
  92. package/skills/void/docs/guide/platform/installation/first-deployment.md +82 -0
  93. package/skills/void/docs/guide/platform/installation/maintenance.md +137 -0
  94. package/skills/void/docs/guide/platform/installation/prerequisites.md +86 -0
  95. package/skills/void/docs/guide/platform/installation/setup.md +169 -0
  96. package/skills/void/docs/guide/platform/installation/uninstall.md +54 -0
  97. package/skills/void/docs/guide/platform-administration.md +6 -202
  98. package/skills/void/docs/guide/platform-development.md +5 -254
  99. package/skills/void/docs/guide/project-collaboration.md +94 -0
  100. package/skills/void/docs/guide/sandboxes.md +9 -24
  101. package/skills/void/docs/guide/self-hosted-platform.md +13 -542
  102. package/skills/void/docs/reference/api.md +34 -34
  103. package/skills/void/docs/reference/cli.md +276 -31
  104. package/skills/void/docs/reference/config.md +1 -1
  105. package/skills/void/docs/reference/resource-inference.md +10 -10
  106. package/dist/cf-access-AJ1ehiFR.mjs +0 -42
  107. package/dist/cf-access-DsSsZUPr.mjs +0 -67
  108. package/dist/client-Clirrol3.mjs +0 -705
  109. package/dist/email-uKyQYUVY.mjs +0 -1016
@@ -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,40 +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;
218
223
  /**
219
- * The registered custom domain this message arrived on, set only by the
220
- * platform's relay lane (`POST /email/relay` — design doc 0078, "Custom
221
- * domains on Cloudflare"). Undefined on the platform mail zone. A relayed
222
- * recipient carries no `<slug>+` prefix, so the generated dispatcher runs
223
- * in no-prefix mode for it (Mode B item 4) and `replyEmail` defaults
224
- * `from` to the address that received it, on this domain.
224
+ * Exact custom domain selected by the gateway. Custom-domain recipients
225
+ * have no project-slug prefix; replies default to the received address.
225
226
  */
226
227
  viaDomain?: string;
227
228
  }
228
- /**
229
- * What the user handler decided to do with a message. Returned from the
230
- * worker's `email(envelope)` RPC method as an array (handlers may chain
231
- * multiple actions, e.g. forward then reply); the router enacts each on the
232
- * real `ForwardableEmailMessage` in order.
233
- */
229
+ /** Handler intents, validated and admitted by the gateway before delivery. */
234
230
  type EmailAction = {
235
231
  type: "reject";
236
232
  reason: string;
@@ -270,7 +266,7 @@ interface CreateMessageProxyOptions {
270
266
  * accept a native `EmailMessage` from `cloudflare:email`: that class exposes
271
267
  * only `from` and `to` (workers-types declares nothing else, and the body
272
268
  * has no public accessor), so its bytes cannot be recorded here, and nothing
273
- * 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
274
270
  * buffered before being recorded so the action is fully self-contained.
275
271
  *
276
272
  * When `opts.slug` is provided, `message.to` is rewritten to remove the
@@ -323,8 +319,8 @@ interface ReplyEmailOptions {
323
319
  /**
324
320
  * Send a threading-aware reply to an inbound message. Builds the reply MIME
325
321
  * (correct `In-Reply-To` / `References` / `Subject: Re: …`) and records a
326
- * reply intent on the message proxy. The platform router enacts the reply
327
- * 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.
328
324
  *
329
325
  * The threading headers are best-effort, since the inbound values they are
330
326
  * built from are remote-sender controlled: an id no header line can carry
@@ -333,10 +329,8 @@ interface ReplyEmailOptions {
333
329
  */
334
330
  declare function replyEmail(message: ForwardableEmailMessage, opts: ReplyEmailOptions): Promise<void>;
335
331
  /**
336
- * Whether `value` is a real `ForwardableEmailMessage` — one Cloudflare
337
- * delivered to this worker's `email()` directly — rather than an
338
- * `EmailEnvelope` from the platform router. Functions do not survive RPC,
339
- * 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.
340
334
  */
341
335
  declare function isForwardableEmailMessage(value: unknown): value is ForwardableEmailMessage;
342
336
  /**
@@ -347,17 +341,9 @@ declare function isForwardableEmailMessage(value: unknown): value is Forwardable
347
341
  * duck-typed object — so `cloudflare:email` is loaded here, and only when
348
342
  * a reply was recorded.
349
343
  *
350
- * The `new Headers()` below is the one step this loop constructs from
351
- * handler input, and it cannot fail on a recorded action: the proxy's
352
- * `forward()` already ran the record through that constructor, so a bad
353
- * record failed inside the handler, before any replay. (`EmailMessage`
354
- * construction neither validates nor reads its stream — measured under
355
- * workerd.) A reject's reason reaches the native `setReject()` as the proxy
356
- * recorded it — already sliced and stripped the way the platform router
357
- * would (`normalizeRejectReason`), so the SMTP reply is the same in both
358
- * deployments. What Cloudflare itself refuses at `forward()` / `reply()`
359
- * 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.
360
346
  */
361
347
  declare function enactEmailActions(message: ForwardableEmailMessage, actions: ReadonlyArray<EmailAction>): Promise<void>;
362
348
  //#endregion
363
- 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 {
@@ -108,7 +78,7 @@ var ParseEmailError = class extends Error {
108
78
  * one-shot.
109
79
  */
110
80
  async function parseEmail(message) {
111
- 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.`);
112
82
  const { default: PostalMime } = await import("postal-mime");
113
83
  const cached = proxyRawCache.get(message);
114
84
  try {
@@ -138,10 +108,10 @@ function stripSlugFromAddress(address, slug) {
138
108
  return address;
139
109
  }
140
110
  function replyTooLarge(size) {
141
- 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.`);
142
112
  }
143
113
  function replyBudgetExhausted(size) {
144
- 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.`);
145
115
  }
146
116
  function queueFull() {
147
117
  return new MimeError("MIME_ERROR", `email: handler queued more than ${MAX_ACTIONS} actions for one message.`);
@@ -204,15 +174,29 @@ async function readStreamToBytes(stream, limit = Infinity, onOverflow = replyToo
204
174
  const reader = stream.getReader();
205
175
  const chunks = [];
206
176
  let total = 0;
207
- for (;;) {
208
- const { value, done } = await reader.read();
209
- if (done) break;
210
- total += value.byteLength;
211
- if (total > limit) {
212
- await reader.cancel().catch(() => {});
213
- 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);
214
192
  }
215
- chunks.push(value);
193
+ } catch (error) {
194
+ reader.cancel().catch(() => {});
195
+ throw error;
196
+ } finally {
197
+ try {
198
+ reader.releaseLock();
199
+ } catch {}
216
200
  }
217
201
  const out = new Uint8Array(total);
218
202
  let offset = 0;
@@ -239,7 +223,7 @@ function bytesToReadableStream(bytes) {
239
223
  * accept a native `EmailMessage` from `cloudflare:email`: that class exposes
240
224
  * only `from` and `to` (workers-types declares nothing else, and the body
241
225
  * has no public accessor), so its bytes cannot be recorded here, and nothing
242
- * 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
243
227
  * buffered before being recorded so the action is fully self-contained.
244
228
  *
245
229
  * When `opts.slug` is provided, `message.to` is rewritten to remove the
@@ -270,9 +254,20 @@ function createMessageProxy(envelope, opts = {}) {
270
254
  }
271
255
  if (envelope.raw instanceof Uint8Array) cachedRaw = envelope.raw;
272
256
  let rejected = false;
273
- let replyBytesUsed = 0;
274
- 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;
275
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();
276
271
  }
277
272
  const message = {
278
273
  from: envelope.from,
@@ -300,10 +295,12 @@ function createMessageProxy(envelope, opts = {}) {
300
295
  }
301
296
  }
302
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.`);
303
- actions.push({
298
+ const action = {
304
299
  type: "reject",
305
300
  reason: normalizeRejectReason(reason)
306
- });
301
+ };
302
+ actions.push(action);
303
+ actionWireBytesUsed = EMAIL_ACTION_TERMINAL_BYTES + emailActionWireSize(action);
307
304
  },
308
305
  async forward(to, fwdHeaders) {
309
306
  if (rejected) {
@@ -324,11 +321,13 @@ function createMessageProxy(envelope, opts = {}) {
324
321
  headersObj = { ...fwdHeaders };
325
322
  }
326
323
  if (headersObj) assertForwardHeadersWithinBounds(headersObj);
327
- actions.push({
324
+ const action = {
328
325
  type: "forward",
329
326
  to,
330
327
  ...headersObj ? { headers: headersObj } : {}
331
- });
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);
332
331
  },
333
332
  async reply(reply) {
334
333
  if (rejected) {
@@ -340,25 +339,47 @@ function createMessageProxy(envelope, opts = {}) {
340
339
  if (reply.to.length > MAX_ACTION_ADDRESS_LENGTH) throw addressTooLong("to", reply.to.length);
341
340
  validateAddressParts("from", reply.from);
342
341
  validateAddressParts("to", reply.to);
343
- const remaining = MAX_REPLY_BYTES_TOTAL - replyBytesUsed;
344
- let rawBytes;
345
- if (reply.raw instanceof Uint8Array) {
346
- if (reply.raw.byteLength > 10485760) throw replyTooLarge(reply.raw.byteLength);
347
- if (reply.raw.byteLength > remaining) throw replyBudgetExhausted(replyBytesUsed + reply.raw.byteLength);
348
- rawBytes = reply.raw;
349
- } else {
350
- const raw = reply.raw;
351
- 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.");
352
- const limit = Math.min(MAX_MESSAGE_BYTES, remaining);
353
- rawBytes = await readStreamToBytes(reply.raw, limit, (total) => limit === 10485760 ? replyTooLarge(total) : replyBudgetExhausted(replyBytesUsed + total));
354
- }
355
- replyBytesUsed += rawBytes.byteLength;
356
- actions.push({
357
- type: "reply",
358
- from: reply.from,
359
- to: reply.to,
360
- raw: rawBytes
342
+ pendingReplies++;
343
+ const previousReply = replyRecordingTail;
344
+ let releaseReply;
345
+ replyRecordingTail = new Promise((resolve) => {
346
+ releaseReply = resolve;
361
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
+ }
362
383
  }
363
384
  };
364
385
  if (envelope.to !== userVisibleTo) Object.defineProperty(message, "__voidPlatformTo", {
@@ -641,8 +662,8 @@ async function buildReplyMessage(message, opts) {
641
662
  /**
642
663
  * Send a threading-aware reply to an inbound message. Builds the reply MIME
643
664
  * (correct `In-Reply-To` / `References` / `Subject: Re: …`) and records a
644
- * reply intent on the message proxy. The platform router enacts the reply
645
- * 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.
646
667
  *
647
668
  * The threading headers are best-effort, since the inbound values they are
648
669
  * built from are remote-sender controlled: an id no header line can carry
@@ -658,16 +679,32 @@ async function replyEmail(message, opts) {
658
679
  });
659
680
  }
660
681
  /**
661
- * Whether `value` is a real `ForwardableEmailMessage` — one Cloudflare
662
- * delivered to this worker's `email()` directly — rather than an
663
- * `EmailEnvelope` from the platform router. Functions do not survive RPC,
664
- * 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.
665
684
  */
666
685
  function isForwardableEmailMessage(value) {
667
686
  if (typeof value !== "object" || value === null) return false;
668
687
  const m = value;
669
688
  return typeof m.setReject === "function" && typeof m.forward === "function" && typeof m.reply === "function";
670
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
+ }
671
708
  /**
672
709
  * Replay recorded `EmailAction`s on a real `ForwardableEmailMessage`, in
673
710
  * order. The proxy already made `setReject` terminal (earlier forward/reply
@@ -676,28 +713,21 @@ function isForwardableEmailMessage(value) {
676
713
  * duck-typed object — so `cloudflare:email` is loaded here, and only when
677
714
  * a reply was recorded.
678
715
  *
679
- * The `new Headers()` below is the one step this loop constructs from
680
- * handler input, and it cannot fail on a recorded action: the proxy's
681
- * `forward()` already ran the record through that constructor, so a bad
682
- * record failed inside the handler, before any replay. (`EmailMessage`
683
- * construction neither validates nor reads its stream — measured under
684
- * workerd.) A reject's reason reaches the native `setReject()` as the proxy
685
- * recorded it — already sliced and stripped the way the platform router
686
- * would (`normalizeRejectReason`), so the SMTP reply is the same in both
687
- * deployments. What Cloudflare itself refuses at `forward()` / `reply()`
688
- * 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.
689
718
  */
690
719
  async function enactEmailActions(message, actions) {
720
+ const providerDeadline = createInboundProviderDeadline();
691
721
  for (const action of actions) switch (action.type) {
692
722
  case "reject":
693
723
  message.setReject(action.reason);
694
724
  break;
695
725
  case "forward":
696
- 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));
697
727
  break;
698
728
  case "reply": {
699
729
  const { EmailMessage } = await import("cloudflare:email");
700
- 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))));
701
731
  break;
702
732
  }
703
733
  }