diff --git a/docs/use/email-primitives.md b/docs/use/email-primitives.md index 588b2e453b..501556c821 100644 --- a/docs/use/email-primitives.md +++ b/docs/use/email-primitives.md @@ -14,6 +14,14 @@ platform-assigned sender address. - Inbound mail to `{username}@` routes to the user who owns that username. The default inbox is provisioned automatically at signup (or on the first inbound message), so there is nothing to create or configure. +- Subaddressing (RFC 5233 plus addressing) is supported: mail to + `{username}+{tag}@` routes to `{username}`'s inbox (and + `support+{tag}@` to the corresponding system inbox). The base local part + — everything before the first `+` — is what routes, so a tag can never bypass + the reserved or unknown-username checks. The full tagged address is preserved + in the stored message's `to_addresses`, so a package subscribing to + `email.message.received` can dispatch on the tag (for example, only handle + mail addressed to `{username}+invoices@...`). - Mail to unknown usernames is rejected, and the app's apex domain is never a user inbox: user mail lives exclusively on the configured platform domain (the `inbox.` subdomain by default), while the apex hosts only system mail — the diff --git a/packages/worker/src/email/address.node.test.ts b/packages/worker/src/email/address.node.test.ts index ec913fe1aa..40eb8c5387 100644 --- a/packages/worker/src/email/address.node.test.ts +++ b/packages/worker/src/email/address.node.test.ts @@ -6,6 +6,7 @@ import { normalizeEmailAddress, normalizeSubject, parseHeaderAddressList, + splitEmailLocalPart, } from './address.ts' test('email address helpers normalize mailbox strings and reply tokens', () => { @@ -20,6 +21,24 @@ test('email address helpers normalize mailbox strings and reply tokens', () => { expect(getEmailDomain('Support@Example.com')).toBe('example.com') expect(normalizeSubject(' Re: Fwd: Hello world ')).toBe('hello world') + expect(splitEmailLocalPart('kentcdodds')).toEqual({ + base: 'kentcdodds', + subaddress: null, + }) + expect(splitEmailLocalPart('kentcdodds+billing')).toEqual({ + base: 'kentcdodds', + subaddress: 'billing', + }) + expect(splitEmailLocalPart('kentcdodds+a+b')).toEqual({ + base: 'kentcdodds', + subaddress: 'a+b', + }) + expect(splitEmailLocalPart('kentcdodds+')).toEqual({ + base: 'kentcdodds', + subaddress: null, + }) + expect(splitEmailLocalPart('+tag')).toEqual({ base: '', subaddress: 'tag' }) + const headers = new Headers({ 'X-Kody-Reply-Token': 'token-123' }) expect(extractReplyToken({ headers, recipients: [] })).toBe('token-123') const alternateHeaders = new Headers({ 'X-Reply-Token': 'alternate-token' }) diff --git a/packages/worker/src/email/address.ts b/packages/worker/src/email/address.ts index 7291fa40d8..9e53188b9c 100644 --- a/packages/worker/src/email/address.ts +++ b/packages/worker/src/email/address.ts @@ -26,6 +26,21 @@ export function getEmailLocalPart(address: string) { return normalized.slice(0, at) } +/** + * Split an email local part into its base and RFC 5233 subaddress tag + * (`user+tag` → base `user`, subaddress `tag`). The tag starts at the first + * `+`; a missing or empty tag yields null. + */ +export function splitEmailLocalPart(localPart: string) { + const plusIndex = localPart.indexOf('+') + if (plusIndex === -1) { + return { base: localPart, subaddress: null } + } + const base = localPart.slice(0, plusIndex) + const subaddress = localPart.slice(plusIndex + 1) + return { base, subaddress: subaddress.length > 0 ? subaddress : null } +} + export function normalizeEmailAddressList(values: ReadonlyArray) { return Array.from( new Set( diff --git a/packages/worker/src/email/inbound.ts b/packages/worker/src/email/inbound.ts index 35443c192a..55fae52b7e 100644 --- a/packages/worker/src/email/inbound.ts +++ b/packages/worker/src/email/inbound.ts @@ -11,7 +11,11 @@ import { consumeDailyEntitlement, } from '#worker/entitlements/service.ts' import { recordUsage } from '#worker/usage/record-usage.ts' -import { normalizeEmailAddress, normalizeSubject } from './address.ts' +import { + normalizeEmailAddress, + normalizeSubject, + splitEmailLocalPart, +} from './address.ts' import { ensureDefaultEmailInbox } from './default-inbox.ts' import { maxInlineRawMimeBytes, @@ -173,19 +177,24 @@ export async function handleInboundEmail( const atIndex = recipient.lastIndexOf('@') const localPart = recipient.slice(0, atIndex) const recipientDomain = recipient.slice(atIndex + 1) + // RFC 5233 subaddressing: `user+tag@...` routes like `user@...`. The + // full tagged address stays visible in the stored message's + // to_addresses, so automations (for example email.message.received + // package handlers) can dispatch on the tag. + const { base: localBase } = splitEmailLocalPart(localPart) // Operator-owned system inboxes live on the apex domain, next to the // kody@ transactional sender whose replies they receive. User mail // lives exclusively on the user subdomain; all other apex mail rejects. if ( systemDomain && recipientDomain === systemDomain && - isSystemEmailLocal(localPart) + isSystemEmailLocal(localBase) ) { await handleSystemInboundEmail({ message, env, recipient, - localPart, + localPart: localBase, systemDomain, }) return @@ -194,14 +203,14 @@ export async function handleInboundEmail( message.setReject('Unknown Kody email address.') return } - if (isReservedUsername(localPart)) { + if (isReservedUsername(localBase)) { message.setReject('This address is reserved for system mail.') return } const identity = await findPublicUserIdentityByUsername({ db: env.APP_DB, - username: localPart, + username: localBase, }) if (!identity) { message.setReject('Unknown Kody email address.') diff --git a/packages/worker/src/email/inbound.workers.test.ts b/packages/worker/src/email/inbound.workers.test.ts index fb96342f15..c69ac06fd1 100644 --- a/packages/worker/src/email/inbound.workers.test.ts +++ b/packages/worker/src/email/inbound.workers.test.ts @@ -129,6 +129,7 @@ test('inbound email routes {username}@platform-domain and auto-provisions the de }) await handleInboundEmail(secondMessage, createInboundEnv()) expect(secondMessage.rejectedReason).toBeNull() + // The second delivery reuses the provisioned inbox instead of creating // another one. expect( @@ -178,6 +179,39 @@ test('inbound email routes {username}@platform-domain and auto-provisions the de limit: 1, }) expect(subjectOnly[0]?.threadId).not.toBe(normalizedExistingThread.id) + + // RFC 5233 subaddressing routes {username}+{tag} to the same inbox and + // keeps the full tagged address in the stored to_addresses so package + // handlers can dispatch on the tag. + const taggedAddress = `${username}+billing@${platformDomain}` + const taggedMessage = createForwardableEmailMessage({ + from: 'invoices@example.net', + to: taggedAddress, + raw: [ + 'From: Invoices ', + `To: ${taggedAddress}`, + 'Subject: Subaddressed mail', + 'Message-ID: ', + '', + 'Tagged body.', + ].join('\r\n'), + }) + await handleInboundEmail(taggedMessage, createInboundEnv()) + expect(taggedMessage.rejectedReason).toBeNull() + const taggedStored = await listEmailMessages({ + db: env.APP_DB, + userId, + inboxId: inbox.id, + limit: 1, + }) + expect(taggedStored[0]).toMatchObject({ + subject: 'Subaddressed mail', + toAddresses: [taggedAddress], + }) + // Still the same single auto-provisioned inbox after the tagged delivery. + expect( + await listEmailInboxesForUser({ db: env.APP_DB, userId }), + ).toHaveLength(1) }) test('inbound email rejects unknown usernames, reserved locals, and foreign domains', async () => { @@ -210,6 +244,16 @@ test('inbound email rejects unknown usernames, reserved locals, and foreign doma to: `help@${platformDomain}`, reason: 'This address is reserved for system mail.', }) + // Subaddressing cannot smuggle past the reserved or unknown checks: the + // base local part (before the +) is what routes. + await expectRejected({ + to: `help+tag@${platformDomain}`, + reason: 'This address is reserved for system mail.', + }) + await expectRejected({ + to: `missing-${crypto.randomUUID().slice(0, 8)}+tag@${platformDomain}`, + reason: 'Unknown Kody email address.', + }) // Mail for other domains is never a Kody user inbox. await expectRejected({ to: 'someone@other.example.com', diff --git a/packages/worker/src/email/system-email.workers.test.ts b/packages/worker/src/email/system-email.workers.test.ts index 7780af7906..1b4fb165d5 100644 --- a/packages/worker/src/email/system-email.workers.test.ts +++ b/packages/worker/src/email/system-email.workers.test.ts @@ -124,6 +124,34 @@ test('reserved system locals store under the operator-owned system inbox', async .bind(systemEmailOwnerId) .first<{ event_count: number; error_count: number }>() expect(rollup).toMatchObject({ event_count: 1, error_count: 0 }) + + // Subaddressed system mail (support+tag@apex) routes to the same + // operator inbox for the base local part. + const tagged = buildInboundMessage({ + to: `support+ticket-123@${systemDomain}`, + subject: 'Tagged system mail', + }) + await handleInboundEmail(tagged, createInboundEnv()) + expect(tagged.rejectedReason).toBeNull() + const taggedMessages = await listEmailMessages({ + db: env.APP_DB, + userId: systemEmailOwnerId, + limit: 10, + }) + expect(taggedMessages[0]).toMatchObject({ + subject: 'Tagged system mail', + toAddresses: [`support+ticket-123@${systemDomain}`], + }) + expect( + ( + await listEmailInboxesForUser({ + db: env.APP_DB, + userId: systemEmailOwnerId, + }) + ) + .map((inbox) => inbox.name) + .sort(), + ).toEqual(['kody', 'support']) }) test('non-system reserved locals still reject while username addresses are unaffected', async () => {