From 88f355851528c814ed94f73929dac6b8d01c3e9b Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 12 Mar 2026 22:52:29 -0700 Subject: [PATCH 01/21] feat(sim-mailer): email inbox for mothership with chat history and plan gating --- apps/sim/app/api/webhooks/agentmail/route.ts | 243 ++++++ .../app/api/workspaces/[id]/inbox/route.ts | 130 +++ .../workspaces/[id]/inbox/senders/route.ts | 164 ++++ .../api/workspaces/[id]/inbox/tasks/route.ts | 79 ++ .../settings/[section]/settings.tsx | 7 + .../components/inbox/inbox-enable-toggle.tsx | 113 +++ .../components/inbox/inbox-settings-tab.tsx | 337 ++++++++ .../components/inbox/inbox-skeleton.tsx | 56 ++ .../components/inbox/inbox-task-list.tsx | 209 +++++ .../settings/components/inbox/inbox.tsx | 74 ++ .../[workspaceId]/settings/layout.tsx | 2 +- .../[workspaceId]/settings/navigation.ts | 10 + .../background/mothership-inbox-execution.ts | 28 + apps/sim/hooks/queries/use-inbox.ts | 281 ++++++ apps/sim/lib/billing/core/subscription.ts | 14 + apps/sim/lib/core/config/env.ts | 6 + apps/sim/lib/core/config/feature-flags.ts | 6 + .../lib/mothership/inbox/agentmail-client.ts | 144 ++++ apps/sim/lib/mothership/inbox/executor.ts | 322 +++++++ apps/sim/lib/mothership/inbox/format.ts | 118 +++ apps/sim/lib/mothership/inbox/lifecycle.ts | 141 +++ apps/sim/lib/mothership/inbox/response.ts | 113 +++ apps/sim/lib/mothership/inbox/types.ts | 94 ++ apps/sim/package.json | 1 + bun.lock | 5 +- packages/db/migrations/0172_glossy_miek.sql | 57 ++ .../db/migrations/meta/0172_snapshot.json | 811 ++++++++++-------- packages/db/migrations/meta/_journal.json | 4 +- packages/db/schema.ts | 71 +- 29 files changed, 3257 insertions(+), 383 deletions(-) create mode 100644 apps/sim/app/api/webhooks/agentmail/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/inbox/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-enable-toggle.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-settings-tab.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-skeleton.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-task-list.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx create mode 100644 apps/sim/background/mothership-inbox-execution.ts create mode 100644 apps/sim/hooks/queries/use-inbox.ts create mode 100644 apps/sim/lib/mothership/inbox/agentmail-client.ts create mode 100644 apps/sim/lib/mothership/inbox/executor.ts create mode 100644 apps/sim/lib/mothership/inbox/format.ts create mode 100644 apps/sim/lib/mothership/inbox/lifecycle.ts create mode 100644 apps/sim/lib/mothership/inbox/response.ts create mode 100644 apps/sim/lib/mothership/inbox/types.ts create mode 100644 packages/db/migrations/0172_glossy_miek.sql diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts new file mode 100644 index 0000000000..c7482c6e21 --- /dev/null +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -0,0 +1,243 @@ +import { + db, + mothershipInboxAllowedSender, + mothershipInboxTask, + permissions, + user, + workspace, +} from '@sim/db' +import { createLogger } from '@sim/logger' +import { tasks } from '@trigger.dev/sdk' +import { and, eq, gt, sql } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { v4 as uuidv4 } from 'uuid' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { executeInboxTask } from '@/lib/mothership/inbox/executor' +import type { AgentMailWebhookPayload, RejectionReason } from '@/lib/mothership/inbox/types' + +const logger = createLogger('AgentMailWebhook') + +const AUTOMATED_SENDERS = ['mailer-daemon@', 'noreply@', 'no-reply@', 'postmaster@'] +const MAX_EMAILS_PER_HOUR = 20 + +export async function POST(req: Request) { + try { + const payload = (await req.json()) as AgentMailWebhookPayload + + if (payload.event_type !== 'message.received') { + return NextResponse.json({ ok: true }) + } + + const { message } = payload + const inboxId = message?.inbox_id + if (!message || !inboxId) { + return NextResponse.json({ ok: true }) + } + + const [ws] = await db + .select() + .from(workspace) + .where(eq(workspace.inboxProviderId, inboxId)) + .limit(1) + + if (!ws) { + logger.warn('No workspace found for inbox', { inboxId }) + return NextResponse.json({ ok: true }) + } + + if (!ws.inboxEnabled) { + logger.info('Inbox disabled, rejecting', { workspaceId: ws.id }) + return NextResponse.json({ ok: true }) + } + + const fromEmail = extractSenderEmail(message.from_) || '' + logger.info('Webhook received', { fromEmail, from_raw: message.from_, workspaceId: ws.id }) + + if (ws.inboxAddress && fromEmail === ws.inboxAddress.toLowerCase()) { + logger.info('Skipping email from inbox itself', { workspaceId: ws.id }) + return NextResponse.json({ ok: true }) + } + + if (AUTOMATED_SENDERS.some((prefix) => fromEmail.startsWith(prefix))) { + await createRejectedTask(ws.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] = await Promise.all([ + emailMessageId + ? db + .select({ id: mothershipInboxTask.id }) + .from(mothershipInboxTask) + .where(eq(mothershipInboxTask.emailMessageId, emailMessageId)) + .limit(1) + : Promise.resolve([]), + isSenderAllowed(fromEmail, ws.id), + getRecentTaskCount(ws.id), + inReplyTo + ? db + .select({ chatId: mothershipInboxTask.chatId }) + .from(mothershipInboxTask) + .where(eq(mothershipInboxTask.responseMessageId, inReplyTo)) + .limit(1) + : Promise.resolve([]), + ]) + + if (existingResult[0]) { + logger.info('Duplicate webhook, skipping', { emailMessageId }) + return NextResponse.json({ ok: true }) + } + + if (!isAllowed) { + await createRejectedTask(ws.id, message, 'sender_not_allowed') + return NextResponse.json({ ok: true }) + } + + if (recentCount >= MAX_EMAILS_PER_HOUR) { + await createRejectedTask(ws.id, message, 'rate_limit_exceeded') + return NextResponse.json({ ok: true }) + } + + const chatId = parentTaskResult[0]?.chatId ?? null + + const fromName = extractDisplayName(message.from_) + + const taskId = uuidv4() + const bodyText = message.text || null + const bodyHtml = message.html || null + const bodyPreview = (bodyText || '')?.substring(0, 200) || null + + await db.insert(mothershipInboxTask).values({ + id: taskId, + workspaceId: ws.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 }) + 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: err instanceof Error ? err.message : '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: err instanceof Error ? err.message : 'Unknown error', + }) + }) + } + + return NextResponse.json({ ok: true }) + } catch (error) { + logger.error('AgentMail webhook error', { + error: error instanceof Error ? error.message : 'Unknown error', + }) + return NextResponse.json({ ok: true }) + } +} + +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), + eq(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) + ) + ) + return result?.count ?? 0 +} + +async function createRejectedTask( + workspaceId: string, + message: AgentMailWebhookPayload['message'], + reason: RejectionReason +): Promise { + await db.insert(mothershipInboxTask).values({ + id: uuidv4(), + 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 match = from.match(/<([^>]+)>/) + return (match?.[1] || from).toLowerCase().trim() +} + +function extractDisplayName(from: string): string | null { + const match = from.match(/^(.+?)\s* }) { + const { id: workspaceId } = await params + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const hasAccess = hasInboxAccess() + if (!hasAccess) { + return NextResponse.json({ error: 'Inbox feature is not available' }, { status: 403 }) + } + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (!permission) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const [wsResult, statsResult] = await Promise.all([ + db + .select({ + inboxEnabled: workspace.inboxEnabled, + inboxAddress: workspace.inboxAddress, + }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1), + db + .select({ + status: mothershipInboxTask.status, + count: sql`count(*)::int`, + }) + .from(mothershipInboxTask) + .where(eq(mothershipInboxTask.workspaceId, workspaceId)) + .groupBy(mothershipInboxTask.status), + ]) + + const [ws] = wsResult + if (!ws) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + const stats = { + total: 0, + completed: 0, + processing: 0, + failed: 0, + } + for (const row of statsResult) { + const count = Number(row.count) + stats.total += count + if (row.status === 'completed') stats.completed = count + else if (row.status === 'processing') stats.processing = count + else if (row.status === 'failed') stats.failed = count + } + + return NextResponse.json({ + enabled: ws.inboxEnabled, + address: ws.inboxAddress, + taskStats: stats, + }) +} + +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id: workspaceId } = await params + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const hasAccess = hasInboxAccess() + if (!hasAccess) { + return NextResponse.json({ error: 'Inbox feature is not available' }, { status: 403 }) + } + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin') { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) + } + + try { + const body = patchSchema.parse(await req.json()) + + if (body.enabled === true) { + const config = await enableInbox(workspaceId, { username: body.username }) + return NextResponse.json(config) + } + + if (body.enabled === false) { + await disableInbox(workspaceId) + return NextResponse.json({ enabled: false, address: null }) + } + + if (body.username) { + const config = await updateInboxAddress(workspaceId, body.username) + return NextResponse.json(config) + } + + return NextResponse.json({ error: 'No valid update provided' }, { status: 400 }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid request', details: error.errors }, { status: 400 }) + } + + logger.error('Inbox config update failed', { + workspaceId, + error: error instanceof Error ? error.message : 'Unknown error', + }) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to update inbox' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts new file mode 100644 index 0000000000..741b559424 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts @@ -0,0 +1,164 @@ +import { db, mothershipInboxAllowedSender, permissions, user } from '@sim/db' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { v4 as uuidv4 } from 'uuid' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { hasInboxAccess } from '@/lib/billing/core/subscription' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('InboxSendersAPI') + +const addSenderSchema = z.object({ + email: z.string().email('Invalid email address'), + label: z.string().max(100).optional(), +}) + +const deleteSenderSchema = z.object({ + senderId: z.string().min(1), +}) + +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id: workspaceId } = await params + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const hasAccess = hasInboxAccess() + if (!hasAccess) { + return NextResponse.json({ error: 'Inbox feature is not available' }, { status: 403 }) + } + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (!permission) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const [senders, members] = await Promise.all([ + db + .select() + .from(mothershipInboxAllowedSender) + .where(eq(mothershipInboxAllowedSender.workspaceId, workspaceId)) + .orderBy(mothershipInboxAllowedSender.createdAt), + db + .select({ + email: user.email, + name: user.name, + }) + .from(permissions) + .innerJoin(user, eq(permissions.userId, user.id)) + .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))), + ]) + + return NextResponse.json({ + senders: senders.map((s) => ({ + id: s.id, + email: s.email, + label: s.label, + createdAt: s.createdAt, + })), + workspaceMembers: members.map((m) => ({ + email: m.email, + name: m.name, + isAutoAllowed: true, + })), + }) +} + +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id: workspaceId } = await params + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const hasAccess = hasInboxAccess() + if (!hasAccess) { + return NextResponse.json({ error: 'Inbox feature is not available' }, { status: 403 }) + } + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin') { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) + } + + try { + const { email, label } = addSenderSchema.parse(await req.json()) + const normalizedEmail = email.toLowerCase() + + const [existing] = await db + .select({ id: mothershipInboxAllowedSender.id }) + .from(mothershipInboxAllowedSender) + .where( + and( + eq(mothershipInboxAllowedSender.workspaceId, workspaceId), + eq(mothershipInboxAllowedSender.email, normalizedEmail) + ) + ) + .limit(1) + + if (existing) { + return NextResponse.json({ error: 'Sender already exists' }, { status: 409 }) + } + + const [sender] = await db + .insert(mothershipInboxAllowedSender) + .values({ + id: uuidv4(), + workspaceId, + email: normalizedEmail, + label: label || null, + addedBy: session.user.id, + }) + .returning() + + return NextResponse.json({ sender }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid request', details: error.errors }, { status: 400 }) + } + logger.error('Failed to add sender', { workspaceId, error }) + return NextResponse.json({ error: 'Failed to add sender' }, { status: 500 }) + } +} + +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id: workspaceId } = await params + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const hasAccess = hasInboxAccess() + if (!hasAccess) { + return NextResponse.json({ error: 'Inbox feature is not available' }, { status: 403 }) + } + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin') { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) + } + + try { + const { senderId } = deleteSenderSchema.parse(await req.json()) + + await db + .delete(mothershipInboxAllowedSender) + .where( + and( + eq(mothershipInboxAllowedSender.id, senderId), + eq(mothershipInboxAllowedSender.workspaceId, workspaceId) + ) + ) + + return NextResponse.json({ ok: true }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid request', details: error.errors }, { status: 400 }) + } + logger.error('Failed to delete sender', { workspaceId, error }) + return NextResponse.json({ error: 'Failed to delete sender' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts new file mode 100644 index 0000000000..a2baf6b5f6 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts @@ -0,0 +1,79 @@ +import { db, mothershipInboxTask } from '@sim/db' +import { createLogger } from '@sim/logger' +import { and, desc, eq, lt } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { hasInboxAccess } from '@/lib/billing/core/subscription' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('InboxTasksAPI') + +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id: workspaceId } = await params + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const hasAccess = hasInboxAccess() + if (!hasAccess) { + return NextResponse.json({ error: 'Inbox feature is not available' }, { status: 403 }) + } + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (!permission) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const url = new URL(req.url) + const status = url.searchParams.get('status') || 'all' + const limit = Math.min(Number(url.searchParams.get('limit') || '20'), 50) + const cursor = url.searchParams.get('cursor') // ISO date string for cursor-based pagination + + const conditions = [eq(mothershipInboxTask.workspaceId, workspaceId)] + + if (status !== 'all') { + conditions.push(eq(mothershipInboxTask.status, status)) + } + + if (cursor) { + conditions.push(lt(mothershipInboxTask.createdAt, new Date(cursor))) + } + + const tasks = await db + .select({ + id: mothershipInboxTask.id, + fromEmail: mothershipInboxTask.fromEmail, + fromName: mothershipInboxTask.fromName, + subject: mothershipInboxTask.subject, + bodyPreview: mothershipInboxTask.bodyPreview, + status: mothershipInboxTask.status, + hasAttachments: mothershipInboxTask.hasAttachments, + resultSummary: mothershipInboxTask.resultSummary, + errorMessage: mothershipInboxTask.errorMessage, + rejectionReason: mothershipInboxTask.rejectionReason, + chatId: mothershipInboxTask.chatId, + createdAt: mothershipInboxTask.createdAt, + completedAt: mothershipInboxTask.completedAt, + }) + .from(mothershipInboxTask) + .where(and(...conditions)) + .orderBy(desc(mothershipInboxTask.createdAt)) + .limit(limit + 1) // Fetch one extra to determine hasMore + + const hasMore = tasks.length > limit + const resultTasks = hasMore ? tasks.slice(0, limit) : tasks + const nextCursor = + hasMore && resultTasks.length > 0 + ? resultTasks[resultTasks.length - 1].createdAt.toISOString() + : null + + return NextResponse.json({ + tasks: resultTasks, + pagination: { + limit, + hasMore, + nextCursor, + }, + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 65db139d4e..fc8dbbaa66 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -11,6 +11,7 @@ import { CredentialsSkeleton } from '@/app/workspace/[workspaceId]/settings/comp import { CustomToolsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton' import { DebugSkeleton } from '@/app/workspace/[workspaceId]/settings/components/debug/debug-skeleton' import { GeneralSkeleton } from '@/app/workspace/[workspaceId]/settings/components/general/general-skeleton' +import { InboxSkeleton } from '@/app/workspace/[workspaceId]/settings/components/inbox/inbox-skeleton' import { McpSkeleton } from '@/app/workspace/[workspaceId]/settings/components/mcp/mcp-skeleton' import { SkillsSkeleton } from '@/app/workspace/[workspaceId]/settings/components/skills/skill-skeleton' import { WorkflowMcpServersSkeleton } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers-skeleton' @@ -124,6 +125,11 @@ const WorkflowMcpServers = dynamic( ).then((m) => m.WorkflowMcpServers), { loading: () => } ) +const Inbox = dynamic( + () => + import('@/app/workspace/[workspaceId]/settings/components/inbox/inbox').then((m) => m.Inbox), + { loading: () => } +) const Debug = dynamic( () => import('@/app/workspace/[workspaceId]/settings/components/debug/debug').then((m) => m.Debug), @@ -170,6 +176,7 @@ export function SettingsPage({ section }: SettingsPageProps) { {effectiveSection === 'custom-tools' && } {effectiveSection === 'skills' && } {effectiveSection === 'workflow-mcp-servers' && } + {effectiveSection === 'inbox' && } {effectiveSection === 'debug' && } ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-enable-toggle.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-enable-toggle.tsx new file mode 100644 index 0000000000..b2dcc57124 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-enable-toggle.tsx @@ -0,0 +1,113 @@ +'use client' + +import { useCallback, useState } from 'react' +import { createLogger } from '@sim/logger' +import { useParams } from 'next/navigation' +import { + Button, + Input as EmcnInput, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Switch, +} from '@/components/emcn' +import { useInboxConfig, useToggleInbox } from '@/hooks/queries/use-inbox' + +const logger = createLogger('InboxEnableToggle') + +export function InboxEnableToggle() { + const params = useParams() + const workspaceId = params.workspaceId as string + + const { data: config } = useInboxConfig(workspaceId) + const toggleInbox = useToggleInbox() + + const [isEnableOpen, setIsEnableOpen] = useState(false) + const [enableUsername, setEnableUsername] = useState('') + + const handleToggle = useCallback( + async (checked: boolean) => { + if (checked) { + setIsEnableOpen(true) + return + } + try { + await toggleInbox.mutateAsync({ workspaceId, enabled: false }) + } catch (error) { + logger.error('Failed to disable inbox', { error }) + } + }, + [workspaceId] + ) + + const handleEnable = useCallback(async () => { + try { + await toggleInbox.mutateAsync({ + workspaceId, + enabled: true, + username: enableUsername.trim() || undefined, + }) + setIsEnableOpen(false) + setEnableUsername('') + } catch (error) { + logger.error('Failed to enable inbox', { error }) + } + }, [workspaceId, enableUsername]) + + return ( + <> +
+
+ + Enable email inbox + + + Allow this workspace to receive tasks via email + +
+ +
+ + + + Enable email inbox + +

+ An email address will be created for this workspace. Anyone in the allowed senders + list can email it to create tasks. +

+
+

+ Custom email prefix (optional) +

+ setEnableUsername(e.target.value)} + placeholder='e.g., acme' + className='h-9' + autoFocus + /> +

+ Leave blank for an auto-generated address. +

+
+
+ + + + +
+
+ + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-settings-tab.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-settings-tab.tsx new file mode 100644 index 0000000000..f3fceb3e76 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-settings-tab.tsx @@ -0,0 +1,337 @@ +'use client' + +import { useCallback, useState } from 'react' +import { createLogger } from '@sim/logger' +import { Check, Clipboard, Pencil, Plus, Trash2 } from 'lucide-react' +import { useParams } from 'next/navigation' +import { + Badge, + Button, + Input as EmcnInput, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Skeleton, + Tooltip, +} from '@/components/emcn' +import { + useAddInboxSender, + useInboxConfig, + useInboxSenders, + useRemoveInboxSender, + useUpdateInboxAddress, +} from '@/hooks/queries/use-inbox' + +const logger = createLogger('InboxSettingsTab') + +export function InboxSettingsTab() { + const params = useParams() + const workspaceId = params.workspaceId as string + + const { data: config } = useInboxConfig(workspaceId) + const { data: sendersData, isLoading: sendersLoading } = useInboxSenders(workspaceId) + const updateAddress = useUpdateInboxAddress() + const addSender = useAddInboxSender() + const removeSender = useRemoveInboxSender() + + const [isAddSenderOpen, setIsAddSenderOpen] = useState(false) + const [newSenderEmail, setNewSenderEmail] = useState('') + const [newSenderLabel, setNewSenderLabel] = useState('') + const [addSenderError, setAddSenderError] = useState(null) + + const [isEditAddressOpen, setIsEditAddressOpen] = useState(false) + const [newUsername, setNewUsername] = useState('') + const [editAddressError, setEditAddressError] = useState(null) + + const [copiedAddress, setCopiedAddress] = useState(false) + + const handleCopyAddress = useCallback(() => { + if (config?.address) { + navigator.clipboard.writeText(config.address) + setCopiedAddress(true) + setTimeout(() => setCopiedAddress(false), 2000) + } + }, [config?.address]) + + const handleEditAddress = useCallback(async () => { + if (!newUsername.trim()) return + setEditAddressError(null) + try { + await updateAddress.mutateAsync({ workspaceId, username: newUsername.trim() }) + setIsEditAddressOpen(false) + setNewUsername('') + } catch (error) { + setEditAddressError(error instanceof Error ? error.message : 'Failed to update address') + } + }, [workspaceId, newUsername]) + + const handleAddSender = useCallback(async () => { + if (!newSenderEmail.trim()) return + setAddSenderError(null) + try { + await addSender.mutateAsync({ + workspaceId, + email: newSenderEmail.trim(), + label: newSenderLabel.trim() || undefined, + }) + setIsAddSenderOpen(false) + setNewSenderEmail('') + setNewSenderLabel('') + } catch (error) { + setAddSenderError(error instanceof Error ? error.message : 'Failed to add sender') + } + }, [workspaceId, newSenderEmail, newSenderLabel]) + + const handleRemoveSender = useCallback( + async (senderId: string) => { + try { + await removeSender.mutateAsync({ workspaceId, senderId }) + } catch (error) { + logger.error('Failed to remove sender', { error }) + } + }, + [workspaceId] + ) + + return ( + <> +
+ {config?.address && ( +
+
+ Sim's email +
+
+

+ Send emails here to create tasks. +

+
+ + + + + +

{copiedAddress ? 'Copied!' : 'Copy'}

+
+
+ + + + + +

Edit

+
+
+
+
+ +
+ )} + +
+
+ Allowed senders +
+

+ Only emails from these addresses can create tasks. +

+ +
+ {sendersLoading ? ( +
+ +
+ ) : ( + <> + {sendersData?.workspaceMembers.map((member) => ( +
+
+ {member.email} + + member + +
+
+ ))} + + {sendersData?.senders.map((sender) => ( +
+
+ {sender.email} + {sender.label && ( + + ({sender.label}) + + )} +
+ +
+ ))} + + {sendersData?.workspaceMembers.length === 0 && + sendersData?.senders.length === 0 && ( +
+ No allowed senders configured. +
+ )} + + )} +
+ + +
+
+ + + + Add allowed sender + +
+
+

+ Email address +

+ { + setNewSenderEmail(e.target.value) + if (addSenderError) setAddSenderError(null) + }} + placeholder='user@example.com' + className='h-9' + autoFocus + /> +
+
+

+ Label (optional) +

+ setNewSenderLabel(e.target.value)} + placeholder='e.g., John from Marketing' + className='h-9' + /> +
+ {addSenderError && ( +

+ {addSenderError} +

+ )} +
+
+ + + + +
+
+ + + + Change email address + +

+ Changing your email address will create a new inbox.{' '} + + The old address will stop receiving emails immediately. + +

+
+

+ New email prefix +

+ { + setNewUsername(e.target.value) + if (editAddressError) setEditAddressError(null) + }} + placeholder='e.g., new-acme' + className='h-9' + autoFocus + /> + {editAddressError && ( +

+ {editAddressError} +

+ )} +
+
+ + + + +
+
+ + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-skeleton.tsx new file mode 100644 index 0000000000..b65710925d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-skeleton.tsx @@ -0,0 +1,56 @@ +import { Skeleton } from '@/components/emcn' + +/** + * Skeleton for a single inbox task row. + */ +export function InboxTaskSkeleton() { + return ( +
+
+ + +
+
+ + +
+ +
+ ) +} + +/** + * Skeleton for the full Inbox section shown during dynamic import loading. + */ +export function InboxSkeleton() { + return ( +
+ + + +
+ + + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-task-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-task-list.tsx new file mode 100644 index 0000000000..d20f5e5d6d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-task-list.tsx @@ -0,0 +1,209 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { ChevronDown, Paperclip, Search } from 'lucide-react' +import { useParams, useRouter } from 'next/navigation' +import { + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '@/components/emcn' +import { Input } from '@/components/ui' +import { formatRelativeTime } from '@/lib/core/utils/formatting' +import { InboxTaskSkeleton } from '@/app/workspace/[workspaceId]/settings/components/inbox/inbox-skeleton' +import type { InboxTaskItem } from '@/hooks/queries/use-inbox' +import { useInboxConfig, useInboxTasks } from '@/hooks/queries/use-inbox' + +const STATUS_OPTIONS = [ + { value: 'all', label: 'All statuses' }, + { value: 'completed', label: 'Completed' }, + { value: 'processing', label: 'Processing' }, + { value: 'received', label: 'Received' }, + { value: 'failed', label: 'Failed' }, + { value: 'rejected', label: 'Rejected' }, +] as const + +const STATUS_BADGES: Record< + string, + { label: string; variant: 'gray' | 'amber' | 'green' | 'red' | 'gray-secondary' } +> = { + received: { label: 'Received', variant: 'gray' }, + processing: { label: 'Processing', variant: 'amber' }, + completed: { label: 'Complete', variant: 'green' }, + failed: { label: 'Failed', variant: 'red' }, + rejected: { label: 'Rejected', variant: 'gray-secondary' }, +} + +export function InboxTaskList() { + const params = useParams() + const router = useRouter() + const workspaceId = params.workspaceId as string + + const [statusFilter, setStatusFilter] = useState('all') + const [searchTerm, setSearchTerm] = useState('') + + const { data: config } = useInboxConfig(workspaceId) + const { data: tasksData, isLoading } = useInboxTasks(workspaceId, { + status: statusFilter, + }) + + const filteredTasks = useMemo(() => { + if (!tasksData?.tasks) return [] + if (!searchTerm.trim()) return tasksData.tasks + const term = searchTerm.toLowerCase() + return tasksData.tasks.filter( + (t) => + t.subject?.toLowerCase().includes(term) || + t.fromEmail?.toLowerCase().includes(term) || + t.bodyPreview?.toLowerCase().includes(term) + ) + }, [tasksData?.tasks, searchTerm]) + + const handleTaskClick = useCallback( + (task: InboxTaskItem) => { + if (task.chatId && (task.status === 'completed' || task.status === 'failed')) { + router.push(`/workspace/${workspaceId}/task/${task.chatId}`) + } + }, + [workspaceId, router] + ) + + return ( +
+
+
+ + setSearchTerm(e.target.value)} + className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0' + /> +
+ + + + + + + {STATUS_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + + + +
+ +
+ {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : filteredTasks.length === 0 ? ( +
+ {searchTerm.trim() + ? `No tasks matching "${searchTerm}"` + : config?.address + ? `No email tasks yet. Send an email to ${config.address} to get started.` + : 'No email tasks yet.'} +
+ ) : ( +
+ {filteredTasks.map((task) => { + const statusBadge = STATUS_BADGES[task.status] || STATUS_BADGES.received + const isClickable = + task.chatId && (task.status === 'completed' || task.status === 'failed') + return ( +
handleTaskClick(task)} + > +
+ + {task.subject} + +
+ {task.hasAttachments && ( + + )} + + {formatRelativeTime(task.createdAt)} + +
+
+
+ + {task.fromName || task.fromEmail} + + + {task.status === 'processing' && ( + + )} + {statusBadge.label} + +
+ {task.status === 'rejected' && task.rejectionReason && ( + + {formatRejectionReason(task.rejectionReason)} + + )} + {task.status === 'failed' && task.errorMessage && ( + + {task.errorMessage} + + )} + {task.status === 'completed' && task.resultSummary && ( + + {task.resultSummary} + + )} + {task.status !== 'completed' && + task.status !== 'failed' && + task.status !== 'rejected' && + task.bodyPreview && ( + + {task.bodyPreview} + + )} +
+ ) + })} +
+ )} +
+
+ ) +} + +function formatRejectionReason(reason: string): string { + switch (reason) { + case 'sender_not_allowed': + return 'Sender not allowed' + case 'automated_sender': + return 'Automated sender' + case 'rate_limit_exceeded': + return 'Rate limit exceeded' + default: + return reason + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx new file mode 100644 index 0000000000..dac11540bf --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx @@ -0,0 +1,74 @@ +'use client' + +import { ArrowRight } from 'lucide-react' +import { useParams, useRouter } from 'next/navigation' +import { Button } from '@/components/emcn' +import { getPlanTierCredits, isEnterprise } from '@/lib/billing/plan-helpers' +import { InboxEnableToggle } from '@/app/workspace/[workspaceId]/settings/components/inbox/inbox-enable-toggle' +import { InboxSettingsTab } from '@/app/workspace/[workspaceId]/settings/components/inbox/inbox-settings-tab' +import { InboxSkeleton } from '@/app/workspace/[workspaceId]/settings/components/inbox/inbox-skeleton' +import { InboxTaskList } from '@/app/workspace/[workspaceId]/settings/components/inbox/inbox-task-list' +import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' +import { useSubscriptionData } from '@/hooks/queries/subscription' +import { useInboxConfig } from '@/hooks/queries/use-inbox' + +export function Inbox() { + const params = useParams() + const router = useRouter() + const workspaceId = params.workspaceId as string + + const { data: config, isLoading } = useInboxConfig(workspaceId) + const { data: subscriptionResponse, isLoading: isSubLoading } = useSubscriptionData({ + enabled: isBillingEnabled, + }) + + const plan = subscriptionResponse?.data?.plan ?? null + const isMaxPlan = getPlanTierCredits(plan) >= 25000 || isEnterprise(plan) + + if (isLoading || (isBillingEnabled && isSubLoading)) { + return + } + + if (isBillingEnabled && !isMaxPlan) { + return ( +
+
+

+ Sim Mailer requires a Max plan +

+

+ Upgrade to Max to receive tasks via email and let Sim work on your behalf. +

+
+ +
+ ) + } + + return ( +
+ + + {config?.enabled && ( + <> +
+ + +
+
Inbox
+

+ Email tasks received by this workspace. +

+
+ + + )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx b/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx index 92853f24c4..76564966fb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/layout.tsx @@ -1,6 +1,6 @@ export default function SettingsLayout({ children }: { children: React.ReactNode }) { return ( -
+
{children}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts index aabbe537dd..59f305c691 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts @@ -36,6 +36,7 @@ export type SettingsSection = | 'custom-tools' | 'skills' | 'workflow-mcp-servers' + | 'inbox' | 'docs' | 'debug' @@ -64,6 +65,7 @@ export interface NavigationItem { const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) const isCredentialSetsEnabled = isTruthy(getEnv('NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED')) const isAccessControlEnabled = isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED')) +const isInboxEnabled = isTruthy(getEnv('NEXT_PUBLIC_INBOX_ENABLED')) export const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) @@ -125,6 +127,14 @@ export const allNavigationItems: NavigationItem[] = [ section: 'system', requiresHosted: true, }, + { + id: 'inbox', + label: 'Sim Mailer', + icon: Mail, + section: 'system', + requiresHosted: true, + selfHostedOverride: isInboxEnabled, + }, { id: 'credential-sets', label: 'Email Polling', diff --git a/apps/sim/background/mothership-inbox-execution.ts b/apps/sim/background/mothership-inbox-execution.ts new file mode 100644 index 0000000000..82a706189e --- /dev/null +++ b/apps/sim/background/mothership-inbox-execution.ts @@ -0,0 +1,28 @@ +import { createLogger } from '@sim/logger' +import { task } from '@trigger.dev/sdk' +import { executeInboxTask } from '@/lib/mothership/inbox/executor' + +const logger = createLogger('MothershipInboxExecution') + +export interface MothershipInboxExecutionParams { + taskId: string +} + +export const mothershipInboxExecution = task({ + id: 'mothership-inbox-execution', + machine: { preset: 'medium-1x' }, + retry: { + maxAttempts: 2, + minTimeoutInMs: 5000, + maxTimeoutInMs: 30000, + factor: 2, + }, + run: async (params: MothershipInboxExecutionParams) => { + logger.info('Starting inbox task execution', { taskId: params.taskId }) + + await executeInboxTask(params.taskId) + + logger.info('Inbox task execution completed', { taskId: params.taskId }) + return { success: true, taskId: params.taskId } + }, +}) diff --git a/apps/sim/hooks/queries/use-inbox.ts b/apps/sim/hooks/queries/use-inbox.ts new file mode 100644 index 0000000000..f01a3daa16 --- /dev/null +++ b/apps/sim/hooks/queries/use-inbox.ts @@ -0,0 +1,281 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { InboxTaskStatus } from '@/lib/mothership/inbox/types' + +export const inboxKeys = { + all: ['inbox'] as const, + configs: () => [...inboxKeys.all, 'config'] as const, + config: (workspaceId: string) => [...inboxKeys.configs(), workspaceId] as const, + senders: () => [...inboxKeys.all, 'sender'] as const, + senderList: (workspaceId: string) => [...inboxKeys.senders(), workspaceId] as const, + tasks: () => [...inboxKeys.all, 'task'] as const, + taskList: (workspaceId: string, status?: string) => + [...inboxKeys.tasks(), workspaceId, status ?? 'all'] as const, +} + +export interface InboxConfigResponse { + enabled: boolean + address: string | null + taskStats: { + total: number + completed: number + processing: number + failed: number + } +} + +export interface InboxSender { + id: string + email: string + label: string | null + createdAt: string +} + +export interface InboxMember { + email: string + name: string + isAutoAllowed: boolean +} + +export interface InboxSendersResponse { + senders: InboxSender[] + workspaceMembers: InboxMember[] +} + +export interface InboxTaskItem { + id: string + fromEmail: string + fromName: string | null + subject: string + bodyPreview: string | null + status: InboxTaskStatus + hasAttachments: boolean + resultSummary: string | null + errorMessage: string | null + rejectionReason: string | null + chatId: string | null + createdAt: string + completedAt: string | null +} + +export interface InboxTasksResponse { + tasks: InboxTaskItem[] + pagination: { + limit: number + hasMore: boolean + nextCursor: string | null + } +} + +async function fetchInboxConfig( + workspaceId: string, + signal?: AbortSignal +): Promise { + const response = await fetch(`/api/workspaces/${workspaceId}/inbox`, { signal }) + if (!response.ok) throw new Error('Failed to fetch inbox config') + return response.json() +} + +async function fetchInboxSenders( + workspaceId: string, + signal?: AbortSignal +): Promise { + const response = await fetch(`/api/workspaces/${workspaceId}/inbox/senders`, { signal }) + if (!response.ok) throw new Error('Failed to fetch inbox senders') + return response.json() +} + +async function fetchInboxTasks( + workspaceId: string, + opts: { status?: string; cursor?: string; limit?: number }, + signal?: AbortSignal +): Promise { + const params = new URLSearchParams() + if (opts.status && opts.status !== 'all') params.set('status', opts.status) + if (opts.cursor) params.set('cursor', opts.cursor) + if (opts.limit) params.set('limit', String(opts.limit)) + const qs = params.toString() + const response = await fetch(`/api/workspaces/${workspaceId}/inbox/tasks${qs ? `?${qs}` : ''}`, { + signal, + }) + if (!response.ok) throw new Error('Failed to fetch inbox tasks') + return response.json() +} + +export function useInboxConfig(workspaceId: string) { + return useQuery({ + queryKey: inboxKeys.config(workspaceId), + queryFn: ({ signal }) => fetchInboxConfig(workspaceId, signal), + enabled: Boolean(workspaceId), + staleTime: 30 * 1000, + }) +} + +export function useInboxSenders(workspaceId: string) { + return useQuery({ + queryKey: inboxKeys.senderList(workspaceId), + queryFn: ({ signal }) => fetchInboxSenders(workspaceId, signal), + enabled: Boolean(workspaceId), + staleTime: 60 * 1000, + }) +} + +export function useInboxTasks( + workspaceId: string, + opts: { status?: string; cursor?: string; limit?: number } = {} +) { + return useQuery({ + queryKey: inboxKeys.taskList(workspaceId, opts.status), + queryFn: ({ signal }) => fetchInboxTasks(workspaceId, opts, signal), + enabled: Boolean(workspaceId), + staleTime: 15 * 1000, + placeholderData: keepPreviousData, + }) +} + +export function useToggleInbox() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + workspaceId, + enabled, + username, + }: { + workspaceId: string + enabled: boolean + username?: string + }) => { + const response = await fetch(`/api/workspaces/${workspaceId}/inbox`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled, username }), + }) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error || 'Failed to toggle inbox') + } + return response.json() + }, + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: inboxKeys.config(variables.workspaceId) }) + }, + }) +} + +export function useUpdateInboxAddress() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ workspaceId, username }: { workspaceId: string; username: string }) => { + const response = await fetch(`/api/workspaces/${workspaceId}/inbox`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username }), + }) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error || 'Failed to update inbox address') + } + return response.json() + }, + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: inboxKeys.config(variables.workspaceId) }) + }, + }) +} + +export function useAddInboxSender() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + workspaceId, + email, + label, + }: { + workspaceId: string + email: string + label?: string + }) => { + const response = await fetch(`/api/workspaces/${workspaceId}/inbox/senders`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, label }), + }) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error || 'Failed to add sender') + } + return response.json() + }, + onMutate: async ({ workspaceId, email, label }) => { + await queryClient.cancelQueries({ queryKey: inboxKeys.senderList(workspaceId) }) + const previous = queryClient.getQueryData( + inboxKeys.senderList(workspaceId) + ) + if (previous) { + queryClient.setQueryData(inboxKeys.senderList(workspaceId), { + ...previous, + senders: [ + ...previous.senders, + { + id: `optimistic-${Date.now()}`, + email, + label: label || null, + createdAt: new Date().toISOString(), + }, + ], + }) + } + return { previous } + }, + onError: (_err, variables, context) => { + if (context?.previous) { + queryClient.setQueryData(inboxKeys.senderList(variables.workspaceId), context.previous) + } + }, + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: inboxKeys.senderList(variables.workspaceId) }) + }, + }) +} + +export function useRemoveInboxSender() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ workspaceId, senderId }: { workspaceId: string; senderId: string }) => { + const response = await fetch(`/api/workspaces/${workspaceId}/inbox/senders`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ senderId }), + }) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error || 'Failed to remove sender') + } + return response.json() + }, + onMutate: async ({ workspaceId, senderId }) => { + await queryClient.cancelQueries({ queryKey: inboxKeys.senderList(workspaceId) }) + const previous = queryClient.getQueryData( + inboxKeys.senderList(workspaceId) + ) + if (previous) { + queryClient.setQueryData(inboxKeys.senderList(workspaceId), { + ...previous, + senders: previous.senders.filter((s) => s.id !== senderId), + }) + } + return { previous } + }, + onError: (_err, variables, context) => { + if (context?.previous) { + queryClient.setQueryData(inboxKeys.senderList(variables.workspaceId), context.previous) + } + }, + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: inboxKeys.senderList(variables.workspaceId) }) + }, + }) +} diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index d5b80f85aa..081d764716 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -17,6 +17,7 @@ import { isAccessControlEnabled, isCredentialSetsEnabled, isHosted, + isInboxEnabled, isProd, isSsoEnabled, } from '@/lib/core/config/feature-flags' @@ -390,6 +391,19 @@ export async function hasAccessControlAccess(userId: string): Promise { } } +/** + * Check if user has access to inbox (Sim Mailer) feature + * Returns true if: + * - INBOX_ENABLED env var is set (self-hosted override), OR + * - Running on hosted (sim.ai) environment + */ +export function hasInboxAccess(): boolean { + if (isInboxEnabled && !isHosted) { + return true + } + return isHosted +} + /** * Check if user has exceeded their cost limit based on current period usage */ diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 17d034e3e0..0d9147a253 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -298,6 +298,10 @@ export const env = createEnv({ ATTIO_CLIENT_ID: z.string().optional(), // Attio OAuth client ID ATTIO_CLIENT_SECRET: z.string().optional(), // Attio OAuth client secret + // AgentMail - Mothership Email Inbox + AGENTMAIL_API_KEY: z.string().min(1).optional(), // AgentMail API key for mothership email inbox + INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted (bypasses hosted requirements) + // E2B Remote Code Execution E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution E2B_API_KEY: z.string().optional(), // E2B API key for sandbox creation @@ -400,6 +404,7 @@ export const env = createEnv({ NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements) NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments) NEXT_PUBLIC_DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access UI toggle globally + NEXT_PUBLIC_INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms }, @@ -432,6 +437,7 @@ export const env = createEnv({ NEXT_PUBLIC_ORGANIZATIONS_ENABLED: process.env.NEXT_PUBLIC_ORGANIZATIONS_ENABLED, NEXT_PUBLIC_DISABLE_INVITATIONS: process.env.NEXT_PUBLIC_DISABLE_INVITATIONS, NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API, + NEXT_PUBLIC_INBOX_ENABLED: process.env.NEXT_PUBLIC_INBOX_ENABLED, NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED, NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED, NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED, diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index d204ec3802..0856b3f859 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -98,6 +98,12 @@ export const isAccessControlEnabled = isTruthy(env.ACCESS_CONTROL_ENABLED) export const isOrganizationsEnabled = isBillingEnabled || isTruthy(env.ORGANIZATIONS_ENABLED) || isAccessControlEnabled +/** + * Is inbox (Sim Mailer) enabled via env var override + * This bypasses hosted requirements for self-hosted deployments + */ +export const isInboxEnabled = isTruthy(env.INBOX_ENABLED) + /** * Is E2B enabled for remote code execution */ diff --git a/apps/sim/lib/mothership/inbox/agentmail-client.ts b/apps/sim/lib/mothership/inbox/agentmail-client.ts new file mode 100644 index 0000000000..98e3c8f25c --- /dev/null +++ b/apps/sim/lib/mothership/inbox/agentmail-client.ts @@ -0,0 +1,144 @@ +import { createLogger } from '@sim/logger' +import { env } from '@/lib/core/config/env' +import type { + AgentMailAttachment, + AgentMailInbox, + AgentMailMessage, + AgentMailReplyResponse, + AgentMailWebhook, +} from '@/lib/mothership/inbox/types' + +const logger = createLogger('AgentMailClient') + +const BASE_URL = 'https://api.agentmail.to/v0' + +function getApiKey(): string { + const key = env.AGENTMAIL_API_KEY + if (!key) { + throw new Error('AGENTMAIL_API_KEY is not configured') + } + return key +} + +async function request(path: string, options: RequestInit = {}): Promise { + const url = `${BASE_URL}${path}` + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${getApiKey()}`, + ...options.headers, + }, + }) + + if (!response.ok) { + const body = await response.text().catch(() => '') + logger.error('AgentMail API error', { + status: response.status, + path, + body, + }) + throw new Error(`AgentMail API error: ${response.status} ${body}`) + } + + if (response.status === 204) { + return undefined as T + } + + return response.json() as Promise +} + +export async function createInbox(opts: { + username?: string + displayName?: string +}): Promise { + return request('/inboxes', { + method: 'POST', + body: JSON.stringify({ + username: opts.username, + display_name: opts.displayName, + }), + }) +} + +export async function deleteInbox(inboxId: string): Promise { + return request(`/inboxes/${inboxId}`, { + method: 'DELETE', + }) +} + +export async function getInbox(inboxId: string): Promise { + return request(`/inboxes/${inboxId}`) +} + +export async function createWebhook(opts: { + url: string + eventTypes: string[] + inboxIds: string[] +}): Promise { + return request('/webhooks', { + method: 'POST', + body: JSON.stringify({ + url: opts.url, + event_types: opts.eventTypes, + inbox_ids: opts.inboxIds, + }), + }) +} + +export async function deleteWebhook(webhookId: string): Promise { + return request(`/webhooks/${webhookId}`, { + method: 'DELETE', + }) +} + +export async function replyToMessage( + inboxId: string, + messageId: string, + opts: { + text: string + html?: string + to?: string[] + attachments?: AgentMailAttachment[] + } +): Promise { + return request(`/inboxes/${inboxId}/messages/${messageId}/reply`, { + method: 'POST', + body: JSON.stringify({ + text: opts.text, + html: opts.html, + to: opts.to, + attachments: opts.attachments, + }), + }) +} + +export async function getMessage(inboxId: string, messageId: string): Promise { + return request(`/inboxes/${inboxId}/messages/${messageId}`) +} + +export async function getAttachment( + inboxId: string, + messageId: string, + attachmentId: string +): Promise { + const path = `/inboxes/${inboxId}/messages/${messageId}/attachments/${attachmentId}` + return requestRaw(path) +} + +async function requestRaw(path: string): Promise { + const url = `${BASE_URL}${path}` + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${getApiKey()}`, + }, + }) + + if (!response.ok) { + const body = await response.text().catch(() => '') + logger.error('AgentMail API error', { status: response.status, path, body }) + throw new Error(`AgentMail API error: ${response.status} ${body}`) + } + + return response.arrayBuffer() +} diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts new file mode 100644 index 0000000000..26ac405503 --- /dev/null +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -0,0 +1,322 @@ +import { copilotChats, db, mothershipInboxTask, permissions, user, workspace } from '@sim/db' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle' +import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload' +import { requestChatTitle } from '@/lib/copilot/chat-streaming' +import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' +import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types' +import { taskPubSub } from '@/lib/copilot/task-events' +import { generateWorkspaceContext } from '@/lib/copilot/workspace-context' +import * as agentmail from '@/lib/mothership/inbox/agentmail-client' +import { formatEmailAsMessage } from '@/lib/mothership/inbox/format' +import { sendInboxResponse } from '@/lib/mothership/inbox/response' +import type { AgentMailAttachment } from '@/lib/mothership/inbox/types' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('InboxExecutor') + +const MAX_BODY_LENGTH = 50_000 + +/** + * Execute a mothership inbox task end-to-end: + * 1. Load task and workspace + * 2. Resolve user identity + * 3. Resolve or create chat + * 4. Build execution context + * 5. Run orchestrator + * 6. Send response email + * 7. Update task status + */ +export async function executeInboxTask(taskId: string): Promise { + const [inboxTask] = await db + .select() + .from(mothershipInboxTask) + .where(eq(mothershipInboxTask.id, taskId)) + .limit(1) + + if (!inboxTask) { + logger.error('Inbox task not found', { taskId }) + return + } + + const [ws] = await db + .select() + .from(workspace) + .where(eq(workspace.id, inboxTask.workspaceId)) + .limit(1) + + if (!ws) { + logger.error('Workspace not found for inbox task', { + taskId, + workspaceId: inboxTask.workspaceId, + }) + await markTaskFailed(taskId, 'Workspace not found') + return + } + + let chatId = inboxTask.chatId + + try { + const [, userId] = await Promise.all([ + db + .update(mothershipInboxTask) + .set({ status: 'processing', processingStartedAt: new Date() }) + .where(eq(mothershipInboxTask.id, taskId)), + resolveUserId(inboxTask.fromEmail, ws), + ]) + + if (!chatId) { + const chatResult = await resolveOrCreateChat({ + userId, + workspaceId: ws.id, + model: 'claude-opus-4-5', + type: 'mothership', + }) + chatId = chatResult.chatId + + await db.update(mothershipInboxTask).set({ chatId }).where(eq(mothershipInboxTask.id, taskId)) + + const titleInput = + inboxTask.subject !== '(no subject)' + ? `${inboxTask.subject}\n\n${inboxTask.bodyPreview || ''}` + : inboxTask.bodyPreview || inboxTask.bodyText?.substring(0, 500) || '' + + requestChatTitle({ + message: titleInput, + model: 'claude-opus-4-5', + }) + .then(async (title) => { + if (title && chatId) { + await db.update(copilotChats).set({ title }).where(eq(copilotChats.id, chatId)) + taskPubSub?.publishStatusChanged({ + workspaceId: ws.id, + chatId, + type: 'renamed', + }) + } + }) + .catch((err) => { + logger.warn('Failed to generate chat title', { chatId, err }) + }) + + taskPubSub?.publishStatusChanged({ + workspaceId: ws.id, + chatId, + type: 'created', + }) + } + + if (chatId) { + taskPubSub?.publishStatusChanged({ + workspaceId: ws.id, + chatId, + type: 'started', + }) + } + + let attachments: AgentMailAttachment[] = [] + if (inboxTask.hasAttachments && ws.inboxProviderId && inboxTask.agentmailMessageId) { + try { + const fullMessage = await agentmail.getMessage( + ws.inboxProviderId, + inboxTask.agentmailMessageId + ) + attachments = fullMessage.attachments || [] + } catch (attachErr) { + logger.warn('Failed to fetch attachment metadata', { taskId, attachErr }) + } + } + + const truncatedTask = { + ...inboxTask, + bodyText: inboxTask.bodyText?.substring(0, MAX_BODY_LENGTH) ?? null, + bodyHtml: inboxTask.bodyHtml?.substring(0, MAX_BODY_LENGTH) ?? null, + } + const messageContent = formatEmailAsMessage(truncatedTask, attachments) + + const [workspaceContext, integrationTools, userPermission] = await Promise.all([ + generateWorkspaceContext(ws.id, userId), + buildIntegrationToolSchemas(userId), + getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null), + ]) + + const userMessageId = crypto.randomUUID() + const requestPayload: Record = { + message: messageContent, + userId, + chatId, + mode: 'agent', + messageId: userMessageId, + isHosted: true, + workspaceContext, + ...(integrationTools.length > 0 ? { integrationTools } : {}), + ...(userPermission ? { userPermission } : {}), + } + + const result = await orchestrateCopilotStream(requestPayload, { + userId, + workspaceId: ws.id, + chatId: chatId ?? undefined, + goRoute: '/api/mothership/execute', + autoExecuteTools: true, + interactive: false, + }) + + const cleanContent = stripThinkingTags(result.content) + + if (chatId) { + await persistChatMessages(chatId, userId, userMessageId, messageContent, { + ...result, + content: cleanContent, + }) + } + + const finalStatus = result.success ? 'completed' : 'failed' + const updatedTask = { ...inboxTask, chatId } + const errorStr = result.error || result.errors?.join('; ') + + const responseMessageId = await sendInboxResponse( + updatedTask, + { success: result.success, content: cleanContent, error: errorStr }, + { inboxProviderId: ws.inboxProviderId, workspaceId: ws.id } + ) + + await db + .update(mothershipInboxTask) + .set({ + status: finalStatus, + resultSummary: cleanContent?.substring(0, 200) || null, + errorMessage: errorStr || null, + completedAt: new Date(), + ...(responseMessageId ? { responseMessageId } : {}), + }) + .where(eq(mothershipInboxTask.id, taskId)) + + if (chatId) { + taskPubSub?.publishStatusChanged({ + workspaceId: ws.id, + chatId, + type: 'completed', + }) + } + + logger.info('Inbox task execution complete', { taskId, status: finalStatus }) + } catch (error) { + logger.error('Inbox task execution failed', { + taskId, + error: error instanceof Error ? error.message : 'Unknown error', + }) + + await markTaskFailed(taskId, error instanceof Error ? error.message : 'Execution failed') + + try { + await sendInboxResponse( + { ...inboxTask, chatId }, + { + success: false, + content: '', + error: error instanceof Error ? error.message : 'Execution failed', + }, + { inboxProviderId: ws.inboxProviderId, workspaceId: ws.id } + ) + } catch (emailError) { + logger.error('Failed to send error email', { taskId, emailError }) + } + } +} + +/** + * Resolve which user ID to use for execution. + * Match sender email to a workspace member, fallback to workspace owner. + */ +async function resolveUserId( + senderEmail: string, + ws: { id: string; ownerId: string } +): Promise { + const [member] = await db + .select({ userId: permissions.userId }) + .from(permissions) + .innerJoin(user, eq(permissions.userId, user.id)) + .where( + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, ws.id), + eq(user.email, senderEmail.toLowerCase()) + ) + ) + .limit(1) + + return member?.userId ?? ws.ownerId +} + +/** + * Persist the user message and assistant response to the copilot chat. + * This is necessary because the orchestrator doesn't persist messages — + * in the interactive UI flow, the client store handles persistence. + * For background execution, we write directly to the DB. + */ +async function persistChatMessages( + chatId: string, + userId: string, + userMessageId: string, + userContent: string, + result: OrchestratorResult +): Promise { + try { + const [chat] = await db + .select({ messages: copilotChats.messages }) + .from(copilotChats) + .where(eq(copilotChats.id, chatId)) + .limit(1) + + const existingMessages = Array.isArray(chat?.messages) ? chat.messages : [] + const now = new Date().toISOString() + + const userMessage = { + id: userMessageId, + role: 'user' as const, + content: userContent, + timestamp: now, + } + + const assistantMessage = { + id: crypto.randomUUID(), + role: 'assistant' as const, + content: result.content || '', + timestamp: now, + ...(result.error ? { errorType: 'internal' } : {}), + } + + await db + .update(copilotChats) + .set({ + messages: [...existingMessages, userMessage, assistantMessage], + updatedAt: new Date(), + }) + .where(eq(copilotChats.id, chatId)) + } catch (err) { + logger.warn('Failed to persist chat messages', { + chatId, + error: err instanceof Error ? err.message : 'Unknown error', + }) + } +} + +function stripThinkingTags(text: string): string { + return text + .replace(/[\s\S]*?<\/thinking>/gi, '') + .replace(/<\/?thinking[^>]*>/gi, '') + .trim() +} + +async function markTaskFailed(taskId: string, errorMessage: string): Promise { + await db + .update(mothershipInboxTask) + .set({ + status: 'failed', + errorMessage, + completedAt: new Date(), + }) + .where(eq(mothershipInboxTask.id, taskId)) +} diff --git a/apps/sim/lib/mothership/inbox/format.ts b/apps/sim/lib/mothership/inbox/format.ts new file mode 100644 index 0000000000..3add0f4db1 --- /dev/null +++ b/apps/sim/lib/mothership/inbox/format.ts @@ -0,0 +1,118 @@ +import type { AgentMailAttachment, InboxTask } from '@/lib/mothership/inbox/types' +import { formatFileSize } from '@/lib/uploads/utils/file-utils' + +const FORWARDED_PATTERNS = [ + /^fwd?:/i, + /^fw:/i, + /^forwarded:/i, + /---------- forwarded message/i, + /begin forwarded message/i, +] + +/** + * Formats an inbound email into a mothership chat message content string. + * Handles forwarded emails, CC'd conversations, and attachment metadata. + */ +export function formatEmailAsMessage( + task: InboxTask, + attachments: AgentMailAttachment[] = [] +): string { + const parts: string[] = [] + const isForwarded = isForwardedEmail(task.subject, task.bodyText) + + if (isForwarded) { + parts.push(`**Forwarded email from:** ${task.fromName || task.fromEmail}`) + } + + if (task.subject && task.subject !== 'Re:' && task.subject !== '(no subject)') { + const cleanSubject = task.subject.replace(/^(fwd?|fw|re):\s*/gi, '').trim() + parts.push(`**Subject:** ${cleanSubject}`) + } + + if (task.ccRecipients) { + try { + const cc = JSON.parse(task.ccRecipients) as string[] + if (cc.length > 0) { + parts.push(`**CC'd:** ${cc.join(', ')}`) + } + } catch {} + } + + const rawBody = task.bodyText || extractTextFromHtml(task.bodyHtml) || '(empty email body)' + const hasExistingChat = !!task.chatId + const body = hasExistingChat ? stripQuotedReply(rawBody) : rawBody + parts.push(body) + + if (attachments.length > 0) { + const attachmentList = attachments + .map((a) => `- ${a.filename} (${a.content_type}, ${formatFileSize(a.size)})`) + .join('\n') + parts.push(`**Attachments:**\n${attachmentList}`) + } else if (task.hasAttachments) { + parts.push('**Attachments:** (attached files are available for processing)') + } + + return parts.join('\n\n') +} + +/** + * Strips quoted reply content from email body. + * Email clients append the prior thread below a marker line like: + * - "On [date] [person] wrote:" + * - Lines starting with ">" + * - Gmail's "---------- Forwarded message ----------" + * + * We keep only the new content above the first quote marker. + */ +function stripQuotedReply(text: string): string { + const lines = text.split('\n') + const cutIndex = lines.findIndex((line, i) => { + const trimmed = line.trim() + + if (/^On .+ wrote:\s*$/i.test(trimmed)) return true + + if (trimmed.startsWith('>') && i > 0) { + const prevTrimmed = lines[i - 1].trim() + if (prevTrimmed === '' || /^On .+ wrote:\s*$/i.test(prevTrimmed)) return true + } + + return false + }) + + if (cutIndex <= 0) return text + + return lines.slice(0, cutIndex).join('\n').trim() +} + +/** + * Detects whether an email is a forwarded message based on subject/body patterns. + */ +function isForwardedEmail(subject: string | null, body: string | null): boolean { + if (subject && FORWARDED_PATTERNS.some((p) => p.test(subject))) return true + if (body && FORWARDED_PATTERNS.some((p) => p.test(body.substring(0, 500)))) return true + return false +} + +/** + * Basic HTML to text extraction. + */ +function extractTextFromHtml(html: string | null): string | null { + if (!html) return null + + return html + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/<\/div>/gi, '\n') + .replace(/<\/li>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\n{3,}/g, '\n\n') + .trim() +} diff --git a/apps/sim/lib/mothership/inbox/lifecycle.ts b/apps/sim/lib/mothership/inbox/lifecycle.ts new file mode 100644 index 0000000000..4de8aa916a --- /dev/null +++ b/apps/sim/lib/mothership/inbox/lifecycle.ts @@ -0,0 +1,141 @@ +import { db, mothershipInboxWebhook, workspace } from '@sim/db' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { v4 as uuidv4 } from 'uuid' +import { getBaseUrl } from '@/lib/core/utils/urls' +import * as agentmail from '@/lib/mothership/inbox/agentmail-client' +import type { InboxConfig } from '@/lib/mothership/inbox/types' + +const logger = createLogger('InboxLifecycle') + +/** + * Enable inbox for a workspace: + * 1. Create AgentMail inbox (with optional custom username) + * 2. Create AgentMail webhook scoped to this inbox + * 3. Store inbox details + webhook secret in DB + * 4. Update workspace.inboxEnabled = true + */ +export async function enableInbox( + workspaceId: string, + opts?: { username?: string } +): Promise { + const inbox = await agentmail.createInbox({ + username: opts?.username, + displayName: `Sim Mothership`, + }) + + logger.info('AgentMail createInbox response', { inbox: JSON.stringify(inbox) }) + + if (!inbox?.inbox_id) { + throw new Error('AgentMail createInbox response missing inbox_id') + } + + let webhook: Awaited> | null = null + try { + webhook = await agentmail.createWebhook({ + url: `${getBaseUrl()}/api/webhooks/agentmail`, + eventTypes: ['message.received'], + inboxIds: [inbox.inbox_id], + }) + + await db.insert(mothershipInboxWebhook).values({ + id: uuidv4(), + workspaceId, + webhookId: webhook.webhook_id, + secret: webhook.secret, + }) + + await db + .update(workspace) + .set({ + inboxEnabled: true, + inboxAddress: inbox.inbox_id, + inboxProviderId: inbox.inbox_id, + updatedAt: new Date(), + }) + .where(eq(workspace.id, workspaceId)) + + logger.info('Inbox enabled', { workspaceId, address: inbox.inbox_id }) + + return { + enabled: true, + address: inbox.inbox_id, + providerId: inbox.inbox_id, + } + } catch (error) { + try { + if (webhook) await agentmail.deleteWebhook(webhook.webhook_id) + await agentmail.deleteInbox(inbox.inbox_id) + } catch (rollbackError) { + logger.error('Failed to rollback AgentMail resources', { rollbackError }) + } + throw error + } +} + +/** + * Disable inbox: + * 1. Delete AgentMail webhook + * 2. Delete AgentMail inbox + * 3. Clear workspace inbox columns + * 4. Delete mothershipInboxWebhook row + */ +export async function disableInbox(workspaceId: string): Promise { + const [[ws], [webhookRow]] = await Promise.all([ + db + .select({ inboxProviderId: workspace.inboxProviderId }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1), + db + .select({ webhookId: mothershipInboxWebhook.webhookId }) + .from(mothershipInboxWebhook) + .where(eq(mothershipInboxWebhook.workspaceId, workspaceId)) + .limit(1), + ]) + + const deletePromises: Promise[] = [] + if (webhookRow) { + deletePromises.push( + agentmail.deleteWebhook(webhookRow.webhookId).catch((error) => { + logger.warn('Failed to delete AgentMail webhook', { error }) + }) + ) + } + if (ws?.inboxProviderId) { + deletePromises.push( + agentmail.deleteInbox(ws.inboxProviderId).catch((error) => { + logger.warn('Failed to delete AgentMail inbox', { error }) + }) + ) + } + await Promise.all(deletePromises) + + await Promise.all([ + db.delete(mothershipInboxWebhook).where(eq(mothershipInboxWebhook.workspaceId, workspaceId)), + db + .update(workspace) + .set({ + inboxEnabled: false, + inboxAddress: null, + inboxProviderId: null, + updatedAt: new Date(), + }) + .where(eq(workspace.id, workspaceId)), + ]) + + logger.info('Inbox disabled', { workspaceId }) +} + +/** + * Update inbox address (regenerate): + * 1. Disable old inbox + * 2. Enable new inbox with new username + */ +export async function updateInboxAddress( + workspaceId: string, + newUsername: string +): Promise { + await disableInbox(workspaceId) + return enableInbox(workspaceId, { username: newUsername }) +} diff --git a/apps/sim/lib/mothership/inbox/response.ts b/apps/sim/lib/mothership/inbox/response.ts new file mode 100644 index 0000000000..a47b940caa --- /dev/null +++ b/apps/sim/lib/mothership/inbox/response.ts @@ -0,0 +1,113 @@ +import { createLogger } from '@sim/logger' +import { marked } from 'marked' +import { getBaseUrl } from '@/lib/core/utils/urls' +import * as agentmail from '@/lib/mothership/inbox/agentmail-client' +import type { InboxTask } from '@/lib/mothership/inbox/types' + +const logger = createLogger('InboxResponse') + +interface InboxResponseContext { + inboxProviderId: string | null + workspaceId: string +} + +/** + * Send the mothership execution result as an email reply via AgentMail. + * Returns the AgentMail response message ID for thread stitching, or null on failure. + */ +export async function sendInboxResponse( + inboxTask: InboxTask, + result: { success: boolean; content: string; error?: string }, + ctx: InboxResponseContext +): Promise { + if (!ctx.inboxProviderId || !inboxTask.agentmailMessageId) { + logger.warn('Cannot send response: missing inbox provider or message ID', { + taskId: inboxTask.id, + }) + return null + } + + const chatUrl = inboxTask.chatId + ? `${getBaseUrl()}/workspace/${ctx.workspaceId}/task/${inboxTask.chatId}` + : `${getBaseUrl()}/workspace/${ctx.workspaceId}/home` + + const text = result.success + ? `${result.content}\n\n[View full conversation](${chatUrl})\n\nBest,\nMothership` + : `I wasn't able to complete this task.\n\nError: ${result.error || 'Unknown error'}\n\n[View details](${chatUrl})\n\nBest,\nMothership` + + const html = result.success + ? renderEmailHtml(result.content, chatUrl) + : renderErrorHtml(result.error || 'Unknown error', chatUrl) + + try { + const response = await agentmail.replyToMessage( + ctx.inboxProviderId, + inboxTask.agentmailMessageId, + { text, html } + ) + + logger.info('Inbox response sent', { taskId: inboxTask.id, responseId: response.message_id }) + return response.message_id + } catch (error) { + logger.error('Failed to send inbox response email', { + taskId: inboxTask.id, + error: error instanceof Error ? error.message : 'Unknown error', + }) + return null + } +} + +const EMAIL_STYLES = ` + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif; font-size: 15px; line-height: 25px; color: #1a1a1a; font-weight: 430; } + p { margin: 0 0 16px 0; } + h1, h2, h3, h4 { font-weight: 600; color: #1a1a1a; margin-top: 24px; margin-bottom: 12px; } + h1 { font-size: 24px; } h2 { font-size: 20px; } h3 { font-size: 16px; } h4 { font-size: 15px; } + strong { font-weight: 600; color: #1a1a1a; } + pre { background: #f3f3f3; padding: 16px; border-radius: 8px; border: 1px solid #ededed; overflow-x: auto; margin: 24px 0; } + code { background: #f3f3f3; padding: 2px 6px; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace; font-size: 13px; color: #1a1a1a; } + pre code { background: none; padding: 0; font-size: 13px; line-height: 21px; } + table { border-collapse: collapse; margin: 16px 0; } + th, td { border: 1px solid #ededed; padding: 8px 12px; text-align: left; font-size: 14px; } + th { background: #f5f5f5; font-weight: 600; } + tr { border-bottom: 1px solid #ededed; } + blockquote { border-left: 4px solid #e0e0e0; margin: 16px 0; padding: 4px 16px; color: #525252; font-style: italic; } + a { color: #2563eb; text-decoration: underline; text-decoration-style: dashed; text-underline-offset: 2px; } + ul, ol { margin: 16px 0; padding-left: 24px; } + li { margin: 4px 0; } + hr { border: none; border-top: 1px solid #ededed; margin: 24px 0; } + .signature { color: #525252; margin-top: 32px; font-size: 14px; } + .signature a { color: #1a1a1a; text-decoration: underline; text-decoration-style: dashed; text-underline-offset: 2px; } +` + +function renderEmailHtml(markdown: string, chatUrl: string): string { + const bodyHtml = marked.parse(markdown, { async: false }) as string + + return ` + +${bodyHtml} +
+

View full conversation

+

Best,
Mothership

+
+` +} + +function renderErrorHtml(error: string, chatUrl: string): string { + return ` + +

I wasn't able to complete this task.

+

Error: ${escapeHtml(error)}

+
+

View details

+

Best,
Mothership

+
+` +} + +function escapeHtml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>') +} + +function escapeAttr(str: string): string { + return str.replace(/&/g, '&').replace(/"/g, '"') +} diff --git a/apps/sim/lib/mothership/inbox/types.ts b/apps/sim/lib/mothership/inbox/types.ts new file mode 100644 index 0000000000..22ea8dd2d2 --- /dev/null +++ b/apps/sim/lib/mothership/inbox/types.ts @@ -0,0 +1,94 @@ +import type { copilotChats, mothershipInboxTask, workspace } from '@sim/db' + +export type InboxTask = typeof mothershipInboxTask.$inferSelect +export type InboxTaskInsert = typeof mothershipInboxTask.$inferInsert +export type InboxTaskStatus = 'received' | 'processing' | 'completed' | 'failed' | 'rejected' +export type RejectionReason = 'sender_not_allowed' | 'automated_sender' | 'rate_limit_exceeded' + +export type Workspace = typeof workspace.$inferSelect +export type CopilotChat = typeof copilotChats.$inferSelect + +export interface InboxConfig { + enabled: boolean + address: string | null + providerId: string | null +} + +export interface InboxTaskStats { + total: number + completed: number + processing: number + failed: number +} + +export interface AllowedSender { + id: string + email: string + label: string | null + addedBy: string + createdAt: Date +} + +export interface AgentMailInbox { + organization_id: string + pod_id: string + inbox_id: string + display_name: string | null + client_id?: string | null + updated_at: string + created_at: string +} + +export interface AgentMailWebhook { + webhook_id: string + url: string + event_types: string[] + pod_ids?: string[] + inbox_ids?: string[] + secret: string + enabled: boolean + client_id?: string | null + updated_at: string + created_at: string +} + +export interface AgentMailMessage { + message_id: string + thread_id: string + inbox_id: string + organization_id?: string + from_: string + to: string[] + cc: string[] + bcc?: string[] + reply_to?: string[] + subject: string + preview?: string + text: string | null + html: string | null + attachments: AgentMailAttachment[] + in_reply_to?: string + references?: string[] + labels?: string[] + sort_key?: string + updated_at?: string + created_at: string +} + +export interface AgentMailAttachment { + attachment_id: string + filename: string + content_type: string + size: number + inline?: boolean +} + +export interface AgentMailWebhookPayload { + event_type: string + event_id?: string + message: AgentMailMessage +} + +export interface AgentMailReplyResponse { + message_id: string +} diff --git a/apps/sim/package.json b/apps/sim/package.json index 36b6ab8b4d..2073dae6fa 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -125,6 +125,7 @@ "lodash": "4.17.21", "lucide-react": "^0.479.0", "mammoth": "^1.9.0", + "marked": "17.0.4", "mongodb": "6.19.0", "mysql2": "3.14.3", "nanoid": "^3.3.7", diff --git a/bun.lock b/bun.lock index 8180c71a42..44aa14a2bf 100644 --- a/bun.lock +++ b/bun.lock @@ -151,6 +151,7 @@ "lodash": "4.17.21", "lucide-react": "^0.479.0", "mammoth": "^1.9.0", + "marked": "17.0.4", "mongodb": "6.19.0", "mysql2": "3.14.3", "nanoid": "^3.3.7", @@ -2636,7 +2637,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="], + "marked": ["marked@17.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ=="], "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], @@ -4170,6 +4171,8 @@ "mammoth/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], diff --git a/packages/db/migrations/0172_glossy_miek.sql b/packages/db/migrations/0172_glossy_miek.sql new file mode 100644 index 0000000000..e25a0f5a27 --- /dev/null +++ b/packages/db/migrations/0172_glossy_miek.sql @@ -0,0 +1,57 @@ +CREATE TABLE "mothership_inbox_allowed_sender" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "email" text NOT NULL, + "label" text, + "added_by" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "mothership_inbox_task" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "from_email" text NOT NULL, + "from_name" text, + "subject" text NOT NULL, + "body_preview" text, + "body_text" text, + "body_html" text, + "email_message_id" text, + "in_reply_to" text, + "response_message_id" text, + "agentmail_message_id" text, + "status" text DEFAULT 'received' NOT NULL, + "chat_id" uuid, + "trigger_job_id" text, + "result_summary" text, + "error_message" text, + "rejection_reason" text, + "has_attachments" boolean DEFAULT false NOT NULL, + "cc_recipients" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "processing_started_at" timestamp, + "completed_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "mothership_inbox_webhook" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "webhook_id" text NOT NULL, + "secret" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "mothership_inbox_webhook_workspace_id_unique" UNIQUE("workspace_id") +); +--> statement-breakpoint +ALTER TABLE "workspace" ADD COLUMN "inbox_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "workspace" ADD COLUMN "inbox_address" text;--> statement-breakpoint +ALTER TABLE "workspace" ADD COLUMN "inbox_provider_id" text;--> statement-breakpoint +ALTER TABLE "mothership_inbox_allowed_sender" ADD CONSTRAINT "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mothership_inbox_allowed_sender" ADD CONSTRAINT "mothership_inbox_allowed_sender_added_by_user_id_fk" FOREIGN KEY ("added_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mothership_inbox_task" ADD CONSTRAINT "mothership_inbox_task_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mothership_inbox_task" ADD CONSTRAINT "mothership_inbox_task_chat_id_copilot_chats_id_fk" FOREIGN KEY ("chat_id") REFERENCES "public"."copilot_chats"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mothership_inbox_webhook" ADD CONSTRAINT "mothership_inbox_webhook_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "inbox_sender_ws_email_idx" ON "mothership_inbox_allowed_sender" USING btree ("workspace_id","email");--> statement-breakpoint +CREATE INDEX "inbox_task_ws_created_at_idx" ON "mothership_inbox_task" USING btree ("workspace_id","created_at");--> statement-breakpoint +CREATE INDEX "inbox_task_ws_status_idx" ON "mothership_inbox_task" USING btree ("workspace_id","status");--> statement-breakpoint +CREATE INDEX "inbox_task_response_msg_id_idx" ON "mothership_inbox_task" USING btree ("response_message_id");--> statement-breakpoint +CREATE INDEX "inbox_task_email_msg_id_idx" ON "mothership_inbox_task" USING btree ("email_message_id"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0172_snapshot.json b/packages/db/migrations/meta/0172_snapshot.json index d5f484ac25..0ab4aa5620 100644 --- a/packages/db/migrations/meta/0172_snapshot.json +++ b/packages/db/migrations/meta/0172_snapshot.json @@ -1,5 +1,5 @@ { - "id": "d2304a4b-0b0f-4cb9-b325-0fa29ee60d13", + "id": "3423d6df-9e7e-4ac2-880c-3c7a40e40197", "prevId": "ffa08eb3-e03d-4418-880d-8fdc17091fa5", "version": "7", "dialect": "postgresql", @@ -92,12 +92,6 @@ "primaryKey": false, "notNull": false }, - "archived_at": { - "name": "archived_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, "created_at": { "name": "created_at", "type": "timestamp", @@ -161,22 +155,6 @@ } ], "isUnique": true, - "where": "\"a2a_agent\".\"archived_at\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "a2a_agent_archived_at_idx": { - "name": "a2a_agent_archived_at_idx", - "columns": [ - { - "expression": "archived_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, "concurrently": false, "method": "btree", "with": {} @@ -1212,12 +1190,6 @@ "notNull": false, "default": "'[]'" }, - "archived_at": { - "name": "archived_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, "created_at": { "name": "created_at", "type": "timestamp", @@ -1245,22 +1217,6 @@ } ], "isUnique": true, - "where": "\"chat\".\"archived_at\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "chat_archived_at_idx": { - "name": "chat_archived_at_idx", - "columns": [ - { - "expression": "archived_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, "concurrently": false, "method": "btree", "with": {} @@ -1373,13 +1329,6 @@ "primaryKey": false, "notNull": false }, - "resources": { - "name": "resources", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'" - }, "last_seen_at": { "name": "last_seen_at", "type": "timestamp", @@ -3085,12 +3034,6 @@ "notNull": true, "default": true }, - "archived_at": { - "name": "archived_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, "deleted_at": { "name": "deleted_at", "type": "timestamp", @@ -3327,36 +3270,6 @@ "method": "btree", "with": {} }, - "doc_archived_at_idx": { - "name": "doc_archived_at_idx", - "columns": [ - { - "expression": "archived_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "doc_deleted_at_idx": { - "name": "doc_deleted_at_idx", - "columns": [ - { - "expression": "deleted_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, "doc_tag1_idx": { "name": "doc_tag1_idx", "columns": [ @@ -3629,7 +3542,7 @@ "tableTo": "knowledge_connector", "columnsFrom": ["connector_id"], "columnsTo": ["id"], - "onDelete": "cascade", + "onDelete": "set null", "onUpdate": "no action" } }, @@ -4421,12 +4334,6 @@ "notNull": true, "default": true }, - "archived_at": { - "name": "archived_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, "created_at": { "name": "created_at", "type": "timestamp", @@ -4454,7 +4361,6 @@ } ], "isUnique": true, - "where": "\"form\".\"archived_at\" IS NULL", "concurrently": false, "method": "btree", "with": {} @@ -4488,21 +4394,6 @@ "concurrently": false, "method": "btree", "with": {} - }, - "form_archived_at_idx": { - "name": "form_archived_at_idx", - "columns": [ - { - "expression": "archived_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} } }, "foreignKeys": { @@ -5317,12 +5208,6 @@ "notNull": true, "default": "now()" }, - "archived_at": { - "name": "archived_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, "deleted_at": { "name": "deleted_at", "type": "timestamp", @@ -5366,36 +5251,6 @@ "concurrently": false, "method": "btree", "with": {} - }, - "kc_archived_at_idx": { - "name": "kc_archived_at_idx", - "columns": [ - { - "expression": "archived_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "kc_deleted_at_idx": { - "name": "kc_deleted_at_idx", - "columns": [ - { - "expression": "deleted_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} } }, "foreignKeys": { @@ -5921,33 +5776,370 @@ "name": "memory_workspace_key_idx", "columns": [ { - "expression": "workspace_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "key", + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": true, + "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "memory_workspace_id_workspace_id_fk": { - "name": "memory_workspace_id_workspace_id_fk", - "tableFrom": "memory", + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", "tableTo": "workspace", "columnsFrom": ["workspace_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -5956,6 +6148,66 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.oauth_access_token": { "name": "oauth_access_token", "schema": "", @@ -9350,12 +9602,6 @@ "notNull": true, "default": 0 }, - "archived_at": { - "name": "archived_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, "created_by": { "name": "created_by", "type": "text", @@ -9410,22 +9656,6 @@ } ], "isUnique": true, - "where": "\"user_table_definitions\".\"archived_at\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "user_table_def_archived_at_idx": { - "name": "user_table_def_archived_at_idx", - "columns": [ - { - "expression": "archived_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, "concurrently": false, "method": "btree", "with": {} @@ -9826,12 +10056,6 @@ "primaryKey": false, "notNull": false }, - "archived_at": { - "name": "archived_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, "created_at": { "name": "created_at", "type": "timestamp", @@ -9865,7 +10089,6 @@ } ], "isUnique": true, - "where": "\"webhook\".\"archived_at\" IS NULL", "concurrently": false, "method": "btree", "with": {} @@ -9926,21 +10149,6 @@ "concurrently": false, "method": "btree", "with": {} - }, - "webhook_archived_at_idx": { - "name": "webhook_archived_at_idx", - "columns": [ - { - "expression": "archived_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} } }, "foreignKeys": { @@ -10089,12 +10297,6 @@ "primaryKey": false, "notNull": false, "default": "'{}'" - }, - "archived_at": { - "name": "archived_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false } }, "indexes": { @@ -10149,34 +10351,6 @@ "method": "btree", "with": {} }, - "workflow_workspace_folder_name_active_unique": { - "name": "workflow_workspace_folder_name_active_unique", - "columns": [ - { - "expression": "workspace_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "coalesce(\"folder_id\", '')", - "asc": true, - "isExpression": true, - "nulls": "last" - }, - { - "expression": "name", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"workflow\".\"archived_at\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, "workflow_folder_sort_idx": { "name": "workflow_folder_sort_idx", "columns": [ @@ -10197,21 +10371,6 @@ "concurrently": false, "method": "btree", "with": {} - }, - "workflow_archived_at_idx": { - "name": "workflow_archived_at_idx", - "columns": [ - { - "expression": "archived_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} } }, "foreignKeys": { @@ -11563,12 +11722,6 @@ "notNull": true, "default": false }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, "created_at": { "name": "created_at", "type": "timestamp", @@ -11614,21 +11767,6 @@ "concurrently": false, "method": "btree", "with": {} - }, - "workflow_mcp_server_deleted_at_idx": { - "name": "workflow_mcp_server_deleted_at_idx", - "columns": [ - { - "expression": "deleted_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} } }, "foreignKeys": { @@ -11698,12 +11836,6 @@ "notNull": true, "default": "'{}'" }, - "archived_at": { - "name": "archived_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, "created_at": { "name": "created_at", "type": "timestamp", @@ -11767,22 +11899,6 @@ } ], "isUnique": true, - "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "workflow_mcp_tool_archived_at_idx": { - "name": "workflow_mcp_tool_archived_at_idx", - "columns": [ - { - "expression": "archived_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, "concurrently": false, "method": "btree", "with": {} @@ -11974,12 +12090,6 @@ "primaryKey": false, "notNull": false }, - "archived_at": { - "name": "archived_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, "created_at": { "name": "created_at", "type": "timestamp", @@ -12019,7 +12129,6 @@ } ], "isUnique": true, - "where": "\"workflow_schedule\".\"archived_at\" IS NULL", "concurrently": false, "method": "btree", "with": {} @@ -12044,21 +12153,6 @@ "concurrently": false, "method": "btree", "with": {} - }, - "workflow_schedule_archived_at_idx": { - "name": "workflow_schedule_archived_at_idx", - "columns": [ - { - "expression": "archived_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} } }, "foreignKeys": { @@ -12246,9 +12340,22 @@ "notNull": true, "default": true }, - "archived_at": { - "name": "archived_at", - "type": "timestamp", + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", "primaryKey": false, "notNull": false }, @@ -12525,12 +12632,6 @@ "primaryKey": false, "notNull": true }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, "uploaded_at": { "name": "uploaded_at", "type": "timestamp", @@ -12569,21 +12670,6 @@ "concurrently": false, "method": "btree", "with": {} - }, - "workspace_file_deleted_at_idx": { - "name": "workspace_file_deleted_at_idx", - "columns": [ - { - "expression": "deleted_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} } }, "foreignKeys": { @@ -12670,12 +12756,6 @@ "primaryKey": false, "notNull": true }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, "uploaded_at": { "name": "uploaded_at", "type": "timestamp", @@ -12685,22 +12765,6 @@ } }, "indexes": { - "workspace_files_key_active_unique": { - "name": "workspace_files_key_active_unique", - "columns": [ - { - "expression": "key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"workspace_files\".\"deleted_at\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, "workspace_files_key_idx": { "name": "workspace_files_key_idx", "columns": [ @@ -12760,21 +12824,6 @@ "concurrently": false, "method": "btree", "with": {} - }, - "workspace_files_deleted_at_idx": { - "name": "workspace_files_deleted_at_idx", - "columns": [ - { - "expression": "deleted_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} } }, "foreignKeys": { @@ -12798,7 +12847,13 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, + "uniqueConstraints": { + "workspace_files_key_unique": { + "name": "workspace_files_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 4b2248767d..e16621ac02 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1202,8 +1202,8 @@ { "idx": 172, "version": "7", - "when": 1773390821375, - "tag": "0172_silky_magma", + "when": 1773374324997, + "tag": "0172_glossy_miek", "breakpoints": true } ] diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 3839075f2c..f9d5b6a360 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1055,7 +1055,9 @@ export const workspace = pgTable('workspace', { .notNull() .references(() => user.id, { onDelete: 'no action' }), allowPersonalApiKeys: boolean('allow_personal_api_keys').notNull().default(true), - archivedAt: timestamp('archived_at'), + inboxEnabled: boolean('inbox_enabled').notNull().default(false), + inboxAddress: text('inbox_address'), + inboxProviderId: text('inbox_provider_id'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }) @@ -2683,3 +2685,70 @@ export const jwks = pgTable('jwks', { privateKey: text('private_key').notNull(), createdAt: timestamp('created_at').notNull(), }) + +export const mothershipInboxAllowedSender = pgTable( + 'mothership_inbox_allowed_sender', + { + id: text('id').primaryKey(), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspace.id, { onDelete: 'cascade' }), + email: text('email').notNull(), + label: text('label'), + addedBy: text('added_by') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => ({ + wsEmailIdx: uniqueIndex('inbox_sender_ws_email_idx').on(table.workspaceId, table.email), + }) +) + +export const mothershipInboxTask = pgTable( + 'mothership_inbox_task', + { + id: text('id').primaryKey(), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspace.id, { onDelete: 'cascade' }), + fromEmail: text('from_email').notNull(), + fromName: text('from_name'), + subject: text('subject').notNull(), + bodyPreview: text('body_preview'), + bodyText: text('body_text'), + bodyHtml: text('body_html'), + emailMessageId: text('email_message_id'), + inReplyTo: text('in_reply_to'), + responseMessageId: text('response_message_id'), + agentmailMessageId: text('agentmail_message_id'), + status: text('status').notNull().default('received'), + chatId: uuid('chat_id').references(() => copilotChats.id, { onDelete: 'set null' }), + triggerJobId: text('trigger_job_id'), + resultSummary: text('result_summary'), + errorMessage: text('error_message'), + rejectionReason: text('rejection_reason'), + hasAttachments: boolean('has_attachments').notNull().default(false), + ccRecipients: text('cc_recipients'), + createdAt: timestamp('created_at').notNull().defaultNow(), + processingStartedAt: timestamp('processing_started_at'), + completedAt: timestamp('completed_at'), + }, + (table) => ({ + wsCreatedAtIdx: index('inbox_task_ws_created_at_idx').on(table.workspaceId, table.createdAt), + wsStatusIdx: index('inbox_task_ws_status_idx').on(table.workspaceId, table.status), + responseMsgIdIdx: index('inbox_task_response_msg_id_idx').on(table.responseMessageId), + emailMsgIdIdx: index('inbox_task_email_msg_id_idx').on(table.emailMessageId), + }) +) + +export const mothershipInboxWebhook = pgTable('mothership_inbox_webhook', { + id: text('id').primaryKey(), + workspaceId: text('workspace_id') + .notNull() + .unique() + .references(() => workspace.id, { onDelete: 'cascade' }), + webhookId: text('webhook_id').notNull(), + secret: text('secret').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), +}) From 0fc5333e7946eaba5285994b04106ebc175c459e Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 12 Mar 2026 23:01:03 -0700 Subject: [PATCH 02/21] revert hardcoded ff --- apps/sim/lib/core/config/feature-flags.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 0856b3f859..b1e3b148d6 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -1,7 +1,7 @@ /** * Environment utility functions for consistent environment detection across the application */ -import { env, isFalsy, isTruthy } from './env' +import { env, getEnv, isFalsy, isTruthy } from './env' /** * Is the application running in production mode @@ -21,7 +21,9 @@ export const isTest = env.NODE_ENV === 'test' /** * Is this the hosted version of the application */ -export const isHosted = true +export const isHosted = + getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' || + getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai' /** * Is billing enforcement enabled From 7ac4875ca614dc71000d3c74628355551ed8cf06 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 12 Mar 2026 23:09:45 -0700 Subject: [PATCH 03/21] fix(inbox): address PR review comments - plan enforcement, idempotency, webhook auth - Enforce Max plan at API layer: hasInboxAccess() now checks subscription tier (>= 25k credits or enterprise) - Add idempotency guard to executeInboxTask() to prevent duplicate emails on Trigger.dev retries - Add AGENTMAIL_WEBHOOK_SECRET env var for webhook signature verification (Bearer token) --- apps/sim/app/api/webhooks/agentmail/route.ts | 10 ++++++ .../app/api/workspaces/[id]/inbox/route.ts | 8 ++--- .../workspaces/[id]/inbox/senders/route.ts | 12 +++---- .../api/workspaces/[id]/inbox/tasks/route.ts | 4 +-- apps/sim/lib/billing/core/subscription.ts | 32 +++++++++++++++---- apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/mothership/inbox/executor.ts | 5 +++ 7 files changed, 54 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index c7482c6e21..4da0376c0f 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -11,6 +11,7 @@ import { tasks } from '@trigger.dev/sdk' import { and, eq, gt, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { v4 as uuidv4 } from 'uuid' +import { env } from '@/lib/core/config/env' import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { executeInboxTask } from '@/lib/mothership/inbox/executor' import type { AgentMailWebhookPayload, RejectionReason } from '@/lib/mothership/inbox/types' @@ -22,6 +23,15 @@ const MAX_EMAILS_PER_HOUR = 20 export async function POST(req: Request) { try { + const webhookSecret = env.AGENTMAIL_WEBHOOK_SECRET + if (webhookSecret) { + const authHeader = req.headers.get('authorization') + if (authHeader !== `Bearer ${webhookSecret}`) { + logger.warn('Invalid webhook authorization') + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + } + const payload = (await req.json()) as AgentMailWebhookPayload if (payload.event_type !== 'message.received') { diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.ts index 0dc3b5edd2..448161b555 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.ts @@ -22,9 +22,9 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const hasAccess = hasInboxAccess() + const hasAccess = await hasInboxAccess(session.user.id) if (!hasAccess) { - return NextResponse.json({ error: 'Inbox feature is not available' }, { status: 403 }) + return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) } const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) @@ -84,9 +84,9 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const hasAccess = hasInboxAccess() + const hasAccess = await hasInboxAccess(session.user.id) if (!hasAccess) { - return NextResponse.json({ error: 'Inbox feature is not available' }, { status: 403 }) + return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) } const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts index 741b559424..84015c70ef 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts @@ -26,9 +26,9 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const hasAccess = hasInboxAccess() + const hasAccess = await hasInboxAccess(session.user.id) if (!hasAccess) { - return NextResponse.json({ error: 'Inbox feature is not available' }, { status: 403 }) + return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) } const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) @@ -74,9 +74,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const hasAccess = hasInboxAccess() + const hasAccess = await hasInboxAccess(session.user.id) if (!hasAccess) { - return NextResponse.json({ error: 'Inbox feature is not available' }, { status: 403 }) + return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) } const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) @@ -131,9 +131,9 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const hasAccess = hasInboxAccess() + const hasAccess = await hasInboxAccess(session.user.id) if (!hasAccess) { - return NextResponse.json({ error: 'Inbox feature is not available' }, { status: 403 }) + return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) } const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts index a2baf6b5f6..bca852b310 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts @@ -15,9 +15,9 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const hasAccess = hasInboxAccess() + const hasAccess = await hasInboxAccess(session.user.id) if (!hasAccess) { - return NextResponse.json({ error: 'Inbox feature is not available' }, { status: 403 }) + return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) } const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 081d764716..07af8cb90f 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -4,7 +4,12 @@ import { createLogger } from '@sim/logger' import { and, eq, sql } from 'drizzle-orm' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { getUserUsageLimit } from '@/lib/billing/core/usage' -import { isOrgPlan, isPro as isPlanPro, isTeam as isPlanTeam } from '@/lib/billing/plan-helpers' +import { + getPlanTierCredits, + isOrgPlan, + isPro as isPlanPro, + isTeam as isPlanTeam, +} from '@/lib/billing/plan-helpers' import { checkEnterprisePlan, checkProPlan, @@ -395,13 +400,28 @@ export async function hasAccessControlAccess(userId: string): Promise { * Check if user has access to inbox (Sim Mailer) feature * Returns true if: * - INBOX_ENABLED env var is set (self-hosted override), OR - * - Running on hosted (sim.ai) environment + * - User has a Max plan (credits >= 25000) or enterprise plan + * + * In non-production environments, returns true for convenience. */ -export function hasInboxAccess(): boolean { - if (isInboxEnabled && !isHosted) { - return true +export async function hasInboxAccess(userId: string): Promise { + try { + if (isInboxEnabled && !isHosted) { + return true + } + if (!isHosted) { + return false + } + if (!isProd) { + return true + } + const sub = await getHighestPrioritySubscription(userId) + if (!sub) return false + return getPlanTierCredits(sub.plan) >= 25000 || checkEnterprisePlan(sub) + } catch (error) { + logger.error('Error checking inbox access', { error, userId }) + return false } - return isHosted } /** diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 0d9147a253..cb3038049e 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -300,6 +300,7 @@ export const env = createEnv({ // AgentMail - Mothership Email Inbox AGENTMAIL_API_KEY: z.string().min(1).optional(), // AgentMail API key for mothership email inbox + AGENTMAIL_WEBHOOK_SECRET: z.string().min(1).optional(), // Shared secret for verifying AgentMail webhook requests (Bearer token) INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted (bypasses hosted requirements) // E2B Remote Code Execution diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 26ac405503..9e6d804904 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -40,6 +40,11 @@ export async function executeInboxTask(taskId: string): Promise { return } + if (inboxTask.status === 'completed' || inboxTask.status === 'failed') { + logger.info('Inbox task already terminal, skipping', { taskId, status: inboxTask.status }) + return + } + const [ws] = await db .select() .from(workspace) From 183a88079b9f7ebc94fbc9fbd501fbb7f64c0441 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 12 Mar 2026 23:16:48 -0700 Subject: [PATCH 04/21] improvement(inbox): harden security and efficiency from code audit - Use crypto.timingSafeEqual for webhook secret comparison (prevents timing attacks) - Atomic claim in executor: WHERE status='received' prevents duplicate processing on retries - Parallelize hasInboxAccess + getUserEntityPermissions in all API routes (reduces latency) - Truncate email body at webhook insertion (50k char limit, prevents unbounded DB storage) - Harden escapeAttr with angle bracket and single quote escaping - Rename use-inbox.ts to inbox.ts (matches hooks/queries/ naming convention) --- apps/sim/app/api/webhooks/agentmail/route.ts | 12 +++++++---- .../app/api/workspaces/[id]/inbox/route.ts | 14 +++++++------ .../workspaces/[id]/inbox/senders/route.ts | 21 +++++++++++-------- .../api/workspaces/[id]/inbox/tasks/route.ts | 7 ++++--- .../components/inbox/inbox-enable-toggle.tsx | 2 +- .../components/inbox/inbox-settings-tab.tsx | 2 +- .../components/inbox/inbox-task-list.tsx | 4 ++-- .../settings/components/inbox/inbox.tsx | 2 +- .../hooks/queries/{use-inbox.ts => inbox.ts} | 0 apps/sim/lib/mothership/inbox/executor.ts | 10 +++++++-- apps/sim/lib/mothership/inbox/response.ts | 7 ++++++- 11 files changed, 51 insertions(+), 30 deletions(-) rename apps/sim/hooks/queries/{use-inbox.ts => inbox.ts} (100%) diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index 4da0376c0f..74f2d4dd3d 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from 'crypto' import { db, mothershipInboxAllowedSender, @@ -25,8 +26,11 @@ export async function POST(req: Request) { try { const webhookSecret = env.AGENTMAIL_WEBHOOK_SECRET if (webhookSecret) { - const authHeader = req.headers.get('authorization') - if (authHeader !== `Bearer ${webhookSecret}`) { + const authHeader = req.headers.get('authorization') ?? '' + const expected = `Bearer ${webhookSecret}` + const authBuf = Buffer.from(authHeader) + const expectedBuf = Buffer.from(expected) + if (authBuf.length !== expectedBuf.length || !timingSafeEqual(authBuf, expectedBuf)) { logger.warn('Invalid webhook authorization') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -115,8 +119,8 @@ export async function POST(req: Request) { const fromName = extractDisplayName(message.from_) const taskId = uuidv4() - const bodyText = message.text || null - const bodyHtml = message.html || null + 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({ diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.ts index 448161b555..83851cb1fb 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.ts @@ -22,12 +22,13 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const hasAccess = await hasInboxAccess(session.user.id) + const [hasAccess, permission] = await Promise.all([ + hasInboxAccess(session.user.id), + getUserEntityPermissions(session.user.id, 'workspace', workspaceId), + ]) if (!hasAccess) { return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) } - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) if (!permission) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) } @@ -84,12 +85,13 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const hasAccess = await hasInboxAccess(session.user.id) + const [hasAccess, permission] = await Promise.all([ + hasInboxAccess(session.user.id), + getUserEntityPermissions(session.user.id, 'workspace', workspaceId), + ]) if (!hasAccess) { return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) } - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) if (permission !== 'admin') { return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) } diff --git a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts index 84015c70ef..c09a55a0ac 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts @@ -26,12 +26,13 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const hasAccess = await hasInboxAccess(session.user.id) + const [hasAccess, permission] = await Promise.all([ + hasInboxAccess(session.user.id), + getUserEntityPermissions(session.user.id, 'workspace', workspaceId), + ]) if (!hasAccess) { return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) } - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) if (!permission) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) } @@ -74,12 +75,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const hasAccess = await hasInboxAccess(session.user.id) + const [hasAccess, permission] = await Promise.all([ + hasInboxAccess(session.user.id), + getUserEntityPermissions(session.user.id, 'workspace', workspaceId), + ]) if (!hasAccess) { return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) } - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) if (permission !== 'admin') { return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) } @@ -131,12 +133,13 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const hasAccess = await hasInboxAccess(session.user.id) + const [hasAccess, permission] = await Promise.all([ + hasInboxAccess(session.user.id), + getUserEntityPermissions(session.user.id, 'workspace', workspaceId), + ]) if (!hasAccess) { return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) } - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) if (permission !== 'admin') { return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) } diff --git a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts index bca852b310..ef378be211 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts @@ -15,12 +15,13 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const hasAccess = await hasInboxAccess(session.user.id) + const [hasAccess, permission] = await Promise.all([ + hasInboxAccess(session.user.id), + getUserEntityPermissions(session.user.id, 'workspace', workspaceId), + ]) if (!hasAccess) { return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) } - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) if (!permission) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-enable-toggle.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-enable-toggle.tsx index b2dcc57124..01f38f9e11 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-enable-toggle.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-enable-toggle.tsx @@ -13,7 +13,7 @@ import { ModalHeader, Switch, } from '@/components/emcn' -import { useInboxConfig, useToggleInbox } from '@/hooks/queries/use-inbox' +import { useInboxConfig, useToggleInbox } from '@/hooks/queries/inbox' const logger = createLogger('InboxEnableToggle') diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-settings-tab.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-settings-tab.tsx index f3fceb3e76..08d5bd2226 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-settings-tab.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-settings-tab.tsx @@ -22,7 +22,7 @@ import { useInboxSenders, useRemoveInboxSender, useUpdateInboxAddress, -} from '@/hooks/queries/use-inbox' +} from '@/hooks/queries/inbox' const logger = createLogger('InboxSettingsTab') diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-task-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-task-list.tsx index d20f5e5d6d..eed33cabcc 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-task-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-task-list.tsx @@ -15,8 +15,8 @@ import { import { Input } from '@/components/ui' import { formatRelativeTime } from '@/lib/core/utils/formatting' import { InboxTaskSkeleton } from '@/app/workspace/[workspaceId]/settings/components/inbox/inbox-skeleton' -import type { InboxTaskItem } from '@/hooks/queries/use-inbox' -import { useInboxConfig, useInboxTasks } from '@/hooks/queries/use-inbox' +import type { InboxTaskItem } from '@/hooks/queries/inbox' +import { useInboxConfig, useInboxTasks } from '@/hooks/queries/inbox' const STATUS_OPTIONS = [ { value: 'all', label: 'All statuses' }, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx index dac11540bf..171306c849 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx @@ -9,8 +9,8 @@ import { InboxSettingsTab } from '@/app/workspace/[workspaceId]/settings/compone import { InboxSkeleton } from '@/app/workspace/[workspaceId]/settings/components/inbox/inbox-skeleton' import { InboxTaskList } from '@/app/workspace/[workspaceId]/settings/components/inbox/inbox-task-list' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' +import { useInboxConfig } from '@/hooks/queries/inbox' import { useSubscriptionData } from '@/hooks/queries/subscription' -import { useInboxConfig } from '@/hooks/queries/use-inbox' export function Inbox() { const params = useParams() diff --git a/apps/sim/hooks/queries/use-inbox.ts b/apps/sim/hooks/queries/inbox.ts similarity index 100% rename from apps/sim/hooks/queries/use-inbox.ts rename to apps/sim/hooks/queries/inbox.ts diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 9e6d804904..02b647262b 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -63,14 +63,20 @@ export async function executeInboxTask(taskId: string): Promise { let chatId = inboxTask.chatId try { - const [, userId] = await Promise.all([ + const [[claimed], userId] = await Promise.all([ db .update(mothershipInboxTask) .set({ status: 'processing', processingStartedAt: new Date() }) - .where(eq(mothershipInboxTask.id, taskId)), + .where(and(eq(mothershipInboxTask.id, taskId), eq(mothershipInboxTask.status, 'received'))) + .returning({ id: mothershipInboxTask.id }), resolveUserId(inboxTask.fromEmail, ws), ]) + if (!claimed) { + logger.info('Task already claimed by another execution, skipping', { taskId }) + return + } + if (!chatId) { const chatResult = await resolveOrCreateChat({ userId, diff --git a/apps/sim/lib/mothership/inbox/response.ts b/apps/sim/lib/mothership/inbox/response.ts index a47b940caa..f43b30b206 100644 --- a/apps/sim/lib/mothership/inbox/response.ts +++ b/apps/sim/lib/mothership/inbox/response.ts @@ -109,5 +109,10 @@ function escapeHtml(str: string): string { } function escapeAttr(str: string): string { - return str.replace(/&/g, '&').replace(/"/g, '"') + return str + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>') } From 347b0c9a26a35777ab5763d991509522b61fc631 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 12 Mar 2026 23:23:11 -0700 Subject: [PATCH 05/21] fix(inbox): replace Bearer token auth with proper Svix HMAC-SHA256 webhook verification - Use per-workspace webhook secret from DB instead of global env var - Verify AgentMail/Svix signatures: HMAC-SHA256 over svix-id.timestamp.body - Timing-safe comparison via crypto.timingSafeEqual - Replay protection via timestamp tolerance (5 min window) - Join mothershipInboxWebhook in workspace lookup (zero additional DB calls) - Remove dead AGENTMAIL_WEBHOOK_SECRET env var - Select only needed workspace columns in webhook handler --- apps/sim/app/api/webhooks/agentmail/route.ts | 112 ++++++++++++++----- apps/sim/lib/core/config/env.ts | 1 - 2 files changed, 84 insertions(+), 29 deletions(-) diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index 74f2d4dd3d..07f000eed8 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -1,8 +1,9 @@ -import { timingSafeEqual } from 'crypto' +import { createHmac, timingSafeEqual } from 'crypto' import { db, mothershipInboxAllowedSender, mothershipInboxTask, + mothershipInboxWebhook, permissions, user, workspace, @@ -12,7 +13,6 @@ import { tasks } from '@trigger.dev/sdk' import { and, eq, gt, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { v4 as uuidv4 } from 'uuid' -import { env } from '@/lib/core/config/env' import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { executeInboxTask } from '@/lib/mothership/inbox/executor' import type { AgentMailWebhookPayload, RejectionReason } from '@/lib/mothership/inbox/types' @@ -21,22 +21,16 @@ const logger = createLogger('AgentMailWebhook') const AUTOMATED_SENDERS = ['mailer-daemon@', 'noreply@', 'no-reply@', 'postmaster@'] const MAX_EMAILS_PER_HOUR = 20 +const TIMESTAMP_TOLERANCE_SECONDS = 300 export async function POST(req: Request) { try { - const webhookSecret = env.AGENTMAIL_WEBHOOK_SECRET - if (webhookSecret) { - const authHeader = req.headers.get('authorization') ?? '' - const expected = `Bearer ${webhookSecret}` - const authBuf = Buffer.from(authHeader) - const expectedBuf = Buffer.from(expected) - if (authBuf.length !== expectedBuf.length || !timingSafeEqual(authBuf, expectedBuf)) { - logger.warn('Invalid webhook authorization') - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - } + const rawBody = await req.text() + const svixId = req.headers.get('svix-id') + const svixTimestamp = req.headers.get('svix-timestamp') + const svixSignature = req.headers.get('svix-signature') - const payload = (await req.json()) as AgentMailWebhookPayload + const payload = JSON.parse(rawBody) as AgentMailWebhookPayload if (payload.event_type !== 'message.received') { return NextResponse.json({ ok: true }) @@ -48,32 +42,51 @@ export async function POST(req: Request) { return NextResponse.json({ ok: true }) } - const [ws] = await db - .select() + const [result] = 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(eq(workspace.inboxProviderId, inboxId)) .limit(1) - if (!ws) { + if (!result) { logger.warn('No workspace found for inbox', { inboxId }) return NextResponse.json({ ok: true }) } - if (!ws.inboxEnabled) { - logger.info('Inbox disabled, rejecting', { workspaceId: ws.id }) + if (result.webhookSecret && svixId && svixTimestamp && svixSignature) { + if ( + !verifySvixSignature(rawBody, svixId, svixTimestamp, svixSignature, result.webhookSecret) + ) { + logger.warn('Webhook signature verification failed', { workspaceId: result.id }) + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }) + } + } else if (result.webhookSecret) { + logger.warn('Webhook missing Svix headers, rejecting', { workspaceId: result.id }) + return NextResponse.json({ error: 'Missing signature headers' }, { 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: ws.id }) + logger.info('Webhook received', { fromEmail, from_raw: message.from_, workspaceId: result.id }) - if (ws.inboxAddress && fromEmail === ws.inboxAddress.toLowerCase()) { - logger.info('Skipping email from inbox itself', { workspaceId: ws.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(ws.id, message, 'automated_sender') + await createRejectedTask(result.id, message, 'automated_sender') return NextResponse.json({ ok: true }) } @@ -88,8 +101,8 @@ export async function POST(req: Request) { .where(eq(mothershipInboxTask.emailMessageId, emailMessageId)) .limit(1) : Promise.resolve([]), - isSenderAllowed(fromEmail, ws.id), - getRecentTaskCount(ws.id), + isSenderAllowed(fromEmail, result.id), + getRecentTaskCount(result.id), inReplyTo ? db .select({ chatId: mothershipInboxTask.chatId }) @@ -105,12 +118,12 @@ export async function POST(req: Request) { } if (!isAllowed) { - await createRejectedTask(ws.id, message, 'sender_not_allowed') + await createRejectedTask(result.id, message, 'sender_not_allowed') return NextResponse.json({ ok: true }) } if (recentCount >= MAX_EMAILS_PER_HOUR) { - await createRejectedTask(ws.id, message, 'rate_limit_exceeded') + await createRejectedTask(result.id, message, 'rate_limit_exceeded') return NextResponse.json({ ok: true }) } @@ -125,7 +138,7 @@ export async function POST(req: Request) { await db.insert(mothershipInboxTask).values({ id: taskId, - workspaceId: ws.id, + workspaceId: result.id, fromEmail, fromName, subject: message.subject || '(no subject)', @@ -179,6 +192,49 @@ export async function POST(req: Request) { } } +/** + * Verify Svix webhook signature (HMAC-SHA256). + * AgentMail uses Svix for webhook delivery. The signed content is: + * `${svixId}.${svixTimestamp}.${rawBody}` + * The secret is prefixed with `whsec_` followed by a base64-encoded key. + */ +function verifySvixSignature( + rawBody: string, + svixId: string, + svixTimestamp: string, + svixSignature: string, + secret: string +): boolean { + const ts = Number.parseInt(svixTimestamp, 10) + if (Number.isNaN(ts)) return false + + const now = Math.floor(Date.now() / 1000) + if (Math.abs(now - ts) > TIMESTAMP_TOLERANCE_SECONDS) { + logger.warn('Webhook timestamp outside tolerance', { svixTimestamp, now }) + return false + } + + const secretKey = secret.startsWith('whsec_') ? secret.slice(6) : secret + const secretBytes = Buffer.from(secretKey, 'base64') + + const signedContent = `${svixId}.${svixTimestamp}.${rawBody}` + const computed = createHmac('sha256', secretBytes).update(signedContent).digest('base64') + + const signatures = svixSignature.split(' ') + for (const sig of signatures) { + const parts = sig.split(',') + if (parts.length < 2) continue + const sigValue = parts.slice(1).join(',') + const sigBuf = Buffer.from(sigValue) + const computedBuf = Buffer.from(computed) + if (sigBuf.length === computedBuf.length && timingSafeEqual(sigBuf, computedBuf)) { + return true + } + } + + return false +} + async function isSenderAllowed(email: string, workspaceId: string): Promise { const [allowedSenderResult, memberResult] = await Promise.all([ db diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index cb3038049e..0d9147a253 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -300,7 +300,6 @@ export const env = createEnv({ // AgentMail - Mothership Email Inbox AGENTMAIL_API_KEY: z.string().min(1).optional(), // AgentMail API key for mothership email inbox - AGENTMAIL_WEBHOOK_SECRET: z.string().min(1).optional(), // Shared secret for verifying AgentMail webhook requests (Bearer token) INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted (bypasses hosted requirements) // E2B Remote Code Execution From 95c02d7843f5b564f391cd481ebeb7eac3bbb962 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 12 Mar 2026 23:26:04 -0700 Subject: [PATCH 06/21] =?UTF-8?q?fix(inbox):=20require=20webhook=20secret?= =?UTF-8?q?=20=E2=80=94=20reject=20requests=20when=20secret=20is=20missing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, if the webhook secret was missing from the DB (corrupted state), the handler would skip verification entirely and process the request unauthenticated. Now all three conditions are hard requirements: secret must exist in DB, Svix headers must be present, and signature must verify. --- apps/sim/app/api/webhooks/agentmail/route.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index 07f000eed8..210ac328ca 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -60,18 +60,21 @@ export async function POST(req: Request) { return NextResponse.json({ ok: true }) } - if (result.webhookSecret && svixId && svixTimestamp && svixSignature) { - if ( - !verifySvixSignature(rawBody, svixId, svixTimestamp, svixSignature, result.webhookSecret) - ) { - logger.warn('Webhook signature verification failed', { workspaceId: result.id }) - return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }) - } - } else if (result.webhookSecret) { + if (!result.webhookSecret) { + logger.warn('No webhook secret found for workspace, rejecting', { workspaceId: result.id }) + return NextResponse.json({ error: 'Webhook not configured' }, { status: 401 }) + } + + if (!svixId || !svixTimestamp || !svixSignature) { logger.warn('Webhook missing Svix headers, rejecting', { workspaceId: result.id }) return NextResponse.json({ error: 'Missing signature headers' }, { status: 401 }) } + if (!verifySvixSignature(rawBody, svixId, svixTimestamp, svixSignature, result.webhookSecret)) { + logger.warn('Webhook signature verification failed', { workspaceId: result.id }) + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }) + } + if (!result.inboxEnabled) { logger.info('Inbox disabled, rejecting', { workspaceId: result.id }) return NextResponse.json({ ok: true }) From 720fd7ffbfe056bd406edb9223c536725f920044 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 12 Mar 2026 23:41:14 -0700 Subject: [PATCH 07/21] fix(inbox): address second round of PR review comments - Exclude rejected tasks from rate limit count to prevent DoS via spam - Strip raw HTML from LLM output before marked.parse to prevent XSS in emails - Track responseSent flag to prevent duplicate emails when DB update fails after send --- apps/sim/app/api/webhooks/agentmail/route.ts | 5 ++-- apps/sim/lib/mothership/inbox/executor.ts | 28 +++++++++++--------- apps/sim/lib/mothership/inbox/response.ts | 6 ++++- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index 210ac328ca..44ae7f9d44 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -10,7 +10,7 @@ import { } from '@sim/db' import { createLogger } from '@sim/logger' import { tasks } from '@trigger.dev/sdk' -import { and, eq, gt, sql } from 'drizzle-orm' +import { and, eq, gt, ne, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { v4 as uuidv4 } from 'uuid' import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' @@ -275,7 +275,8 @@ async function getRecentTaskCount(workspaceId: string): Promise { .where( and( eq(mothershipInboxTask.workspaceId, workspaceId), - gt(mothershipInboxTask.createdAt, oneHourAgo) + gt(mothershipInboxTask.createdAt, oneHourAgo), + ne(mothershipInboxTask.status, 'rejected') ) ) return result?.count ?? 0 diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 02b647262b..8b459eb8d9 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -61,6 +61,7 @@ export async function executeInboxTask(taskId: string): Promise { } let chatId = inboxTask.chatId + let responseSent = false try { const [[claimed], userId] = await Promise.all([ @@ -192,6 +193,7 @@ export async function executeInboxTask(taskId: string): Promise { { success: result.success, content: cleanContent, error: errorStr }, { inboxProviderId: ws.inboxProviderId, workspaceId: ws.id } ) + responseSent = true await db .update(mothershipInboxTask) @@ -221,18 +223,20 @@ export async function executeInboxTask(taskId: string): Promise { await markTaskFailed(taskId, error instanceof Error ? error.message : 'Execution failed') - try { - await sendInboxResponse( - { ...inboxTask, chatId }, - { - success: false, - content: '', - error: error instanceof Error ? error.message : 'Execution failed', - }, - { inboxProviderId: ws.inboxProviderId, workspaceId: ws.id } - ) - } catch (emailError) { - logger.error('Failed to send error email', { taskId, emailError }) + if (!responseSent) { + try { + await sendInboxResponse( + { ...inboxTask, chatId }, + { + success: false, + content: '', + error: error instanceof Error ? error.message : 'Execution failed', + }, + { inboxProviderId: ws.inboxProviderId, workspaceId: ws.id } + ) + } catch (emailError) { + logger.error('Failed to send error email', { taskId, emailError }) + } } } } diff --git a/apps/sim/lib/mothership/inbox/response.ts b/apps/sim/lib/mothership/inbox/response.ts index f43b30b206..de6c9fcd6b 100644 --- a/apps/sim/lib/mothership/inbox/response.ts +++ b/apps/sim/lib/mothership/inbox/response.ts @@ -79,8 +79,12 @@ const EMAIL_STYLES = ` .signature a { color: #1a1a1a; text-decoration: underline; text-decoration-style: dashed; text-underline-offset: 2px; } ` +function stripRawHtml(text: string): string { + return text.replace(/<\/?[a-z][^>]*>/gi, '') +} + function renderEmailHtml(markdown: string, chatUrl: string): string { - const bodyHtml = marked.parse(markdown, { async: false }) as string + const bodyHtml = marked.parse(stripRawHtml(markdown), { async: false }) as string return ` From d712c51997dc8798e30d793e624d40ddb3ac7ec8 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 12 Mar 2026 23:53:16 -0700 Subject: [PATCH 08/21] fix(inbox): address third round of PR review comments - Use dynamic isHosted from feature-flags instead of hardcoded true - Atomic JSON append for chat message persistence (eliminates read-modify-write race) - Handle cutIndex === 0 in stripQuotedReply (body starts with quote) - Clean up orphan mothershipInboxWebhook row on enableInbox rollback - Validate status query parameter against enum in tasks API --- .../app/api/workspaces/[id]/inbox/tasks/route.ts | 4 ++++ apps/sim/lib/mothership/inbox/executor.ts | 15 +++++---------- apps/sim/lib/mothership/inbox/format.ts | 3 ++- apps/sim/lib/mothership/inbox/lifecycle.ts | 3 +++ 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts index ef378be211..e54275984f 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts @@ -33,7 +33,11 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: const conditions = [eq(mothershipInboxTask.workspaceId, workspaceId)] + const validStatuses = ['received', 'processing', 'completed', 'failed', 'rejected'] as const if (status !== 'all') { + if (!validStatuses.includes(status as (typeof validStatuses)[number])) { + return NextResponse.json({ error: 'Invalid status filter' }, { status: 400 }) + } conditions.push(eq(mothershipInboxTask.status, status)) } diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 8b459eb8d9..4c79c27208 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -1,6 +1,6 @@ import { copilotChats, db, mothershipInboxTask, permissions, user, workspace } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, sql } from 'drizzle-orm' import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle' import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload' import { requestChatTitle } from '@/lib/copilot/chat-streaming' @@ -8,6 +8,7 @@ import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types' import { taskPubSub } from '@/lib/copilot/task-events' import { generateWorkspaceContext } from '@/lib/copilot/workspace-context' +import { isHosted } from '@/lib/core/config/feature-flags' import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { formatEmailAsMessage } from '@/lib/mothership/inbox/format' import { sendInboxResponse } from '@/lib/mothership/inbox/response' @@ -160,7 +161,7 @@ export async function executeInboxTask(taskId: string): Promise { chatId, mode: 'agent', messageId: userMessageId, - isHosted: true, + isHosted, workspaceContext, ...(integrationTools.length > 0 ? { integrationTools } : {}), ...(userPermission ? { userPermission } : {}), @@ -279,13 +280,6 @@ async function persistChatMessages( result: OrchestratorResult ): Promise { try { - const [chat] = await db - .select({ messages: copilotChats.messages }) - .from(copilotChats) - .where(eq(copilotChats.id, chatId)) - .limit(1) - - const existingMessages = Array.isArray(chat?.messages) ? chat.messages : [] const now = new Date().toISOString() const userMessage = { @@ -303,10 +297,11 @@ async function persistChatMessages( ...(result.error ? { errorType: 'internal' } : {}), } + const newMessages = JSON.stringify([userMessage, assistantMessage]) await db .update(copilotChats) .set({ - messages: [...existingMessages, userMessage, assistantMessage], + messages: sql`COALESCE(${copilotChats.messages}, '[]'::jsonb) || ${newMessages}::jsonb`, updatedAt: new Date(), }) .where(eq(copilotChats.id, chatId)) diff --git a/apps/sim/lib/mothership/inbox/format.ts b/apps/sim/lib/mothership/inbox/format.ts index 3add0f4db1..cf290f67f1 100644 --- a/apps/sim/lib/mothership/inbox/format.ts +++ b/apps/sim/lib/mothership/inbox/format.ts @@ -79,7 +79,8 @@ function stripQuotedReply(text: string): string { return false }) - if (cutIndex <= 0) return text + if (cutIndex < 0) return text + if (cutIndex === 0) return '(reply with no new content above the quote)' return lines.slice(0, cutIndex).join('\n').trim() } diff --git a/apps/sim/lib/mothership/inbox/lifecycle.ts b/apps/sim/lib/mothership/inbox/lifecycle.ts index 4de8aa916a..768757003c 100644 --- a/apps/sim/lib/mothership/inbox/lifecycle.ts +++ b/apps/sim/lib/mothership/inbox/lifecycle.ts @@ -66,6 +66,9 @@ export async function enableInbox( try { if (webhook) await agentmail.deleteWebhook(webhook.webhook_id) await agentmail.deleteInbox(inbox.inbox_id) + await db + .delete(mothershipInboxWebhook) + .where(eq(mothershipInboxWebhook.workspaceId, workspaceId)) } catch (rollbackError) { logger.error('Failed to rollback AgentMail resources', { rollbackError }) } From 27a42bc6d528f5e85d600bb26e7981b3baa5a9f3 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 13 Mar 2026 00:02:47 -0700 Subject: [PATCH 09/21] fix(inbox): validate cursor param, preserve code blocks in HTML stripping - Validate cursor date before using in query (return 400 for invalid) - Split on fenced code blocks before stripping HTML tags to preserve code examples in email responses --- apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts | 6 +++++- apps/sim/lib/mothership/inbox/response.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts index e54275984f..8deb40cb67 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts @@ -42,7 +42,11 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: } if (cursor) { - conditions.push(lt(mothershipInboxTask.createdAt, new Date(cursor))) + const cursorDate = new Date(cursor) + if (Number.isNaN(cursorDate.getTime())) { + return NextResponse.json({ error: 'Invalid cursor value' }, { status: 400 }) + } + conditions.push(lt(mothershipInboxTask.createdAt, cursorDate)) } const tasks = await db diff --git a/apps/sim/lib/mothership/inbox/response.ts b/apps/sim/lib/mothership/inbox/response.ts index de6c9fcd6b..a4ee29c5ec 100644 --- a/apps/sim/lib/mothership/inbox/response.ts +++ b/apps/sim/lib/mothership/inbox/response.ts @@ -80,7 +80,10 @@ const EMAIL_STYLES = ` ` function stripRawHtml(text: string): string { - return text.replace(/<\/?[a-z][^>]*>/gi, '') + return text + .split(/(```[\s\S]*?```)/g) + .map((segment, i) => (i % 2 === 0 ? segment.replace(/<\/?[a-z][^>]*>/gi, '') : segment)) + .join('') } function renderEmailHtml(markdown: string, chatUrl: string): string { From 118fc41f1673e7cdd8d2e6bd803b7ac2d6990747 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 13 Mar 2026 01:15:35 -0700 Subject: [PATCH 10/21] fix(inbox): return 500 on webhook server errors to enable Svix retries --- apps/sim/app/api/webhooks/agentmail/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index 44ae7f9d44..b7b4f57919 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -191,7 +191,7 @@ export async function POST(req: Request) { logger.error('AgentMail webhook error', { error: error instanceof Error ? error.message : 'Unknown error', }) - return NextResponse.json({ ok: true }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } From 1a0aced47ee9ad579ae0defa829e7b5c9740e63d Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 13 Mar 2026 01:20:37 -0700 Subject: [PATCH 11/21] =?UTF-8?q?fix(inbox):=20remove=20isHosted=20guard?= =?UTF-8?q?=20from=20hasInboxAccess=20=E2=80=94=20feature=20flag=20is=20su?= =?UTF-8?q?fficient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sim/lib/billing/core/subscription.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 07af8cb90f..ff435a085e 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -399,19 +399,15 @@ export async function hasAccessControlAccess(userId: string): Promise { /** * Check if user has access to inbox (Sim Mailer) feature * Returns true if: - * - INBOX_ENABLED env var is set (self-hosted override), OR + * - INBOX_ENABLED env var is set, OR + * - Non-production environment, OR * - User has a Max plan (credits >= 25000) or enterprise plan - * - * In non-production environments, returns true for convenience. */ export async function hasInboxAccess(userId: string): Promise { try { - if (isInboxEnabled && !isHosted) { + if (isInboxEnabled) { return true } - if (!isHosted) { - return false - } if (!isProd) { return true } From b786865e6b8597b4c8f1ebd11f7294851f1a1aa6 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 13 Mar 2026 01:36:28 -0700 Subject: [PATCH 12/21] fix(inbox): prevent double-enable from deleting webhook secret row --- apps/sim/app/api/workspaces/[id]/inbox/route.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.ts index 83851cb1fb..3e64cf3417 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.ts @@ -100,6 +100,14 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id const body = patchSchema.parse(await req.json()) if (body.enabled === true) { + const [current] = await db + .select({ inboxEnabled: workspace.inboxEnabled }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + if (current?.inboxEnabled) { + return NextResponse.json({ error: 'Inbox is already enabled' }, { status: 409 }) + } const config = await enableInbox(workspaceId, { username: body.username }) return NextResponse.json(config) } From b936cdb794236b296f3a5aa59417df849d0a3e4a Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 13 Mar 2026 02:10:14 -0700 Subject: [PATCH 13/21] fix(inbox): null-safe stripThinkingTags, encode URL params, surface remove-sender errors - Guard against null result.content in stripThinkingTags - Use encodeURIComponent on all AgentMail API path parameters - Surface handleRemoveSender errors to the user instead of swallowing --- .../components/inbox/inbox-settings-tab.tsx | 13 +++++--- .../lib/mothership/inbox/agentmail-client.ts | 33 +++++++++++-------- apps/sim/lib/mothership/inbox/executor.ts | 2 +- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-settings-tab.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-settings-tab.tsx index 08d5bd2226..5c83b56886 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-settings-tab.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox-settings-tab.tsx @@ -1,7 +1,6 @@ 'use client' import { useCallback, useState } from 'react' -import { createLogger } from '@sim/logger' import { Check, Clipboard, Pencil, Plus, Trash2 } from 'lucide-react' import { useParams } from 'next/navigation' import { @@ -24,8 +23,6 @@ import { useUpdateInboxAddress, } from '@/hooks/queries/inbox' -const logger = createLogger('InboxSettingsTab') - export function InboxSettingsTab() { const params = useParams() const workspaceId = params.workspaceId as string @@ -45,6 +42,7 @@ export function InboxSettingsTab() { const [newUsername, setNewUsername] = useState('') const [editAddressError, setEditAddressError] = useState(null) + const [removeSenderError, setRemoveSenderError] = useState(null) const [copiedAddress, setCopiedAddress] = useState(false) const handleCopyAddress = useCallback(() => { @@ -86,10 +84,11 @@ export function InboxSettingsTab() { const handleRemoveSender = useCallback( async (senderId: string) => { + setRemoveSenderError(null) try { await removeSender.mutateAsync({ workspaceId, senderId }) } catch (error) { - logger.error('Failed to remove sender', { error }) + setRemoveSenderError(error instanceof Error ? error.message : 'Failed to remove sender') } }, [workspaceId] @@ -218,6 +217,12 @@ export function InboxSettingsTab() { )}
+ {removeSenderError && ( +

+ {removeSenderError} +

+ )} +