Files
mailflare/worker.ts
T
Oday BakkourandClaude Opus 5 32d019303c feat(routing): catch-all, forwarding, and reject rules with priorities
Closes the "advanced routing rules" roadmap item. Previously resolveInboundAddress
only ever matched an exact mailbox or alias and hard-coded action "store", so the
forward/reject branches downstream were unreachable and catch-all addresses were
impossible.

Domain rules now resolve in three phases, which is what stops a `*` catch-all
from shadowing real mailboxes:

  1. reject rules, evaluated before any mailbox lookup so a sender can be blocked
     even when the recipient is a real mailbox
  2. exact mailbox, then multi-domain mailbox aliases
  3. remaining forward/store rules as the catch-all fallback

Within each phase rules run by descending priority, then oldest first.

forward and reject are actioned in the Worker `email` handler rather than the
inbound queue consumer, because message.forward() and message.setReject() only
exist on the live ForwardableEmailMessage. A forward drops the message unless the
rule keeps a copy, and a failed forward still stores the message so mail is never
silently lost. Regex patterns are guarded so an invalid pattern cannot throw and
take down inbound processing.

The existing mailbox-scope rules are unchanged in behaviour; both queries now
filter on scope so the two kinds never leak into each other's UI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGeWDVSkXtVEmDAN2AtB9h
2026-08-30 10:18:12 +03:00

97 lines
3.6 KiB
TypeScript

// @ts-ignore — generated at build time
import { default as nextHandler } from "./.open-next/worker.js";
import {
processInboundMessage,
storeRawToR2,
type InboundQueueMessage,
} from "./src/lib/email/inbound";
import { processOutboundQueue, type OutboundQueueMessage } from "./src/lib/email/send";
import { isInboundQueueMessage, isWebhookRetryMessage } from "./worker-utils";
import { processWebhookRetry, type WebhookRetryMessage } from "./src/lib/email/webhooks";
import { resolveIncomingMail, forwardMessage } from "./src/lib/email/incoming";
import { getUserFromSession } from "./src/lib/auth/session";
import { getSessionTokenFromRequest } from "./src/lib/realtime/utils";
import {
getAccountForwardingDestination,
MAILFLARE_FORWARDED_HEADER,
} from "./src/lib/email/account-forwarding";
export { RealtimeHub } from "./src/lib/realtime/hub";
export { DatabaseBackupWorkflow } from "./src/lib/backups/workflow";
export default {
async fetch(request: Request, env: CloudflareEnv, ctx: ExecutionContext) {
const url = new URL(request.url);
if (url.pathname === "/api/realtime") {
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
return new Response("Expected WebSocket upgrade", { status: 426 });
}
const user = await getUserFromSession(env, getSessionTokenFromRequest(request));
if (!user || user.disabled) {
return new Response("Unauthorized", { status: 401 });
}
const hub = env.REALTIME.getByName(user.id);
return hub.fetch(new Request("https://mailflare-realtime/connect", request));
}
return nextHandler.fetch(request, env, ctx);
},
async email(message: ForwardableEmailMessage, env: CloudflareEnv, ctx: ExecutionContext) {
try {
// Domain routing rules are resolved here rather than in the queue because reject and
// forward can only be actioned on the live ForwardableEmailMessage.
const decision = await resolveIncomingMail(env, message.from, message.to);
if (decision?.action === "reject") {
message.setReject(decision.rejectReason ?? "Message rejected by routing rule");
return;
}
if (decision?.action === "forward" && decision.forwardTo) {
const forwarded = await forwardMessage(message, decision.forwardTo);
// A forward rule drops the message unless it was explicitly asked to keep a copy.
// If the forward itself failed we still store it, so mail is never silently lost.
if (forwarded && !decision.keepCopy) return;
}
if (message.headers.get(MAILFLARE_FORWARDED_HEADER) !== "1") {
const forwardingDestination = await getAccountForwardingDestination(env, message.to);
if (forwardingDestination) {
await forwardMessage(message, forwardingDestination);
}
}
const rawR2Key = await storeRawToR2(env, message.from, message.to, message.raw);
const payload: InboundQueueMessage = {
from: message.from,
to: message.to,
rawR2Key,
headers: Object.fromEntries(message.headers),
};
await env.INBOUND_QUEUE.send(payload);
} catch (err) {
console.error("Inbound enqueue failed", err);
message.setReject("Processing failed");
}
},
async queue(batch: MessageBatch, env: CloudflareEnv): Promise<void> {
for (const msg of batch.messages) {
try {
if (isInboundQueueMessage(msg.body)) {
await processInboundMessage(env, msg.body);
} else if (isWebhookRetryMessage(msg.body)) {
await processWebhookRetry(env, msg.body as WebhookRetryMessage);
} else {
await processOutboundQueue(env, msg.body as OutboundQueueMessage);
}
msg.ack();
} catch (err) {
console.error("Queue processing failed", err);
msg.retry({ delaySeconds: 10 });
}
}
},
} satisfies ExportedHandler<CloudflareEnv>;