Skip to content

[miniflare] Add backend resources for email capture and storage - #15064

Open
tpmmorris wants to merge 12 commits into
cloudflare:mainfrom
tpmmorris:main
Open

[miniflare] Add backend resources for email capture and storage#15064
tpmmorris wants to merge 12 commits into
cloudflare:mainfrom
tpmmorris:main

Conversation

@tpmmorris

@tpmmorris tpmmorris commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Add backend resources for Email interaction within Local Explorer

Fixes #13648


Adds storage and capture methods for emails sent from/received by a worker using durable objects for storage (in line with the new 'Observability' tab), and cdn-cgi endpoints to mimic the sending of an email to a worker. Also records actions taken by the email() handler (received, forwarded, replied, rejected, unhandled), so that they can be mapped and displayed in local explorer in a similar manner as the dash. The Email result interface has been updated to also include a list of events.

  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation
    • Cloudflare docs PR(s):
    • Documentation not necessary because: no changes are user-facing.

Open in Devin Review

@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2db167c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 8 packages
Name Type
miniflare Minor
wrangler Minor
@cloudflare/deploy-helpers Patch
@cloudflare/pages-shared Patch
@cloudflare/remote-bindings Patch
@cloudflare/runtime-types Patch
@cloudflare/vite-plugin Patch
@cloudflare/vitest-pool-workers Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@workers-devprod
workers-devprod requested review from a team and petebacondarwin and removed request for a team August 6, 2026 17:21
@workers-devprod

workers-devprod commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/wrangler
Show detailed file reviewers
  • .changeset/local-email-capture.md: [@cloudflare/wrangler]
  • packages/miniflare/openapi-ts.config.ts: [@cloudflare/wrangler]
  • packages/miniflare/scripts/openapi-filter-config.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/index.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/core/constants.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/core/explorer.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/core/index.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/core/temp-file.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/core/types.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/email/artifacts.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/email/index.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/email/store.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/core/constants.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/core/email.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/core/entry.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/capture.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/constants.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/email-store.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/email-store.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/email.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/message-id.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/send_email.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/storage.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/validate.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/index.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/explorer.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/generated/index.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/generated/types.gen.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/openapi.local.json: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/resources/email.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/route-names.ts: [@cloudflare/wrangler]
  • packages/miniflare/test/index.spec.ts: [@cloudflare/wrangler]
  • packages/miniflare/test/plugins/email/artifacts.spec.ts: [@cloudflare/wrangler]
  • packages/miniflare/test/plugins/email/index.spec.ts: [@cloudflare/wrangler]
  • packages/miniflare/test/plugins/local-explorer/email.spec.ts: [@cloudflare/wrangler]
  • packages/miniflare/test/plugins/local-explorer/index.spec.ts: [@cloudflare/wrangler]
  • packages/wrangler/e2e/createTestHarness.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/e2e/dev.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/e2e/get-platform-proxy.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/e2e/multiworker-dev.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/api/test-harness.ts: [@cloudflare/wrangler]

devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment thread packages/miniflare/src/workers/core/email.ts
rawBase64: bytesToBase64(rawEmailBuffer),
});

this.ctx.waitUntil(

@devin-ai-integration devin-ai-integration Bot Aug 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Emails sent just before the dev session shuts down are never written to disk or logged

The on-disk copy of a sent email and its log line are queued to run in the background (this.ctx.waitUntil(...) at packages/miniflare/src/workers/email/send_email.worker.ts:370 and :451) instead of being finished before the send call returns, so a script that sends an email and then immediately shuts the local dev session down loses the saved message entirely.

Impact: Short-lived usages (for example sending through getPlatformProxy() and then disposing) no longer reliably produce the .eml/text/HTML/attachment files or the "send_email binding called..." log they used to.

Why the deferred work can be dropped

Before this change send() awaited every storeTempFile() call and logged before resolving, so by the time the caller's await env.SEND_EMAIL.send(...) returned the files existed. Now both branches resolve immediately after the in-workerd capture, deferring the loopback /core/store-temp-file writes and logging to ctx.waitUntil.

Miniflare#dispose() aborts, stops the loopback server and tears down workerd before drainEmailArtifactManager() runs (packages/miniflare/src/index.ts:3490-3496); drain() only awaits operations that already reached the Node side (packages/miniflare/src/plugins/email/artifacts.ts:86-89), so waitUntil work that has not yet issued its loopback request is simply discarded.

The test updates in this PR reflect the new asynchrony (the miniflare email specs now poll with vi.waitFor, and the get-platform-proxy e2e no longer asserts on the file), but callers that dispose right after sending have no way to wait.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread packages/miniflare/src/plugins/email/artifacts.ts Outdated
@pkg-pr-new

pkg-pr-new Bot commented Aug 6, 2026

Copy link
Copy Markdown
@cloudflare/autoconfig

npm i https://pkg.pr.new/@cloudflare/autoconfig@15064

@cloudflare/build-output-utils

npm i https://pkg.pr.new/@cloudflare/build-output-utils@15064

@cloudflare/config

npm i https://pkg.pr.new/@cloudflare/config@15064

create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@15064

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/@cloudflare/deploy-helpers@15064

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@15064

miniflare

npm i https://pkg.pr.new/miniflare@15064

@cloudflare/pages-functions

npm i https://pkg.pr.new/@cloudflare/pages-functions@15064

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@15064

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@15064

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@15064

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@15064

@cloudflare/workers-auth

npm i https://pkg.pr.new/@cloudflare/workers-auth@15064

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@15064

@cloudflare/workers-utils

npm i https://pkg.pr.new/@cloudflare/workers-utils@15064

wrangler

npm i https://pkg.pr.new/wrangler@15064

commit: 2db167c

devin-ai-integration[bot]

This comment was marked as resolved.

@tpmmorris
tpmmorris force-pushed the main branch 2 times, most recently from 7fccbb7 to 494e214 Compare August 7, 2026 12:57
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@petebacondarwin petebacondarwin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address the bugs highlighted by Devin

@github-project-automation github-project-automation Bot moved this from Untriaged to In Review in workers-sdk Aug 10, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@emily-shen emily-shen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a quick first pass on the API surface

"tags": ["Local Explorer"]
}
},
"/email/routing": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so because these have different semantics to the actual endpoints, we need to stick these in the local namespace. otherwise it will cause confusion between the 'real' api and this one

(https://developers.cloudflare.com/api/resources/email_routing/methods/get)

"schema": {
"type": "string"
},
"description": "Deliver the test email to this worker's email() handler, regardless of address-based routing."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do you mean by address-based routing? the port? that won't hold because there can be multiple workers per port. you could either use the worker name as part of the path or make this required

},
"outcome": {
"type": "string",
"enum": ["ok", "exception"],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmm. as in you would want a 200 exception if the handler intentionally threw?

task failed successfully i guess 😅

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is a weird situation because the message was sent successfully, just the worker itself didn't like it. This is a confusing way to express it but I felt using a non 200 status would make it seem like the send itself failed. I'll make it clearer in the docs whats actually happening, unless youd have a different preference to how its handled?

"tags": ["Email"]
}
},
"/email/routing/{email_id}": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could this be a query param on GET /email/routing above

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The list and detail items have a different shape so i thought it would be appropriate to keep separate, if not I can make this change?

"tags": ["Email"]
}
},
"/email/sending/{email_id}": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similarly to above, query param on the main list endpoint?

Comment on lines +3721 to +3734
"properties": {
"type": {
"type": "string",
"enum": ["received", "forward", "reply", "reject", "unhandled"],
"description": "The kind of event."
},
"timestamp": {
"type": "string",
"description": "ISO 8601 timestamp of when the event occurred."
},
"messageId": {
"type": "string",
"description": "Present on `forward`/`reply` events; correlates with the matching `forwards`/`replies` entry."
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could be a discriminated union to show that messageId is only available when the type is forward or reply

},
"headers": {
"type": "array",
"description": "Headers added to the forwarded message, as [key, value] pairs.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not an array of objects?

description:
"Worker whose email() handler processed the message, if known.",
},
from: { type: "string", description: "Envelope MAIL FROM address." },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's quite a lot of repetition in these objects with from/to/subject/messageId etc.

could you define an object like 'base_email' and extend that?

@workers-devprod

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/wrangler
Show detailed file reviewers
  • .changeset/local-email-capture.md: [@cloudflare/wrangler]
  • packages/miniflare/openapi-ts.config.ts: [@cloudflare/wrangler]
  • packages/miniflare/scripts/openapi-filter-config.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/index.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/core/constants.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/core/explorer.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/core/index.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/core/temp-file.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/core/types.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/email/index.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/email/store.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/core/constants.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/core/email.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/core/entry.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/capture.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/constants.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/email-store.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/email-store.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/email.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/message-id.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/send_email.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/storage.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/email/validate.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/index.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/explorer.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/generated/index.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/generated/types.gen.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/openapi.local.json: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/resources/email.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/local-explorer/route-names.ts: [@cloudflare/wrangler]
  • packages/miniflare/test/index.spec.ts: [@cloudflare/wrangler]
  • packages/miniflare/test/plugins/email/index.spec.ts: [@cloudflare/wrangler]
  • packages/miniflare/test/plugins/local-explorer/email.spec.ts: [@cloudflare/wrangler]
  • packages/miniflare/test/plugins/local-explorer/index.spec.ts: [@cloudflare/wrangler]
  • packages/wrangler/e2e/createTestHarness.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/e2e/dev.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/e2e/get-platform-proxy.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/e2e/multiworker-dev.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/api/test-harness.ts: [@cloudflare/wrangler]

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@tpmmorris
tpmmorris marked this pull request as ready for review August 12, 2026 16:58

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment on lines +264 to +272
const canContinue = candidate.hasMore && candidate.nextCursor !== undefined;
state[source] = canContinue ? candidate.nextCursor : null;
if (canContinue) {
hasMore = true;
}
if (items.length < options.query.per_page && canContinue) {
await getCandidate(source);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Email list in the inspector can offer a "next page" that turns out to be empty

When emails are combined from several dev instances, the list can be marked as having more results (hasMore = true at packages/miniflare/src/workers/local-explorer/resources/email.ts:264-266) even after the follow-up lookup finds nothing left to show, so the inspector offers another page that comes back empty.

Impact: Users paginating the Email tab can be shown a "load more" affordance that yields zero emails.

Mechanism: hasMore is latched from the consumed candidate before the refill attempt is evaluated

In listAggregatedEmails (packages/miniflare/src/workers/local-explorer/resources/email.ts:207-282), after an item is emitted the code sets hasMore = true whenever candidate.hasMore && candidate.nextCursor !== undefined. It then calls getCandidate(source) again; if that refill returns undefined (e.g. every remaining record on that source is filtered out by the worker query parameter, or the peer became unreachable), state[source] is set to null and no candidate is added. If no other source still has a candidate, the loop exits with candidates.size === 0 but hasMore still true, so a cursor is returned. The subsequent request resolves every source to null and returns items: [], has_more: false.

A correct signal would be derived after the refill attempt (i.e. hasMore = candidates.size > 0 plus any source with a non-null state that still has unexamined records).

Prompt for agents
In listAggregatedEmails (packages/miniflare/src/workers/local-explorer/resources/email.ts), `hasMore` is set to true as soon as a consumed candidate reports it could continue, before the follow-up getCandidate() call has established whether any further *matching* item actually exists for that source. When the refill finds nothing (all remaining records filtered out by the `worker` filter, or the peer stopped responding), the source's state is set to null and it is removed from `candidates`, yet `hasMore` stays true and a cursor is emitted. The client then fetches one more page that is guaranteed to be empty. Consider computing the final `hasMore` from the post-loop state instead: true only if `candidates.size > 0`, or if a source was left with a non-null cursor that was never re-probed (i.e. the loop stopped because the page filled up).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Review

Development

Successfully merging this pull request may close these issues.

4 participants