Receiving Email
Receive mail with handlers in the top-level email/ folder. Inbound handlers need a native SDK app; framework and Node.js, Bun or Deno builds do not support them.
On this pageWrite an inbound email handler · Route mail per recipient · Test receiving locally
// email/_default.ts — fallback for any unmatched recipient
import { defineEmail, parseEmail } from 'void/email';
export default defineEmail(async (message, env, ctx) => {
const parsed = await parseEmail(message);
if (parsed.subject?.startsWith('STOP')) {
message.setReject('Use the unsubscribe link.');
return;
}
await message.forward('archive@example.com');
});The handler gets Cloudflare's native ForwardableEmailMessage. An optional fourth info argument holds params for dynamic and tagged segments.
| Method | Purpose |
|---|---|
message.setReject(reason) | Reject the message with an SMTP error. |
message.forward(to, headers?) | Forward to a verified destination. |
replyEmail(message, options) | Send a threaded reply. |
message.reply({ from, to, raw }) | Reply using your own MIME bytes or stream. |
Returning without an action accepts the message. Use bare email addresses for forward and message.reply.
parseEmail(message) returns from, to, subject, text, html, attachments, and headers. Call it once per message at most. The stream can be read only once.
Per-recipient routing
email/
support.ts — handles support@<your-domain>
billing.ts — handles billing@<your-domain>
support+[ticket].ts — handles support+ticket-123@... → info.params.ticket = "ticket-123"
support+vip.ts — handles exactly support+vip@..., ahead of support+[ticket].ts
[user].ts — dynamic local-part → info.params.user
[user]+[tag].ts — dynamic + subaddress → info.params.user, info.params.tag
_default.ts — fallback when no per-address pattern matchesFiles match the local part of the To address, before the @. Matching ignores case. Match precedence (most specific wins):
support+vip.ts(static + literal tag)support+[ticket].ts(static + captured tag)support.ts(static)[user]+vip.ts(dynamic + literal tag)[user]+[tag].ts(dynamic + captured tag)[user].ts(dynamic)_default.ts(fallback)
The first pattern that matches wins. Within a shape, a literal tag beats a captured tag. support+vip@ reaches support+vip.ts. Every other support+…@ reaches support+[ticket].ts. Handlers of the same shape are tried alphabetically.
If nothing matches and there is no _default.ts, Cloudflare takes over. The sender gets a non-delivery report. Nested folders under email/ are ignored. The recipient is a flat string, not a path.
Threaded replies
// email/support+[ticket].ts
import { defineEmail, parseEmail, replyEmail } from 'void/email';
export default defineEmail(async (message, env, ctx, info) => {
const parsed = await parseEmail(message);
await ticketStore.append(info!.params.ticket, parsed.text ?? '');
await replyEmail(message, {
text: `Got your message on ticket ${info!.params.ticket}.`,
});
});replyEmail() replies to the original sender with a threaded subject. You can override subject.
On an SDK platform, the default sender is your project's shared address. On a registered domain, it is the address that received the mail. Use the default for shared mail. message.to has the project prefix removed. Sender overrides must belong to your project.
Replies follow your project's recipient policy. Verify shared-sender recipients with void email allow <address>. Check void email logs for the provider outcome after the handler finishes.
Forwarding needs a native Cloudflare email event. For custom domains relayed by the platform, reply instead.
Configuring inbound delivery
On an email-enabled platform, <slug>+anything@<mail domain> reaches your deployed email/ handlers. No per-project DNS is needed once the platform verifies your recipient rule and enables plus addressing. The SDK sets up the rule when you deploy handlers. Creating a project or only sending mail needs no inbound rule. If setup fails, the deploy reports a routing conflict or capacity limit. A registered custom domain reaches those handlers once its inbound readiness is ready. Use void email domain status to check current provider routing and any remaining setup steps.
For direct deployments, follow Your own Cloudflare account to configure inbound addresses.
void dev has no local inbound trigger. It only serves the outbound dev inbox. Test inbound handlers with createInboundTestHarness below.
Testing inbound handlers
This step tests handlers with createInboundTestHarness. It captures replies, forwards and rejections. For shared platform mail, pass the full address including the project prefix, such as acme+support+abc-123@acme.dev. For direct Cloudflare mail, set slug: null and use support+abc-123@mail.acme.com.
import { describe, it, expect } from 'vitest';
import { createInboundTestHarness } from 'void/email/testing';
import defaultHandler from './email/_default';
import support from './email/support+[ticket]';
describe('email handlers', () => {
it('routes to support+[ticket] and extracts the tag', async () => {
const harness = createInboundTestHarness({
routes: { 'support+[ticket]': support, _default: defaultHandler },
});
const result = await harness.deliver({
from: 'user@example.com',
to: 'acme+support+abc-123@acme.dev',
subject: 'Help',
text: 'I have a problem',
});
expect(result.handler).toBe('support+[ticket]');
expect(result.params).toEqual({ ticket: 'abc-123' });
});
it('rejects STOP messages via _default', async () => {
const harness = createInboundTestHarness({ routes: { _default: defaultHandler } });
await harness.deliver({
from: 'user@example.com',
to: 'acme+unknown@acme.dev',
subject: 'STOP',
text: 'bye',
});
expect(harness.rejects).toEqual(['Use the unsubscribe link.']);
});
});The harness matches handlers in the same order as deployed email routes.