Receiving Email
An app can receive mail at addresses on its own domain and handle each message in a worker. An Inbox declares the addresses; a handler bound to it is handed each message, parsed. The same code runs under gkm dev, in feature tests, and deployed.
// constructs/inbox.ts
import { Inbox } from '@geekmidas/constructs/inbox';
export const requests = new Inbox('Requests', {
addresses: ['requests'], // local parts: structural
subdomains: true, // requests@<tenant>.<stage domain>
});
// subscribers/requests.ts
import { requests } from '../constructs/inbox.js';
import { worker } from '../constructs/worker.js';
export const onRequest = worker
.inbox(requests)
.handle(async ({ messages, db }) => {
for (const message of messages) {
const tenant = message.recipient.subdomain; // from the envelope
const thread = message.recipient.tag; // requests+<tag>@
}
});Install @geekmidas/emailkit beside @geekmidas/constructs: it holds the MIME parser the handler reads messages with.
The model
- A construct of its own, not part of
Email. Sending owns an SMTP URL and a sending identity. Receiving owns addresses, a store of raw messages and a channel they are announced on. An app that only sends gets none of it. - The local part is structural; the domain is the stage's.
addressesandsubdomainsare fixed in code. The domain is the workspace'sdomainsfor a deployed stage, and<project>.localhostlocally (test.<project>.localhostundergkm test). - Plus-addressing is part of the address.
requestsalso receives atrequests+<tag>@, and the tag is handed to the handler. Threading by a tag you issued is reliable whereIn-Reply-To:is not. - The envelope routes. Which inbox, which tenant and which tag come from the envelope recipient.
From:andTo:are written by the sender, and a BCC'd or forwarded message has no header naming your address at all. - One decoder, everywhere. Deployed, SES stores the raw message and publishes its receipt notification on the inbox's own topic. Locally, Mailpit receives it and the same notification is published. The handler is handed what that notification says, wherever it came from.
With subdomains: true an inbox receives at exactly one label in front of the stage's domain — requests@acme.fixwell.example — and never at the bare domain, whose mail would belong to no tenant.
What a handler is handed
Each message is handed once per envelope recipient the inbox receives at.
| Field | What it is |
|---|---|
recipient | From the envelope: { address, local, tag?, subdomain? } |
from, to, cc, replyTo, subject, date, messageId, inReplyTo, references, headers | From the message's headers, as the sender wrote them. headers is every header by lowercase name |
text, html | The body. Either can be missing |
attachments | { filename?, contentType, size, content() } |
raw | { bucket, key } — where the raw message is kept |
verdicts | { spf, dkim, dmarc, spam, virus }: 'pass', 'fail', 'gray', or 'unavailable'. Always 'unavailable' locally — never a faked 'pass' |
Inbound mail is untrusted input
Everything in a message except the envelope recipient was written by whoever sent it.
- Route on
recipient, never onFrom:orTo:. - Treat
fromas a claim untilverdicts.dmarcis'pass'and the address is one that tenant knows. Either alone is not enough: DMARC proves the domain, not that the sender belongs to this tenant. - Sanitise
htmlbefore rendering it anywhere. - Never use an attachment's
filenameas a key or a path. It is the sender's to choose,../../etc/passwdincluded. - Reply to addresses you know, not to
Reply-To:.
Reply loops
A handler that replies to mail can end up answering its own reply. Say a message to requests@acme… forges From: requests@globex…. Acme's handler replies to Globex's inbox, whose handler replies back to Acme, and so on. A reply to someone's out-of-office loops the same way, between the handler and their auto-responder.
Toolbox stops both with the Auto-Submitted header (RFC 3834), with no code in the handler:
- Every email sent while an inbox handler runs is marked
Auto-Submitted: auto-replied. That covers anyEmailthe handler or anything it awaits sends. Mail sent from endpoints, crons and other subscribers is not marked. - Mail that arrives marked automatic never reaches the handler. That is an
Auto-Submittedvalue other thanno. It is dropped with a debug log, the same way undergkm dev, in tests and when deployed.
A handler that wants that mail, for example to record out-of-office replies, asks for it. It should not reply to it:
export const onAutomated = worker
.inbox(requests)
.automated()
.handle(async ({ messages }) => { … });Mail a handler hands to a queue and sends from there is not marked. Send replies from the handler itself.
Locally: gkm dev
Declaring an inbox runs Mailpit, as declaring an Email does, and points Mailpit's webhook at the worker that runs the inbox's subscribers. Send mail to Mailpit's SMTP port — from a mail client, swaks, or your app — and a message addressed to a declared inbox reaches its handler. Anything else stays in Mailpit, mail your app sends to anyone else included.
The handler reads the raw message back from Mailpit, so no bucket runs for an inbox locally.
Two limits, both local only:
- Mailpit reports the envelope recipients the headers do not name (a BCC) as
Bcc, and reportsTo:andCc:as the sender wrote them. Mail sent by hand whoseTo:names an inbox it was never delivered to looks delivered there. Feature tests (below) are exact. - Mailpit posts to one webhook, so locally every inbox subscriber must run in one worker (#343). The build says so if they do not.
In tests: mailbox.receive
A feature test delivers mail to an inbox with mailbox.receive. The message is built into a real MIME message, delivered to Mailpit with the envelope you name, received exactly as gkm dev receives it, and handed to each subscriber in the test's transaction. It resolves once they have run.
it('threads a reply onto its ticket', async ({ mailbox }) => {
await mailbox.receive({
to: 'requests+t_8f3k2q@acme.fixwell.test', // the envelope: required
from: 'ada@resident.example', // required without `raw`
subject: 'Re: Leaking tap',
text: 'Still dripping.',
attachments: [{ filename: 'tap.jpg', content: photo }],
verdicts: { dmarc: 'pass' }, // 'unavailable' when left out
});
// …assert on what the handler did
});
// Or a whole message, as an .eml holds it:
await mailbox.receive({ to: 'requests@acme.fixwell.test', raw: eml });headerTowrites aTo:header different from the envelope.tomust be an address a declared inbox receives at, or it throwsNoInboxForAddress— as the deployed receipt rule would never take it.- Passing both
rawand fields to build a message throwsReceiveFieldsOrRaw.
mailbox(address) still reads the mail your app sent, and its attachments now come through the same parser.
Deployed: deploy.inbox
A deployed stage names what receives its inboxes:
// gkm.config.ts
deploy: {
inbox: {
production: { provider: 'ses', region: 'eu-west-1' },
},
},With { provider: 'ses' }, the deploy creates each inbox in the stage's AWS account — on a compose stage on your own server as much as anywhere:
- Its own bucket, where SES may write only under
messages/, read by an IAM user of its own whose key is in the inbox's URL. Never a bucket the stack runs ordeploy.objectsbacks. - Its own SNS topic, which only SES may publish to. SNS pushes each message to the worker that runs the inbox's subscribers, at its route on the stage's domain, over HTTPS.
- The stage's domain as an SES identity, verified with DKIM, and MX records pointing the domain — or every subdomain of it, with
subdomains: true— atinbound-smtp.<region>.amazonaws.com. Both go through the stage'sdnsconfig (Route53, GoDaddy or Hostinger); a domain with none is printed as the records to create by hand. - A receipt rule in the region's active rule set, for the inbox's recipients. The deploy never makes a new set active over an existing one: only one is active per region, and switching would stop every other stage's and app's rules. A set is made active only where none is.
A removed inbox's rule is deleted on the next deploy. Nothing else is.
The region must be one SES receives mail in: us-east-1, us-east-2, us-west-1, us-west-2, af-south-1, ap-south-1, ap-southeast-1, ap-southeast-2, ap-southeast-3, ap-northeast-1, ap-northeast-2, ap-northeast-3, ca-central-1, eu-central-1, eu-west-1, eu-west-2, eu-west-3, eu-south-1, eu-north-1, il-central-1, me-south-1, sa-east-1. Any other fails the deploy with SesReceivingUnsupported, and a stage with no domain fails with NoDomainForStage.
On SST
Deploying through SST (gkm deploy --target sst) works the same way. The ses provider runs first and creates everything above, then gkm deploy hands sst deploy what it created:
- the inbox's URL and its topic's string, from the stage's secrets;
- the stage's domain.
SST creates nothing for the inbox. It subscribes the inbox subscriber's Lambda to the provider's topic, unfiltered, and links the inbox's <ID>_URL and <ID>_DOMAIN to that Lambda.
Run outside gkm deploy, sst deploy fails with InboxNotProvisioned, naming the missing key. You can also pass the values to fromManifest yourself: { Requests: { url, domain, topic } }.
Worked example: Fixwell
Fixwell is a made-up maintenance-request app for property-management agencies. Each agency is a tenant with an inbox at requests@<agency>.fixwell.example; a reply on a ticket goes to requests+<token>@<agency>.fixwell.example. Initech Living is suspended.
export const onRequest = worker.inbox(requests).handle(async ({ messages }) => {
for (const message of messages) {
// The envelope says which agency. Never To:, never From:.
const agency = agencies.find(message.recipient.subdomain);
if (!agency || agency.suspended) continue; // 9, 10
// 11: an out-of-office never gets here: see "Reply loops".
if (message.verdicts.virus === 'fail') { // 12
await quarantine(agency, message.raw); // raw kept,
continue; // nothing opened
}
// A token threads only within its own agency.
const ticket = message.recipient.tag
? await tickets.byToken(agency, message.recipient.tag) // 2, 8
: undefined;
if (ticket) {
await tickets.reply(ticket, message);
continue;
}
const known = agency.residents.includes(message.from?.address);
await tickets.open(agency, message, {
review: !known
? 'unknown sender' // 3, 5
: message.verdicts.dmarc !== 'pass'
? 'possible impersonation' // 4
: undefined, // 1, 7
});
}
});Each row of this table is a test, in featureTest.fixwell.spec.ts:
| # | Envelope recipient | From: (as written) | Verdicts | Outcome |
|---|---|---|---|---|
| 1 | requests@acme… | an Acme resident | DMARC pass | New Acme ticket, attributed to the resident |
| 2 | requests+t_8f3k2q@acme… | the same resident | DMARC pass | Threaded onto their Acme ticket |
| 3 | requests@acme… | an unknown sender | DMARC pass | New Acme ticket, needs review |
| 4 | requests@acme… | an Acme resident | DMARC fail | Acme ticket, needs review: possible impersonation |
| 5 | requests@acme… | finance@globex.fixwell.example | DMARC fail | Stays in Acme, needs review. Never routed to Globex |
| 6 | requests@acme…, header To: requests@globex… | anyone | any | Acme. To: is ignored |
| 7 | requests@acme… by BCC | an Acme resident | pass | Acme ticket. The envelope still knows |
| 8 | requests+t_8f3k2q@globex… (an Acme token) | anyone | any | Token not found in Globex: a new Globex ticket |
| 9 | requests@initech… | anyone | any | Dropped: agency suspended |
| 10 | requests@unknown… | anyone | any | Dropped: no such agency |
| 11 | requests@acme… | an out-of-office reply | pass | Never handled: it is marked Auto-Submitted: auto-replied |
| 12 | requests@acme… | anyone | virus fail | Quarantined: raw kept, attachments not stored |
| 13 | support@acme… | anyone | any | Never received: not a declared address |