Tenant mail
A tenant App receives mail through a signed webhook and sends its held messages over SMTP with DKIM, each in its own tenant.
A tenant App (auth.rows: 'tenant') runs its desk’s mail through its jobs
module: mail that arrives runs a command in the tenant its address names, and
mail the App holds is sent from it. The webhook lives where the App’s other
/api routes do: at the server’s root (/api/mail/inbound) for an App served
alone, --mount or not, since --mount moves the App’s pages and not the
server’s /api; under --base (/<base>/api/mail/inbound); and in a suite,
where each App is served under its mount with its config’s email service and
its own environment, at /<mount>/api/mail/inbound.
export default defineJobs({}, { principals: { 'mail-intake': { roles: ['intake'] }, 'mail-sender': { roles: ['sender'] }, 'requester-links': { roles: ['reader'] } }, mail: { receive: { runAs: 'mail-intake', command: 'receiveEmail', attachments: 'attachment' }, send: { runAs: 'mail-sender', claim: 'claimOutbox', record: 'recordSent', every: '30 seconds' }, links: { runAs: 'requester-links', command: 'viewTicket', reply: 'replyToTicket' }, },});{ "name": "mail", "kind": "email", "url": "smtps://smtp.example.com:465", "from": "Help Desk <desk@example.com>", "auth": { "username": "${SMTP_USER}", "password": "${SMTP_PASSWORD}" }, "inbound": { "secret": "${MAIL_INBOUND_SECRET}", "domain": "in.example.com", "maxBytes": 10485760 }, "dkim": { "selector": "tw1", "key": "dkim-tw1.pem" }, "domains": { "acme.com": "acme" }, "links": { "secret": "${MAIL_LINK_SECRET}", "ttl": "30 days" }, "spf": "_spf.example-relay.com", "returnPath": "bounce.example.com" }receive: each message runscommandasrunAsin its tenant, withtargets: 'none'. Its input is{ to, from, from_name, subject, body, message_id, in_reply_to, references }(the body without the quoted history), or whatinput: (mail) => ({ … })makes of the whole message. The Message-ID is the receipt’s idempotency key: a retried message writes nothing twice.attachmentsnames a file resource the command writes; each part must pass its size and name rules and reaches the handler asfiles: [{ name, type, size, data }]in its context, whichdb.attachment.insertFile(file, { message_id })stores. The audit keeps the file’s name, type and size, never its bytes.send: everyevery(5 seconds to a day, default a minute), in each active tenant,claimreturns{ messages: [{ id, from, to, cc?, replyTo?, subject, text, html?, messageId?, inReplyTo?, references?, link? }] }. Each is sent with its own Message-ID, In-Reply-To and References, signed with DKIM, andrecordruns with{ id, status: 'sent' | 'failed' | 'bounced', messageId?, error? }. A From on a domaindomainsgives that tenant is sent as it is; any other goes From the service’s address under the tenant’s name, with the tenant’s address as Reply-To.links: a message withlink: { id: 4 }gets a signed link to that record where its words say{link}. OpeningGET /api/mail/link?token=…runscommandon that record asrunAsand shows its result, what the App decided a requester may see, as a page with no sign-in: atitleorsubjectheads it, other values are listed, and a list of objects (a ticket’s messages) is drawn item by item. The page runs no script, loads nothing from elsewhere and sends no Referer, so the token stays in the address. Withreply: 'replyToTicket'(a command on the same record with a textbody) the page has a reply box; without it the page is read-only. Asked withAccept: application/json, the link answers{ resource, key, view }. A link expires afterttl;revokeTenantMailLinks(target, { tenant, resource, key })fromtablewalk/maintenanceends it sooner.
Addresses and DNS
Section titled “Addresses and DNS”<mailbox>+<tenant>@<inbound.domain> routes to that tenant’s mailbox; a
tenant’s own help@acme.com forwards there, or names its domain in
domains. tablewalk mail-dns tablewalk.json makes the DKIM key when the
file is missing and prints each domain’s DKIM and DMARC records and the SPF
record of the domain bounces return to (bounce+<tenant>@<returnPath>).
The webhook
Section titled “The webhook”POST /api/mail/inbound takes one message as JSON, signed with
inbound.secret:
x-tablewalk-timestamp: 1791194400x-tablewalk-signature: v1=<hex HMAC-SHA256 of "<timestamp>.<raw body>">{ "recipient": "help+acme@in.example.com", "from": "Ann Lee <ann@example.org>", "to": ["help@acme.com"], "cc": [], "subject": "The printer is on fire", "messageId": "<CAF1@mail.example.org>", "inReplyTo": "<notice.12@acme.com>", "references": ["<notice.12@acme.com>"], "text": "…", "html": "<p>…</p>", "headers": { "auto-submitted": "no", "precedence": "" }, "attachments": [{ "name": "photo.jpg", "type": "image/jpeg", "content": "<base64>" }] }| Answer | Why |
|---|---|
| 204 | taken, or the same Message-ID again |
| 202 | dropped and logged: Auto-Submitted other than no, Precedence bulk/list/junk, a bounce, mail from this server’s own addresses, or more than 30 messages from one sender to one tenant in an hour |
| 401 | no valid signature, or a timestamp more than five minutes off |
| 404 | no active tenant’s address |
| 409 | the same signed delivery again |
| 413 | larger than maxBytes (default 10 MiB), or an attachment past its file resource’s limit |
Dropped and unrouted messages are kept in the platform’s mail log
(__tablewalk_mail_log, 90 days), never shown to the App.
Providers
Section titled “Providers”Each provider’s inbound feature posts its own shape; a small forwarder you deploy maps it to the JSON above and signs it. No SDK is needed.
Cloudflare Email Workers: route the domain’s addresses to this Worker,
with MAIL_WEBHOOK and MAIL_INBOUND_SECRET set.
import PostalMime from 'postal-mime';
export default { async email(message, env) { const mail = await PostalMime.parse(message.raw, { attachmentEncoding: 'base64' }); const body = JSON.stringify({ recipient: message.to, from: mail.from, to: mail.to ?? [], cc: mail.cc ?? [], subject: mail.subject ?? '', messageId: mail.messageId, inReplyTo: mail.inReplyTo, references: mail.references, text: mail.text, html: mail.html, headers: Object.fromEntries(mail.headers.map((h) => [h.key, h.value])), attachments: mail.attachments.map((a) => ({ name: a.filename ?? 'attachment', type: a.mimeType, content: a.content })), }); const timestamp = String(Math.floor(Date.now() / 1000)); const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(env.MAIL_INBOUND_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${timestamp}.${body}`))); const signature = [...mac].map((b) => b.toString(16).padStart(2, '0')).join(''); const answer = await fetch(env.MAIL_WEBHOOK, { method: 'POST', body, headers: { 'content-type': 'application/json', 'x-tablewalk-timestamp': timestamp, 'x-tablewalk-signature': `v1=${signature}` } }); if (answer.status === 404) message.setReject('No such mailbox'); else if (answer.status >= 500) throw new Error(`tablewalk answered ${answer.status}`); },};| Neutral field | Postmark (inbound JSON) | SendGrid (Inbound Parse, multipart) | Mailgun (route forward, multipart) |
|---|---|---|---|
recipient |
OriginalRecipient |
envelope.to[0] (JSON) |
recipient |
from |
FromFull (Email, Name) |
from |
from |
to, cc |
ToFull, CcFull |
to, cc |
To, Cc |
subject |
Subject |
subject |
subject |
messageId, inReplyTo, references |
Headers named Message-ID, In-Reply-To, References |
the same names in headers (raw text) |
Message-Id, In-Reply-To, References |
text, html |
TextBody, HtmlBody |
text, html |
body-plain, body-html |
headers |
Headers (Name, Value) |
headers (raw text) |
message-headers (JSON pairs) |
attachments |
Attachments (Name, ContentType, Content already base64) |
the file parts, with attachment-info |
the file parts attachment-1… |
| Check before forwarding | Basic auth on the webhook address | Basic auth on the webhook address | the timestamp, token, signature HMAC under the signing key |
Common mistakes
Section titled “Common mistakes”- Every inbound message answers 401 → the forwarder signs a body other than the one it sends (re-serialized JSON), or its clock is more than five minutes off.
- A tenant’s mail lands in spam → publish the records
tablewalk mail-dnsprints, and name the tenant’s domain indomainsonly once its DKIM record is in place. - The server does not start: the jobs module receives mail and the email service has no
inbound→ add it, with its secret as a${VARIABLE}.
Related
Section titled “Related”- Email: the email service and what every App sends.
- Events, jobs and workflows: the jobs module mail is declared in.
- Tenant Apps: tenants, the platform realm and service principals.