A mailbox per workspace: verify before you parse
Mail the workspace and the lead answers in the thread. The inbound webhook verifies the raw body before parsing it, threading walks Message-ID then References then subject, and a stranger's mail becomes an ask instead of an agent turn.

Status: shipped, dark. vocion-core #334 is running, migration 0097_workspace_mailbox.sql included. Merged onto the release branch release/2026-09-15-rail (7db4f7f6) and deployed to a deployment we operate; migrations 0094–0099 are applied. The surface itself stays off until a deployment sets VOCION_EMAIL_SURFACE — shipped and enabled are two different things here. The pull request is still landing on main individually behind that release branch.
A workspace already has an org chart, a review queue and a conversation surface. Giving it an address is mostly plumbing — except for three decisions that are easy to get wrong and expensive to get wrong later.
Verify before you parse
The inbound route is short, and the order of its statements is the whole security property:
export async function POST(request: Request) {
if (!emailSurfaceEnabled()) {
return NextResponse.json({ error: 'The email surface is not enabled on this deployment' }, { status: 501 });
}
const raw = await request.text();
const verified = verifySvixSignature(raw, request.headers, process.env.RESEND_WEBHOOK_SECRET);
if (!verified.ok) {
const status = verified.reason === 'missing_secret' ? 501 : 401;
return NextResponse.json({ error: `signature check failed: ${verified.reason}` }, { status });
}
let payload: unknown;
try { payload = JSON.parse(raw); } catch { return NextResponse.json({ error: 'body is not JSON' }, { status: 400 }); }
// …
}
The body is read once as text, verified as text, and only then parsed. Parsing first — which is what happens by default in every framework that hands you a decoded body — means untrusted bytes have already been through a parser before anything checked whether they were allowed to be there.
The provider is Resend, which signs webhooks with Svix, so the headers are svix-id, svix-timestamp and svix-signature rather than anything Resend-branded, and the secret is a whsec_-prefixed base64 string. verifySvixSignature in packages/core/src/libs/surfaces/email.ts implements that scheme directly:
const MAX_SKEW_SECONDS = 300; // a five-minute replay window
const tsNum = Number(ts);
if (!Number.isFinite(tsNum) || Math.abs(now - tsNum) > MAX_SKEW_SECONDS) {
return { ok: false, reason: 'stale' };
}
const key = Buffer.from(secret.startsWith('whsec_') ? secret.slice('whsec_'.length) : secret, 'base64');
const expected = Buffer.from(createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64'));
// `svix-signature` is a space-separated list of `v1,<sig>` entries — that is
// how rotation works, so any entry matching is a pass.
const candidates = sigHeader.split(/\s+/).filter(Boolean).map((entry) => {
const comma = entry.indexOf(',');
return comma === -1 ? entry : entry.slice(comma + 1);
});
const matched = candidates.some((sig) => {
const b = Buffer.from(sig);
return b.length === expected.length && timingSafeEqual(b, expected);
});
Three details earn their place. The five-minute window bounds replay. Accepting any entry in the list is what makes secret rotation a rotation rather than an outage. And timingSafeEqual throws on mismatched lengths, so the length guard is not redundant defensiveness — without it a wrong-length signature is an exception, not a false.
The negative cases are the tests, not the prose: packages/core/src/libs/surfaces/email.test.ts has seven, including one named "rejects a wrong secret, a tampered body, a replay outside five minutes, and missing pieces".
One more thing shapes the design: the webhook carries metadata only. Resend's receiving webhook does not include the body, headers or attachments, so the content arrives on a second authenticated call:
export async function fetchReceivedEmail(id: string, apiKey: string, fetchImpl: typeof fetch = fetch) {
const res = await fetchImpl(`${RESEND_RECEIVING_ENDPOINT}/${encodeURIComponent(id)}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) {
throw new Error(`Resend receiving API ${res.status}: ${(await res.text().catch(() => '')).slice(0, 300)}`);
}
return (await res.json()) as ReceivedEmail;
}
Verify, parse metadata, then go and get the content. fetchImpl is injected so the tests drive this without a network. The endpoint constant and the ReceivedEmail shape are in the same file; Resend's receiving API is in limited availability at the time of writing, and nothing here has been exercised against a live inbound webhook — see the caveat at the end.
Threading is a ladder, not a join
There is one table, one row per mail in or out:
CREATE TABLE IF NOT EXISTS "email_thread" (
"id" serial PRIMARY KEY NOT NULL,
"org_id" text NOT NULL,
"conversation_id" integer NOT NULL REFERENCES "conversation"("id") ON DELETE CASCADE,
"message_id" text NOT NULL,
"received_email_id" text,
"direction" text NOT NULL, -- 'in' | 'out'
"from_address" text,
"subject" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX "email_thread_org_message_id_uq" ON "email_thread" ("org_id","message_id");
CREATE UNIQUE INDEX "email_thread_received_email_id_uq"
ON "email_thread" ("received_email_id") WHERE "received_email_id" IS NOT NULL;
There are no in_reply_to or references columns. Those are read off the inbound headers at request time and matched against stored message_ids, which is the direction the data actually flows.
findThread in packages/core/src/services/EmailSurfaceService.ts tries three things in order:
if (referenced.length > 0) {
const [hit] = await db.select({ conversationId: emailThreadSchema.conversationId })
.from(emailThreadSchema)
.where(and(eq(emailThreadSchema.orgId, orgId), inArray(emailThreadSchema.messageId, referenced)))
.orderBy(desc(emailThreadSchema.id)).limit(1);
if (hit) { return hit.conversationId; }
}
const subj = normaliseSubject(subject);
if (!subj) { return null; }
const since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const rows = await db.select({ conversationId: …, subject: … })
.from(emailThreadSchema)
.where(and(
eq(emailThreadSchema.orgId, orgId),
eq(emailThreadSchema.direction, 'in'),
eq(emailThreadSchema.fromAddress, sender),
gt(emailThreadSchema.createdAt, since),
))
.orderBy(desc(emailThreadSchema.id)).limit(20);
return rows.find(r => normaliseSubject(r.subject ?? '') === subj)?.conversationId ?? null;
referenced comes from referencedMessageIds(headers), which puts In-Reply-To first, then every <…> in References, deduped and case-insensitive on the header name. A mail client that drops those headers still threads on the second rung. A stranger reusing a subject line six months later does not — the window is seven days and the sender must match.
normaliseSubject peels re|fwd?|aw|sv|antw: in a loop rather than with a repeated regex group, specifically to avoid super-linear backtracking on a header an outsider controls.
The PGlite-backed tests in EmailSurfaceService.test.ts cover all three rungs plus the redelivery drop and the unknown-sender ask — nine of them.
Idempotency is separate from threading and comes first: the partial unique index on received_email_id means a provider redelivery is dropped before any of this runs.
A sender's address authorises nothing
Which workspace a mail belongs to is decided by the address it was sent to. Who sent it decides exactly one thing: whether an agent turn runs at all.
const known = await isKnownSender(box, meta.from);
if (!known) {
// A stranger wrote to the workspace. No agent turn: file it for a person,
// acknowledge the sender, remember the mail so a redelivery is dropped.
const conv = await createConversation({ /* … */ });
await appendMessage({ orgId, conversationId: conv.id, role: 'user', content: body, userId: `email:${meta.from}` });
const { ask } = await upsertAsk({
orgId,
ask: {
kind: 'input',
title: `Unknown sender wrote to ${box.address}`,
options: [
{ id: 'reply-myself', label: 'I will reply myself' },
{ id: 'let-agent-answer', label: 'Let the workspace lead answer', recommended: true },
{ id: 'ignore', label: 'Ignore' },
],
// …
},
});
}
The mail still becomes a conversation and a thread row — so a redelivery is still dropped, and a person can read what was sent. It just never reaches a model. The acknowledgement that goes back to the stranger is threaded the same way a real reply is, and a failure to send it degrades to acknowledged: false on the returned outcome rather than throwing away the ask that a person needs to see.
An unknown sender lands here, in the same decision sheet as every other thing an agent is waiting on. The three options are the three honest answers, and one of them is marked recommended.
The full outcome union that handleInboundEmail returns is itself a decent summary of the surface: unbound | duplicate | no_lead | unknown_sender | over_budget | replied | failed.
Outbound
The address is the workspace slug as the local part on one deployment domain — <slug>@<VOCION_MAIL_DOMAIN> — not a subdomain and not plus-addressing.
export function defaultMailboxAddress(slug: string, domain: string): string {
return `${slug.toLowerCase()}@${domain}`;
}
export function addressOnDomain(address: string, domain: string): boolean {
const at = address.lastIndexOf('@');
return at > 0 && address.slice(at + 1).toLowerCase() === domain.toLowerCase();
}
addressOnDomain is the tenancy guard, and the applier is where it bites (packages/core/src/libs/workspace/applier.ts):
if (mailboxManifest?.enabled) {
const domain = mailDomain();
if (!domain) {
errors.push({ resource: 'workspace', slug: 'workspace.yaml',
message: 'mailbox.enabled is set but VOCION_MAIL_DOMAIN is not configured on this deployment' });
} else {
const candidate = (mailboxManifest.address
?? defaultMailboxAddress(project?.slug ?? loaded.manifest.name, domain)).toLowerCase();
if (!addressOnDomain(candidate, domain)) {
errors.push({ resource: 'workspace', slug: 'workspace.yaml',
message: `mailbox.address "${candidate}" is not on ${domain}; a workspace may only `
+ `claim addresses on the deployment's mail domain` });
} else {
mailboxEnabled = true;
mailboxAddress = candidate;
}
}
}
Both rejection paths are errors on the apply, not warnings — a workspace must not pose as another host, and a mailbox that silently did not get configured is worse than a failed apply.
The reply mints its own Message-ID, sets In-Reply-To to the inbound id, and appends that id to References. Because whether a provider honours a caller-supplied Message-ID is not something we have verified in production, the threading ladder above is not decoration — it is the fallback for the case where it does not.
The lead is told how it was reached, as text, rather than being swapped for a different agent:
--- how I am reaching you ---
This arrived by email at <address> from <sender> with the subject "…".
Answer as an email reply: plain prose, no markdown tables, links written out in full.
An accident worth keeping
The first version of this had the workspace applier importing addressOnDomain from the email service. The applier runs inside the scheduling worker, whose bundle must stay free of the agent runtime — and the email service imports the agent loop. A follow-up commit extracted four pure functions into libs/mail/mailbox.ts. The bundle boundary did the design review: pure predicates belong in a library, not in a service that also knows how to run a model.
Where this lives in the repo
Read off release/2026-09-15-rail at 7db4f7f6, the commit that shipped. #334 is merging to main individually behind it.
- Route:
packages/core/src/app/api/webhooks/resend/route.ts - Verification and header parsing:
packages/core/src/libs/surfaces/email.ts(+email.test.ts) - Inbound handling, threading, the unknown-sender ask:
packages/core/src/services/EmailSurfaceService.ts(+.test.ts, on PGlite) - Pure mailbox helpers:
packages/core/src/libs/mail/mailbox.ts - Transport and the
headersfield that makes threading possible:packages/core/src/libs/mail/index.ts,resend.ts - Manifest and applier guard:
packages/core/src/libs/workspace/schemas.ts,applier.ts - Migration:
packages/core/migrations/0097_workspace_mailbox.sql - Guide:
docs/guides/email.md
Not verified, and worth stating plainly: no live inbound webhook has fired against this code, and no live body fetch has run — the tests inject fetch. Whether Resend honours a caller-supplied Message-ID header is unknown, which is why the threading ladder has two fallbacks rather than one. Attachments are listed by filename and never read.
Related: Vocion can mail you now and Needs you: every decision an agent is waiting on.