Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/use/email-primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ platform-assigned sender address.
- Inbound mail to `{username}@<platform domain>` 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}@<platform domain>` routes to `{username}`'s inbox (and
`support+{tag}@<apex>` 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
Expand Down
19 changes: 19 additions & 0 deletions packages/worker/src/email/address.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
normalizeEmailAddress,
normalizeSubject,
parseHeaderAddressList,
splitEmailLocalPart,
} from './address.ts'

test('email address helpers normalize mailbox strings and reply tokens', () => {
Expand All @@ -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' })
Expand Down
15 changes: 15 additions & 0 deletions packages/worker/src/email/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>) {
return Array.from(
new Set(
Expand Down
19 changes: 14 additions & 5 deletions packages/worker/src/email/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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@<apex> 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
Expand All @@ -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.')
Expand Down
44 changes: 44 additions & 0 deletions packages/worker/src/email/inbound.workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 <invoices@example.net>',
`To: ${taggedAddress}`,
'Subject: Subaddressed mail',
'Message-ID: <subaddressed@example.net>',
'',
'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 () => {
Expand Down Expand Up @@ -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',
Expand Down
28 changes: 28 additions & 0 deletions packages/worker/src/email/system-email.workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading