Files
simstudioai--sim/apps/sim/app/api/webhooks/agentmail/route.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

375 lines
12 KiB
TypeScript

import {
db,
mothershipInboxAllowedSender,
mothershipInboxTask,
mothershipInboxWebhook,
permissions,
user,
workspace,
} from '@sim/db'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { tasks } from '@trigger.dev/sdk'
import { and, eq, gt, isNotNull, ne, sql } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { Webhook } from 'svix'
import {
agentMailEnvelopeSchema,
agentMailMessageSchema,
webhookSvixHeadersSchema,
} from '@/lib/api/contracts/webhooks'
import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription'
import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
import {
assertContentLengthWithinLimit,
isPayloadSizeLimitError,
readStreamToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { executeInboxTask } from '@/lib/mothership/inbox/executor'
import type { AgentMailWebhookPayload, RejectionReason } from '@/lib/mothership/inbox/types'
import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants'
const logger = createLogger('AgentMailWebhook')
const AUTOMATED_SENDERS = ['mailer-daemon@', 'noreply@', 'no-reply@', 'postmaster@']
const MAX_EMAILS_PER_HOUR = 20
const AGENTMAIL_BODY_LABEL = 'AgentMail webhook body'
/**
* Bound the unauthenticated AgentMail webhook body before buffering it for Svix
* signature verification, so an oversized payload cannot exhaust pod memory.
*/
async function readAgentMailBody(req: Request): Promise<string> {
assertContentLengthWithinLimit(req.headers, WEBHOOK_MAX_BODY_BYTES, AGENTMAIL_BODY_LABEL)
const buffer = await readStreamToBufferWithLimit(req.body, {
maxBytes: WEBHOOK_MAX_BODY_BYTES,
label: AGENTMAIL_BODY_LABEL,
})
return new TextDecoder().decode(buffer)
}
export const POST = withRouteHandler(async (req: Request) => {
try {
let rawBody: string
try {
rawBody = await readAgentMailBody(req)
} catch (bodyError) {
if (isPayloadSizeLimitError(bodyError)) {
logger.warn('Rejected oversized AgentMail webhook body', {
maxBytes: WEBHOOK_MAX_BODY_BYTES,
observedBytes: bodyError.observedBytes,
})
return NextResponse.json({ error: 'Request body too large' }, { status: 413 })
}
throw bodyError
}
const headersResult = webhookSvixHeadersSchema.safeParse({
'svix-id': req.headers.get('svix-id'),
'svix-timestamp': req.headers.get('svix-timestamp'),
'svix-signature': req.headers.get('svix-signature'),
})
if (!headersResult.success) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const webhookCandidates = await db
.select({
id: workspace.id,
inboxEnabled: workspace.inboxEnabled,
inboxAddress: workspace.inboxAddress,
inboxProviderId: workspace.inboxProviderId,
webhookSecret: mothershipInboxWebhook.secret,
})
.from(workspace)
.leftJoin(mothershipInboxWebhook, eq(mothershipInboxWebhook.workspaceId, workspace.id))
.where(isNotNull(mothershipInboxWebhook.secret))
let result: (typeof webhookCandidates)[number] | undefined
for (const candidate of webhookCandidates) {
if (!candidate.webhookSecret) continue
try {
const wh = new Webhook(candidate.webhookSecret)
wh.verify(rawBody, headersResult.data)
result = candidate
break
} catch {}
}
if (!result) {
logger.warn('Webhook signature verification failed', {
candidateCount: webhookCandidates.length,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const envelopeResult = agentMailEnvelopeSchema.safeParse(JSON.parse(rawBody))
if (!envelopeResult.success) {
logger.warn('Invalid AgentMail webhook payload', {
workspaceId: result.id,
issues: envelopeResult.error.issues,
})
return NextResponse.json(
{ error: 'Invalid envelope payload', details: envelopeResult.error.issues },
{ status: 400 }
)
}
if (envelopeResult.data.event_type !== 'message.received') {
return NextResponse.json({ ok: true })
}
const messageResult = agentMailMessageSchema.safeParse(envelopeResult.data.message)
if (!messageResult.success) {
logger.warn('Invalid AgentMail message payload', {
workspaceId: result.id,
issues: messageResult.error.issues,
})
return NextResponse.json(
{ error: 'Invalid message payload', details: messageResult.error.issues },
{ status: 400 }
)
}
const message: AgentMailWebhookPayload['message'] = messageResult.data
const inboxId = message.inbox_id
if (result.inboxProviderId !== inboxId) {
logger.warn('Verified AgentMail payload inbox mismatch', {
workspaceId: result.id,
verifiedInboxId: result.inboxProviderId,
payloadInboxId: inboxId,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!result.inboxEnabled) {
logger.info('Inbox disabled, rejecting', { workspaceId: result.id })
return NextResponse.json({ ok: true })
}
const fromEmail = extractSenderEmail(message.from) || ''
logger.info('Webhook received', { fromEmail, from_raw: message.from, workspaceId: result.id })
if (result.inboxAddress && fromEmail === result.inboxAddress.toLowerCase()) {
logger.info('Skipping email from inbox itself', { workspaceId: result.id })
return NextResponse.json({ ok: true })
}
if (AUTOMATED_SENDERS.some((prefix) => fromEmail.startsWith(prefix))) {
await createRejectedTask(result.id, message, 'automated_sender')
return NextResponse.json({ ok: true })
}
const emailMessageId = message.message_id
const inReplyTo = message.in_reply_to || null
const [existingResult, isAllowed, recentCount, parentTaskResult, isEntitled] =
await Promise.all([
emailMessageId
? db
.select({ id: mothershipInboxTask.id })
.from(mothershipInboxTask)
.where(eq(mothershipInboxTask.emailMessageId, emailMessageId))
.limit(1)
: Promise.resolve([]),
isSenderAllowed(fromEmail, result.id),
getRecentTaskCount(result.id),
inReplyTo
? db
.select({ chatId: mothershipInboxTask.chatId })
.from(mothershipInboxTask)
.where(eq(mothershipInboxTask.responseMessageId, inReplyTo))
.limit(1)
: Promise.resolve([]),
hasWorkspaceInboxAccess(result.id),
])
if (existingResult[0]) {
logger.info('Duplicate webhook, skipping', { emailMessageId })
return NextResponse.json({ ok: true })
}
if (!isEntitled) {
logger.info('Inbox no longer entitled, rejecting', { workspaceId: result.id })
await createRejectedTask(result.id, message, 'not_entitled')
return NextResponse.json({ ok: true })
}
if (!isAllowed) {
await createRejectedTask(result.id, message, 'sender_not_allowed')
return NextResponse.json({ ok: true })
}
if (recentCount >= MAX_EMAILS_PER_HOUR) {
await createRejectedTask(result.id, message, 'rate_limit_exceeded')
return NextResponse.json({ ok: true })
}
const chatId = parentTaskResult[0]?.chatId ?? null
const fromName = extractDisplayName(message.from)
const taskId = generateId()
const bodyText = message.text?.substring(0, 50_000) || null
const bodyHtml = message.html?.substring(0, 50_000) || null
const bodyPreview = (bodyText || '')?.substring(0, 200) || null
await db.insert(mothershipInboxTask).values({
id: taskId,
workspaceId: result.id,
fromEmail,
fromName,
subject: message.subject || '(no subject)',
bodyPreview,
bodyText,
bodyHtml,
emailMessageId,
inReplyTo,
agentmailMessageId: message.message_id,
status: 'received',
chatId,
hasAttachments: (message.attachments?.length ?? 0) > 0,
ccRecipients: message.cc?.length ? JSON.stringify(message.cc) : null,
})
if (isTriggerDevEnabled) {
try {
const handle = await tasks.trigger(
'mothership-inbox-execution',
{ taskId },
{
tags: [`workspaceId:${result.id}`, `taskId:${taskId}`],
region: await resolveTriggerRegion(),
}
)
await db
.update(mothershipInboxTask)
.set({ triggerJobId: handle.id })
.where(eq(mothershipInboxTask.id, taskId))
} catch (triggerError) {
logger.warn('Trigger.dev dispatch failed, falling back to local execution', {
taskId,
triggerError,
})
executeInboxTask(taskId).catch((err) => {
logger.error('Local inbox task execution failed', {
taskId,
error: getErrorMessage(err, 'Unknown error'),
})
})
}
} else {
logger.info('Trigger.dev not available, executing inbox task locally', { taskId })
executeInboxTask(taskId).catch((err) => {
logger.error('Local inbox task execution failed', {
taskId,
error: getErrorMessage(err, 'Unknown error'),
})
})
}
return NextResponse.json({ ok: true })
} catch (error) {
logger.error('AgentMail webhook error', {
error: getErrorMessage(error, 'Unknown error'),
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
async function isSenderAllowed(email: string, workspaceId: string): Promise<boolean> {
const [allowedSenderResult, memberResult] = await Promise.all([
db
.select({ id: mothershipInboxAllowedSender.id })
.from(mothershipInboxAllowedSender)
.where(
and(
eq(mothershipInboxAllowedSender.workspaceId, workspaceId),
eq(mothershipInboxAllowedSender.email, email)
)
)
.limit(1),
db
.select({ userId: permissions.userId })
.from(permissions)
.innerJoin(user, eq(permissions.userId, user.id))
.where(
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId),
sql`lower(${user.email}) = ${email}`
)
)
.limit(1),
])
return !!(allowedSenderResult[0] || memberResult[0])
}
async function getRecentTaskCount(workspaceId: string): Promise<number> {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000)
const [result] = await db
.select({ count: sql<number>`count(*)::int` })
.from(mothershipInboxTask)
.where(
and(
eq(mothershipInboxTask.workspaceId, workspaceId),
gt(mothershipInboxTask.createdAt, oneHourAgo),
ne(mothershipInboxTask.status, 'rejected')
)
)
return result?.count ?? 0
}
async function createRejectedTask(
workspaceId: string,
message: AgentMailWebhookPayload['message'],
reason: RejectionReason
): Promise<void> {
await db.insert(mothershipInboxTask).values({
id: generateId(),
workspaceId,
fromEmail: extractSenderEmail(message.from) || 'unknown',
fromName: extractDisplayName(message.from),
subject: message.subject || '(no subject)',
bodyPreview: (message.text || '').substring(0, 200) || null,
emailMessageId: message.message_id,
agentmailMessageId: message.message_id,
status: 'rejected',
rejectionReason: reason,
hasAttachments: (message.attachments?.length ?? 0) > 0,
})
}
/**
* Extract the raw email address from AgentMail's from field.
* Format: "username@domain.com" or "Display Name <username@domain.com>"
*/
function extractSenderEmail(from: string): string {
const openBracket = from.indexOf('<')
const closeBracket = from.indexOf('>', openBracket + 1)
if (openBracket !== -1 && closeBracket !== -1) {
return from
.substring(openBracket + 1, closeBracket)
.toLowerCase()
.trim()
}
return from.toLowerCase().trim()
}
function extractDisplayName(from: string): string | null {
const openBracket = from.indexOf('<')
if (openBracket <= 0) return null
const name = from.substring(0, openBracket).trim()
if (!name) return null
if (name.startsWith('"') && name.endsWith('"')) {
return name.slice(1, -1) || null
}
return name
}