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
9 changes: 7 additions & 2 deletions docs/use/email-primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,13 @@ Use the MCP `email` domain:
notify-only channel. Omit `to` to use the default destination (the account
email until you pick another). Every explicit address must already be on that
verified set; if any `to` is missing or unverified the whole send fails.
Manage destinations from `/account/email` as well. Unverified extras never
receive mail.
Optional `attachments` (up to 10 of
`{ filename, content_type, content_base64 }`) are sent with the message and
stored as `external` attachments readable later via `emailAttachmentGet`. The
same attachments go to every allowed `to` on one MIME message. With
attachments, the whole message (bodies plus decoded attachment bytes) must fit
the plan's `email_message_bytes` per-message cap. Manage destinations from
`/account/email` as well. Unverified extras never receive mail.
- `emailReply` replies to a stored inbound message. The recipient always comes
from the stored message. Optional `attachments` (up to 10 of
`{ filename, content_type, content_base64 }`) are sent with the reply and
Expand Down
13 changes: 13 additions & 0 deletions packages/worker/src/email/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,16 @@ function buildProviderHeaders(headers: Record<string, string>) {
)
}

function isUnsafeOutboundAttachmentFilename(filename: string) {
return (
filename === '.' ||
filename === '..' ||
filename.includes('/') ||
filename.includes('\\') ||
filename.includes('\0')
)
}

type PreparedOutboundAttachment = {
filename: string
contentType: string
Expand Down Expand Up @@ -266,6 +276,9 @@ function prepareOutboundAttachments(
return attachments.map((attachment) => {
const filename = attachment.filename.trim()
if (!filename) throw new Error('Attachment filename is required.')
if (isUnsafeOutboundAttachmentFilename(filename)) {
throw new Error(`Attachment filename is not allowed: ${filename}`)
}
const contentType = attachment.contentType.trim()
if (!contentType) {
throw new Error(`Attachment content type is required: ${filename}`)
Expand Down
84 changes: 84 additions & 0 deletions packages/worker/src/email/outbound.workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,72 @@ test('sendOutboundEmail passes base64 attachments to the REST fallback', async (
])
})

test('sendOutboundEmail sends one attached MIME message to every allowed to', async () => {
await ensureEmailTestSchema(env.APP_DB)
const accountEmail = `account-${crypto.randomUUID()}@example.com`
const extraEmail = `phone-${crypto.randomUUID()}@example.com`
const userId = await createStableUserIdFromEmail(accountEmail)
await seedVerifiedAccount({ email: accountEmail })
const user = await env.APP_DB.prepare(
`SELECT id FROM users WHERE stable_user_id = ?`,
)
.bind(userId)
.first<{ id: number }>()
if (!user) throw new Error('expected seeded user')
await env.APP_DB.prepare(
`INSERT INTO email_notification_destinations (id, user_id, email, verified_at, is_default)
VALUES (?, ?, ?, ?, 0)`,
)
.bind(crypto.randomUUID(), user.id, extraEmail, new Date().toISOString())
.run()

const pdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31])
const sent: Array<Record<string, unknown>> = []
const sendEnv = {
...env,
APP_BASE_URL: platformBaseUrl,
EMAIL: {
async send(message: Record<string, unknown>) {
sent.push(message)
return { messageId: 'provider-multi-attach-1' }
},
} as unknown as SendEmail,
}

const result = await sendOutboundEmail({
env: sendEnv,
userId,
accountEmail,
recipientPolicy: 'self',
to: [accountEmail, extraEmail],
subject: 'Shared file',
text: 'Same attachment for both.',
attachments: [
{
filename: 'invoice.pdf',
contentType: 'application/pdf',
contentBase64: bytesToBase64(pdfBytes),
},
],
})

expect(result.status).toBe('sent')
expect(result.message.toAddresses).toEqual([accountEmail, extraEmail])
expect(sent).toHaveLength(1)
expect(sent[0]?.to).toEqual([accountEmail, extraEmail])
const sentAttachments = sent[0]?.attachments as Array<Record<string, unknown>>
expect(sentAttachments).toHaveLength(1)
expect(sentAttachments[0]).toMatchObject({
disposition: 'attachment',
filename: 'invoice.pdf',
type: 'application/pdf',
})
expect(new Uint8Array(sentAttachments[0]?.content as Uint8Array)).toEqual(
pdfBytes,
)
expect(await readDailyEmailSendCounter(userId)).toBe(1)
})

test('sendOutboundEmail rejects invalid and oversized attachments', async () => {
await ensureEmailTestSchema(env.APP_DB)
const accountEmail = `account-${crypto.randomUUID()}@example.com`
Expand All @@ -1071,6 +1137,24 @@ test('sendOutboundEmail rejects invalid and oversized attachments', async () =>
}),
).rejects.toThrow('Attachment content must be valid base64: broken.bin')

await expect(
sendOutboundEmail({
env: createBindingSendEnv(),
userId,
accountEmail,
recipientPolicy: 'self',
subject: 'Traversal',
text: 'Body',
attachments: [
{
filename: '../secret.txt',
contentType: 'text/plain',
contentBase64: bytesToBase64(new TextEncoder().encode('nope')),
},
],
}),
).rejects.toThrow('Attachment filename is not allowed: ../secret.txt')

// Attachments put the message under the per-message email_message_bytes
// cap that body-only sends are not subject to.
const oversize = new Uint8Array(maxPlanEmailLimits.email_message_bytes + 1)
Expand Down
25 changes: 5 additions & 20 deletions packages/worker/src/mcp/capabilities/email/email-reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,15 @@ import { z } from 'zod'
import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts'
import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts'
import { requireVerifiedEmailAccountUser } from './require-verified-user.ts'
import {
maxOutboundEmailAttachments,
sendOutboundEmail,
} from '#worker/email/outbound.ts'
import { sendOutboundEmail } from '#worker/email/outbound.ts'
import { mailboxRpc } from '#worker/email/mailbox-client.ts'
import { mailboxMessageToEmailMessageRecord } from '#worker/email/mailbox-record-mappers.ts'
import {
emailMessageSummarySchema,
emailOutboundAttachmentsInputSchema,
stringArray,
toMessageSummary,
toOutboundEmailAttachments,
} from './shared.ts'

export const emailReplyCapability = defineDomainCapability(
Expand All @@ -29,17 +28,7 @@ export const emailReplyCapability = defineDomainCapability(
message_id: z.string().min(1),
text: z.string().min(1).optional(),
html: z.string().min(1).optional(),
attachments: z
.array(
z.object({
filename: z.string().min(1).max(255),
content_type: z.string().min(1).max(255),
content_base64: z.string().min(1),
}),
)
.min(1)
.max(maxOutboundEmailAttachments)
.optional(),
attachments: emailOutboundAttachmentsInputSchema,
})
.refine((value) => value.text !== undefined || value.html !== undefined, {
message: 'Email text or HTML body is required.',
Expand Down Expand Up @@ -72,11 +61,7 @@ export const emailReplyCapability = defineDomainCapability(
: `Re: ${original.subject ?? '(no subject)'}`,
text: args.text ?? null,
html: args.html ?? null,
attachments: args.attachments?.map((attachment) => ({
filename: attachment.filename,
contentType: attachment.content_type,
contentBase64: attachment.content_base64,
})),
attachments: toOutboundEmailAttachments(args.attachments),
inReplyToHeader: original.messageIdHeader ?? null,
references: [
...stringArray(original.references),
Expand Down
196 changes: 196 additions & 0 deletions packages/worker/src/mcp/capabilities/email/email-send.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { expect, test, vi } from 'vitest'
import { McpCallerError } from '#mcp/caller-error.ts'
import { createMcpCallerContext } from '#mcp/context.ts'

const mocks = vi.hoisted(() => ({
sendOutboundEmail: vi.fn(),
}))

vi.mock('#worker/email/outbound.ts', () => ({
maxOutboundEmailAttachments: 10,
sendOutboundEmail: mocks.sendOutboundEmail,
}))

const { emailSendCapability } = await import('./email-send.ts')

function createUsersDb(emailVerifiedAt: string | null) {
return {
prepare: () => ({
bind: () => ({
first: async () => ({ email_verified_at: emailVerifiedAt }),
}),
}),
} as unknown as D1Database
}

function createContext(options: { emailVerifiedAt?: string | null } = {}) {
return {
env: {
APP_DB: createUsersDb(
options.emailVerifiedAt === undefined
? '2026-01-01T00:00:00.000Z'
: options.emailVerifiedAt,
),
} as Env,
callerContext: createMcpCallerContext({
baseUrl: 'https://example.com',
user: {
userId: 'user-1',
email: 'user@example.com',
displayName: 'User Example',
},
}),
}
}

function sentMessage(id = 'outbound-1') {
return {
id,
direction: 'outbound',
inboxId: 'inbox-1',
threadId: 'thread-1',
fromAddress: 'user@heykody.dev',
envelopeFrom: 'user@heykody.dev',
toAddresses: ['user@example.com'],
subject: 'Hello',
messageIdHeader: '<outbound@heykody.dev>',
processingStatus: 'sent',
classification: 'accepted',
classificationReason: null,
providerMessageId: 'provider-1',
deliveryStatus: null,
deliveryStatusAt: null,
error: null,
receivedAt: null,
sentAt: '2026-05-13T07:30:16.000Z',
createdAt: '2026-05-13T07:30:16.000Z',
updatedAt: '2026-05-13T07:30:16.000Z',
}
}

test('emailSend forwards optional attachments and rejects invalid attachment shapes', async () => {
mocks.sendOutboundEmail.mockResolvedValue({
status: 'sent',
error: null,
providerMessageId: 'provider-1',
message: sentMessage(),
})

const withoutAttachments = await emailSendCapability.handler(
{
subject: 'Hello',
text: 'Body',
},
createContext(),
)
expect(withoutAttachments).toMatchObject({
provider_message_id: 'provider-1',
status: 'sent',
error: null,
message: { id: 'outbound-1', subject: 'Hello' },
})
expect(mocks.sendOutboundEmail).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-1',
accountEmail: 'user@example.com',
recipientPolicy: 'self',
to: null,
subject: 'Hello',
text: 'Body',
html: null,
replyTo: null,
attachments: undefined,
}),
)

await emailSendCapability.handler(
{
to: ['user@example.com', 'phone@example.com'],
subject: 'Report',
text: 'Report attached.',
attachments: [
{
filename: 'report.csv',
content_type: 'text/csv',
content_base64: 'bmFtZSx0b3RhbA==',
},
],
},
createContext(),
)
expect(mocks.sendOutboundEmail).toHaveBeenLastCalledWith(
expect.objectContaining({
recipientPolicy: 'self',
to: ['user@example.com', 'phone@example.com'],
attachments: [
{
filename: 'report.csv',
contentType: 'text/csv',
contentBase64: 'bmFtZSx0b3RhbA==',
},
],
}),
)

const missingFilename = await emailSendCapability
.handler(
{
subject: 'Hello',
text: 'Body',
attachments: [
{
content_type: 'text/plain',
content_base64: 'aGVsbG8=',
},
],
},
createContext(),
)
.catch((error: unknown) => error)
expect(missingFilename).toBeInstanceOf(McpCallerError)
expect(missingFilename).toMatchObject({
message: expect.stringContaining(
'Invalid input for capability "emailSend"',
),
})

const emptyList = await emailSendCapability
.handler(
{
subject: 'Hello',
text: 'Body',
attachments: [],
},
createContext(),
)
.catch((error: unknown) => error)
expect(emptyList).toBeInstanceOf(McpCallerError)
expect(emptyList).toMatchObject({
message: expect.stringContaining(
'Invalid input for capability "emailSend"',
),
})

const tooMany = await emailSendCapability
.handler(
{
subject: 'Hello',
text: 'Body',
attachments: Array.from({ length: 11 }, (_, index) => ({
filename: `file-${index}.txt`,
content_type: 'text/plain',
content_base64: 'aGVsbG8=',
})),
},
createContext(),
)
.catch((error: unknown) => error)
expect(tooMany).toBeInstanceOf(McpCallerError)
expect(tooMany).toMatchObject({
message: expect.stringContaining(
'Invalid input for capability "emailSend"',
),
})

expect(mocks.sendOutboundEmail).toHaveBeenCalledTimes(2)
})
Loading
Loading