chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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<T>(path: string, options: RequestInit = {}): Promise<T> {
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<T>
}
export async function createInbox(opts: {
username?: string
displayName?: string
}): Promise<AgentMailInbox> {
const domain = env.AGENTMAIL_DOMAIN
return request<AgentMailInbox>('/inboxes', {
method: 'POST',
body: JSON.stringify({
username: opts.username,
display_name: opts.displayName,
...(domain ? { domain } : {}),
}),
})
}
export async function deleteInbox(inboxId: string): Promise<void> {
return request<void>(`/inboxes/${encodeURIComponent(inboxId)}`, {
method: 'DELETE',
})
}
export async function getInbox(inboxId: string): Promise<AgentMailInbox> {
return request<AgentMailInbox>(`/inboxes/${encodeURIComponent(inboxId)}`)
}
export async function createWebhook(opts: {
url: string
eventTypes: string[]
inboxIds: string[]
}): Promise<AgentMailWebhook> {
return request<AgentMailWebhook>('/webhooks', {
method: 'POST',
body: JSON.stringify({
url: opts.url,
event_types: opts.eventTypes,
inbox_ids: opts.inboxIds,
}),
})
}
export async function deleteWebhook(webhookId: string): Promise<void> {
return request<void>(`/webhooks/${encodeURIComponent(webhookId)}`, {
method: 'DELETE',
})
}
export async function replyToMessage(
inboxId: string,
messageId: string,
opts: {
text: string
html?: string
to?: string[]
attachments?: AgentMailAttachment[]
}
): Promise<AgentMailReplyResponse> {
return request<AgentMailReplyResponse>(
`/inboxes/${encodeURIComponent(inboxId)}/messages/${encodeURIComponent(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<AgentMailMessage> {
return request<AgentMailMessage>(
`/inboxes/${encodeURIComponent(inboxId)}/messages/${encodeURIComponent(messageId)}`
)
}
interface AttachmentMetadata {
download_url: string
}
export async function getAttachment(
inboxId: string,
messageId: string,
attachmentId: string
): Promise<ArrayBuffer> {
const path = `/inboxes/${encodeURIComponent(inboxId)}/messages/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}`
const metadata = await request<AttachmentMetadata>(path)
const response = await fetch(metadata.download_url)
if (!response.ok) {
throw new Error(`Failed to download attachment from presigned URL: ${response.status}`)
}
return response.arrayBuffer()
}
+520
View File
@@ -0,0 +1,520 @@
import { copilotChats, db, mothershipInboxTask, user, workspace } from '@sim/db'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq, sql } from 'drizzle-orm'
import { getActivelyBannedUserIds, isEmailBlocked } from '@/lib/auth/ban'
import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle'
import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload'
import {
buildPersistedAssistantMessage,
buildPersistedUserMessage,
} from '@/lib/copilot/chat/persisted-message'
import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context'
import { chatPubSub } from '@/lib/copilot/chat-status'
import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements'
import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless'
import { requestChatTitle } from '@/lib/copilot/request/lifecycle/start'
import type { OrchestratorResult } from '@/lib/copilot/request/types'
import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags'
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 { buildUserSkillTool } from '@/lib/mothership/skills'
import { uploadFile } from '@/lib/uploads/core/storage-service'
import { createFileContent, type MessageContent } from '@/lib/uploads/utils/file-utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/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<void> {
const [inboxTask] = await db
.select()
.from(mothershipInboxTask)
.where(eq(mothershipInboxTask.id, taskId))
.limit(1)
if (!inboxTask) {
logger.error('Inbox task not found', { taskId })
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({
id: workspace.id,
ownerId: workspace.ownerId,
inboxProviderId: workspace.inboxProviderId,
})
.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
let responseSent = false
try {
const [[claimed], userId] = await Promise.all([
db
.update(mothershipInboxTask)
.set({ status: 'processing', processingStartedAt: new Date() })
.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
}
// Blocked senders and banned accounts must not drive the agent; the sender
// email is checked directly (domain list + the sender's own account ban)
// because non-members resolve to the workspace owner, and the workspace
// billed account is checked to match preprocessExecution's gate. Fails
// closed on lookup errors. No email response in any of these paths —
// never mail a suspended account.
let blockReason: string | null = null
try {
const [senderBlocked, billedAccountUserId] = await Promise.all([
isEmailBlocked(inboxTask.fromEmail),
getWorkspaceBilledAccountUserId(ws.id),
])
const bannedUserIds = await getActivelyBannedUserIds(
billedAccountUserId ? [userId, billedAccountUserId] : [userId]
)
if (senderBlocked || bannedUserIds.length > 0) {
logger.warn('Blocking inbox task: sender, resolved user, or billed account is banned', {
taskId,
userId,
senderBlocked,
bannedUserIds,
})
blockReason = 'User account is suspended'
}
} catch (error) {
logger.error('Inbox task ban check failed; failing closed', {
taskId,
error: getErrorMessage(error, 'Unknown error'),
})
blockReason = 'Unable to verify account status'
}
if (blockReason) {
responseSent = true
await markTaskFailed(taskId, blockReason)
return
}
if (!chatId) {
const chatResult = await resolveOrCreateChat({
userId,
workspaceId: ws.id,
model: 'claude-opus-4-8',
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-8',
userId,
})
.then(async (title) => {
if (title && chatId) {
await db.update(copilotChats).set({ title }).where(eq(copilotChats.id, chatId))
chatPubSub?.publishStatusChanged({
workspaceId: ws.id,
chatId,
type: 'renamed',
})
}
})
.catch((err) => {
logger.warn('Failed to generate chat title', { chatId, err })
})
chatPubSub?.publishStatusChanged({
workspaceId: ws.id,
chatId,
type: 'created',
})
}
const userMessageId = generateId()
if (chatId) {
chatPubSub?.publishStatusChanged({
workspaceId: ws.id,
chatId,
type: 'started',
streamId: userMessageId,
})
}
const fetchAttachments = async () => {
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 downloaded = await downloadAttachmentContents(
attachments,
ws.inboxProviderId,
inboxTask.agentmailMessageId,
taskId,
userId
)
return { attachments, ...downloaded }
}
const [
attachmentResult,
workspaceContext,
integrationTools,
userSkillTool,
userPermission,
entitlements,
] = await Promise.all([
fetchAttachments(),
generateWorkspaceContext(ws.id, userId),
buildIntegrationToolSchemas(userId, undefined, undefined, ws.id),
buildUserSkillTool(ws.id),
getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null),
computeWorkspaceEntitlements(ws.id, userId),
])
const { attachments, fileAttachments, storedAttachments } = attachmentResult
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 requestPayload: Record<string, unknown> = {
message: messageContent,
userId,
chatId,
mode: 'agent',
messageId: userMessageId,
isHosted,
workspaceContext,
...(isE2BDocEnabled ? { docCompiler: 'python' } : {}),
...(integrationTools.length > 0 ? { integrationTools } : {}),
...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}),
...(userPermission ? { userPermission } : {}),
...(entitlements.length > 0 ? { entitlements } : {}),
...(fileAttachments.length > 0 ? { fileAttachments } : {}),
}
const result = await runHeadlessCopilotLifecycle(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,
userMessageId,
messageContent,
{
...result,
content: cleanContent,
},
storedAttachments
)
}
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 }
)
responseSent = responseMessageId !== null
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) {
chatPubSub?.publishStatusChanged({
workspaceId: ws.id,
chatId,
type: 'completed',
streamId: userMessageId,
})
}
logger.info('Inbox task execution complete', { taskId, status: finalStatus })
} catch (error) {
logger.error('Inbox task execution failed', {
taskId,
error: getErrorMessage(error, 'Unknown error'),
})
await markTaskFailed(taskId, getErrorMessage(error, 'Execution failed'))
if (!responseSent) {
try {
await sendInboxResponse(
{ ...inboxTask, chatId },
{
success: false,
content: '',
error: getErrorMessage(error, '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<string> {
const [matchedUser] = await db
.select({ id: user.id })
.from(user)
.where(sql`lower(${user.email}) = ${senderEmail.toLowerCase()}`)
.orderBy(user.createdAt)
.limit(1)
if (matchedUser) {
const permission = await getUserEntityPermissions(matchedUser.id, 'workspace', ws.id)
if (permission !== null) {
return matchedUser.id
}
}
return 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,
userMessageId: string,
userContent: string,
result: OrchestratorResult,
storedAttachments: StoredAttachment[] = []
): Promise<void> {
try {
const userMessage = buildPersistedUserMessage({
id: userMessageId,
content: userContent,
fileAttachments: storedAttachments.length > 0 ? storedAttachments : undefined,
})
const assistantMessage = buildPersistedAssistantMessage(result)
// Best-effort: the email response is the primary deliverable, so a failure
// here is logged (in the catch below) rather than failing the task.
await db.transaction(async (tx) => {
const [updated] = await tx
.update(copilotChats)
.set({ updatedAt: new Date() })
.where(eq(copilotChats.id, chatId))
.returning({ model: copilotChats.model })
if (!updated) return
await appendCopilotChatMessages(
chatId,
[userMessage, assistantMessage],
{ chatModel: updated.model ?? null },
tx
)
})
} catch (err) {
logger.error('Failed to persist chat messages', {
chatId,
error: getErrorMessage(err, 'Unknown error'),
})
}
}
function stripThinkingTags(text: string): string {
return text
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '')
.replace(/<\/?thinking[^>]*>/gi, '')
.trim()
}
async function markTaskFailed(taskId: string, errorMessage: string): Promise<void> {
await db
.update(mothershipInboxTask)
.set({
status: 'failed',
errorMessage,
completedAt: new Date(),
})
.where(eq(mothershipInboxTask.id, taskId))
}
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024
interface StoredAttachment {
id: string
key: string
filename: string
media_type: string
size: number
}
interface DownloadedAttachments {
fileAttachments: Array<MessageContent & { filename: string }>
storedAttachments: StoredAttachment[]
}
/**
* Download attachment content from AgentMail, convert to file content objects
* for the LLM, and upload to copilot storage for chat display.
*/
async function downloadAttachmentContents(
attachments: AgentMailAttachment[],
inboxProviderId: string | null,
messageId: string | null,
taskId: string,
userId: string
): Promise<DownloadedAttachments> {
if (!inboxProviderId || !messageId || attachments.length === 0) {
return { fileAttachments: [], storedAttachments: [] }
}
const eligible = attachments.filter((a) => {
if (a.size > MAX_ATTACHMENT_SIZE) {
logger.info('Skipping large attachment', { taskId, filename: a.filename, size: a.size })
return false
}
return true
})
const settled = await Promise.allSettled(
eligible.map(async (attachment) => {
const arrayBuffer = await agentmail.getAttachment(
inboxProviderId,
messageId,
attachment.attachment_id
)
const buffer = Buffer.from(arrayBuffer)
const fileContent = createFileContent(buffer, attachment.content_type)
if (!fileContent) return null
const storageKey = `copilot/${Date.now()}-${attachment.attachment_id}-${attachment.filename}`
const uploaded = await uploadFile({
file: buffer,
fileName: attachment.filename,
contentType: attachment.content_type,
context: 'copilot',
customKey: storageKey,
preserveKey: true,
metadata: { userId, originalName: attachment.filename },
})
const stored: StoredAttachment = {
id: attachment.attachment_id,
key: uploaded.key,
filename: attachment.filename,
media_type: attachment.content_type,
size: buffer.length,
}
return { fileContent: { ...fileContent, filename: attachment.filename }, stored }
})
)
const fileAttachments: Array<MessageContent & { filename: string }> = []
const storedAttachments: StoredAttachment[] = []
for (let i = 0; i < settled.length; i++) {
const outcome = settled[i]
if (outcome.status === 'fulfilled' && outcome.value) {
fileAttachments.push(outcome.value.fileContent)
storedAttachments.push(outcome.value.stored)
} else if (outcome.status === 'rejected') {
const attachment = eligible[i]
logger.warn('Failed to download attachment', {
taskId,
attachmentId: attachment.attachment_id,
filename: attachment.filename,
error: getErrorMessage(outcome.reason, 'Unknown error'),
})
}
}
logger.info('Downloaded attachment contents', {
taskId,
total: attachments.length,
downloaded: fileAttachments.length,
})
return { fileAttachments, storedAttachments }
}
+160
View File
@@ -0,0 +1,160 @@
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
if (cutIndex === 0) return '(reply with no new content above the quote)'
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
}
/**
* Repeatedly applies a regex replacement until the string stabilises.
* Prevents incomplete sanitization from nested/overlapping patterns
* like `<scr<script>ipt>`.
*/
export function replaceUntilStable(
input: string,
pattern: RegExp,
replacement: string,
maxIterations = 100
): string {
let prev = input
let next = prev.replace(pattern, replacement)
let iterations = 0
while (next !== prev && iterations++ < maxIterations) {
prev = next
next = prev.replace(pattern, replacement)
}
return next
}
const HTML_ENTITY_MAP: Record<string, string> = {
'&nbsp;': ' ',
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
}
/**
* Decodes known HTML entities in a single pass to avoid double-unescaping.
* A two-step decode (e.g. `&amp;` -> `&` then `&lt;` -> `<`) would turn
* `&amp;lt;` into `<`, which is incorrect.
*/
function decodeHtmlEntities(text: string): string {
return text.replace(/&(?:nbsp|amp|lt|gt|quot|#39);/g, (match) => HTML_ENTITY_MAP[match] ?? match)
}
/**
* Basic HTML to text extraction.
*/
function extractTextFromHtml(html: string | null): string | null {
if (!html) return null
let text = html
text = decodeHtmlEntities(text)
text = replaceUntilStable(text, /<style[^>]*>[\s\S]*?<\/style\s*>/gi, '')
text = replaceUntilStable(text, /<script[^>]*>[\s\S]*?<\/script\s*>/gi, '')
text = text
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<\/div>/gi, '\n')
.replace(/<\/li>/gi, '\n')
text = replaceUntilStable(text, /<[^>]+>/g, '')
text = text.replace(/\n{3,}/g, '\n\n').trim()
return text
}
+144
View File
@@ -0,0 +1,144 @@
import { db, mothershipInboxWebhook, workspace } from '@sim/db'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
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<InboxConfig> {
const inbox = await agentmail.createInbox({
username: opts?.username,
displayName: 'Sim',
})
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<ReturnType<typeof agentmail.createWebhook>> | 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: generateId(),
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)
await db
.delete(mothershipInboxWebhook)
.where(eq(mothershipInboxWebhook.workspaceId, workspaceId))
} 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<void> {
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<void>[] = []
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<InboxConfig> {
await disableInbox(workspaceId)
return enableInbox(workspaceId, { username: newUsername })
}
+317
View File
@@ -0,0 +1,317 @@
import { type ComponentType, type CSSProperties, createElement, type ReactNode } from 'react'
import { Body, Head, Html, Link, Markdown, Section, Text } from '@react-email/components'
import { render } from '@react-email/render'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { getBaseUrl } from '@/lib/core/utils/urls'
import * as agentmail from '@/lib/mothership/inbox/agentmail-client'
import { replaceUntilStable } from '@/lib/mothership/inbox/format'
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<string | null> {
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}/chat/${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
? await renderEmailHtml(result.content, chatUrl)
: await 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: getErrorMessage(error, 'Unknown error'),
})
return null
}
}
const FONT_FAMILY = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif"
const CODE_FONT_FAMILY = "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace"
const emailStyles = {
body: {
fontFamily: FONT_FAMILY,
fontSize: '15px',
lineHeight: '25px',
color: '#1a1a1a',
fontWeight: 430,
},
content: {
margin: 0,
},
markdownContainer: {
margin: 0,
},
signature: {
color: '#525252',
marginTop: '32px',
fontSize: '14px',
},
signatureText: {
color: '#525252',
margin: '0 0 16px 0',
fontSize: '14px',
lineHeight: '25px',
fontFamily: FONT_FAMILY,
},
signatureLink: {
color: '#1a1a1a',
textDecoration: 'underline',
textDecorationStyle: 'dashed',
textUnderlineOffset: '2px',
},
} satisfies Record<string, CSSProperties>
const markdownStyles = {
p: {
margin: '0 0 16px 0',
fontSize: '15px',
lineHeight: '25px',
color: '#1a1a1a',
fontFamily: FONT_FAMILY,
fontWeight: 430,
},
h1: {
fontWeight: 600,
color: '#1a1a1a',
margin: '24px 0 12px 0',
fontSize: '24px',
lineHeight: '32px',
fontFamily: FONT_FAMILY,
},
h2: {
fontWeight: 600,
color: '#1a1a1a',
margin: '24px 0 12px 0',
fontSize: '20px',
lineHeight: '28px',
fontFamily: FONT_FAMILY,
},
h3: {
fontWeight: 600,
color: '#1a1a1a',
margin: '24px 0 12px 0',
fontSize: '16px',
lineHeight: '24px',
fontFamily: FONT_FAMILY,
},
h4: {
fontWeight: 600,
color: '#1a1a1a',
margin: '24px 0 12px 0',
fontSize: '15px',
lineHeight: '25px',
fontFamily: FONT_FAMILY,
},
strong: {
fontWeight: 600,
color: '#1a1a1a',
},
codeInline: {
backgroundColor: '#f3f3f3',
padding: '2px 6px',
borderRadius: '4px',
fontFamily: CODE_FONT_FAMILY,
fontSize: '13px',
color: '#1a1a1a',
},
codeBlock: {
backgroundColor: '#f3f3f3',
padding: '16px',
borderRadius: '8px',
border: '1px solid #ededed',
overflowX: 'auto',
margin: '24px 0',
fontFamily: CODE_FONT_FAMILY,
fontSize: '13px',
lineHeight: '21px',
color: '#1a1a1a',
},
table: {
borderCollapse: 'collapse',
margin: '16px 0',
},
th: {
border: '1px solid #ededed',
padding: '8px 12px',
textAlign: 'left',
fontSize: '14px',
backgroundColor: '#f5f5f5',
fontWeight: 600,
},
td: {
border: '1px solid #ededed',
padding: '8px 12px',
textAlign: 'left',
fontSize: '14px',
},
blockQuote: {
borderLeft: '4px solid #e0e0e0',
margin: '16px 0',
padding: '4px 16px',
color: '#525252',
fontStyle: 'italic',
},
a: {
color: '#2563eb',
textDecoration: 'underline',
textDecorationStyle: 'dashed',
textUnderlineOffset: '2px',
},
ul: {
margin: '16px 0',
paddingLeft: '24px',
},
ol: {
margin: '16px 0',
paddingLeft: '24px',
},
li: {
margin: '4px 0',
},
hr: {
border: 'none',
borderTop: '1px solid #ededed',
margin: '24px 0',
},
} satisfies Record<string, CSSProperties>
interface InboxResponseEmailProps {
children?: ReactNode
chatUrl: string
linkLabel: string
}
interface EmailMarkdownProps {
children?: string
markdownContainerStyles?: CSSProperties
markdownCustomStyles?: Record<string, CSSProperties>
}
const EmailMarkdown = Markdown as ComponentType<EmailMarkdownProps>
function InboxResponseEmail({ children, chatUrl, linkLabel }: InboxResponseEmailProps) {
return createElement(
Html,
{ lang: 'en', dir: 'ltr' },
createElement(Head),
createElement(
Body,
{ style: emailStyles.body },
createElement(Section, { style: emailStyles.content }, children),
createElement(
Section,
{ style: emailStyles.signature },
createElement(
Text,
{ style: emailStyles.signatureText },
createElement(Link, { href: chatUrl, style: emailStyles.signatureLink }, linkLabel)
),
createElement(
Text,
{ style: emailStyles.signatureText },
'Best,',
createElement('br'),
'Sim'
)
)
)
)
}
function stripRawHtml(text: string): string {
return text
.split(/(```[\s\S]*?```)/g)
.map((segment, i) =>
i % 2 === 0 ? replaceUntilStable(segment, /<\/?[a-z][^>]*>/gi, '') : segment
)
.join('')
}
function preserveSoftBreaks(text: string): string {
return text
.split(/(```[\s\S]*?```)/g)
.map((segment, i) => (i % 2 === 0 ? segment.replace(/([^\n])\n(?=[^\n])/g, '$1 \n') : segment))
.join('')
}
function stripUnsafeUrls(html: string): string {
return html.replace(/href\s*=\s*(['"])(?:javascript|vbscript|data):.*?\1/gi, 'href="#"')
}
async function renderEmailHtml(markdown: string, chatUrl: string): Promise<string> {
const safeMarkdown = preserveSoftBreaks(stripRawHtml(markdown))
const html = await render(
createElement(
InboxResponseEmail,
{ chatUrl, linkLabel: 'View full conversation' },
createElement(
EmailMarkdown,
{
markdownContainerStyles: emailStyles.markdownContainer,
markdownCustomStyles: markdownStyles,
},
safeMarkdown
)
)
)
return stripUnsafeUrls(html)
}
async function renderErrorHtml(error: string, chatUrl: string): Promise<string> {
const html = await render(
createElement(
InboxResponseEmail,
{ chatUrl, linkLabel: 'View details' },
createElement(
Text,
{ key: 'message', style: markdownStyles.p },
"I wasn't able to complete this task."
),
createElement(
Text,
{ key: 'error', style: { ...markdownStyles.p, color: '#6b7280' } },
`Error: ${error}`
)
)
)
return stripUnsafeUrls(html)
}
+94
View File
@@ -0,0 +1,94 @@
import type { mothershipInboxTask } from '@sim/db'
export type InboxTask = typeof mothershipInboxTask.$inferSelect
export type InboxTaskStatus = 'received' | 'processing' | 'completed' | 'failed' | 'rejected'
export type RejectionReason =
| 'sender_not_allowed'
| 'automated_sender'
| 'rate_limit_exceeded'
| 'not_entitled'
export interface InboxConfig {
enabled: boolean
address: string | null
providerId: string | null
}
interface InboxTaskStats {
total: number
completed: number
processing: number
failed: number
}
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
}