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 { 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 { 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 { const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000) const [result] = await db .select({ count: sql`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 { 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 " */ 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 }