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
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:
@@ -0,0 +1,47 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
const logger = createLogger('MothershipEvents')
|
||||
|
||||
/**
|
||||
* Custom-event name used to send a user message to the Mothership chat.
|
||||
* The mothership host components (workspace home, workflow panel) listen
|
||||
* for this event and call their `sendMessage` on receipt.
|
||||
*/
|
||||
export const MOTHERSHIP_SEND_MESSAGE_EVENT = 'mothership-send-message'
|
||||
|
||||
export interface MothershipSendMessageDetail {
|
||||
message: string
|
||||
/** Structured contexts to attach — e.g. a `logs` mention tagging a run. */
|
||||
contexts?: ChatContext[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a message to a mounted Mothership chat. Producers (terminal block
|
||||
* errors, console copilot actions, toast actions, the log "Troubleshoot in
|
||||
* Chat" action) call this; consumers listen for
|
||||
* {@link MOTHERSHIP_SEND_MESSAGE_EVENT} on `window` and `preventDefault` to
|
||||
* claim it.
|
||||
*
|
||||
* @returns `true` when a mounted host consumed the message, `false` when none
|
||||
* was listening — callers that can fall back (e.g. cross-route navigation) use
|
||||
* this to decide whether to persist a handoff instead.
|
||||
*/
|
||||
export function sendMothershipMessage(message: string, contexts?: ChatContext[]): boolean {
|
||||
const trimmed = message.trim()
|
||||
if (!trimmed) {
|
||||
logger.warn('sendMothershipMessage called with empty message')
|
||||
return false
|
||||
}
|
||||
const consumed = !window.dispatchEvent(
|
||||
new CustomEvent<MothershipSendMessageDetail>(MOTHERSHIP_SEND_MESSAGE_EVENT, {
|
||||
detail: { message: trimmed, contexts },
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
logger.info('Dispatched mothership message event', {
|
||||
messageLength: trimmed.length,
|
||||
consumed,
|
||||
})
|
||||
return consumed
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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> = {
|
||||
' ': ' ',
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes known HTML entities in a single pass to avoid double-unescaping.
|
||||
* A two-step decode (e.g. `&` -> `&` then `<` -> `<`) would turn
|
||||
* `&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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { whereMock } = vi.hoisted(() => ({ whereMock: vi.fn() }))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: { select: () => ({ from: () => ({ where: whereMock }) }) },
|
||||
skill: { workspaceId: 'workspaceId', name: 'name', description: 'description' },
|
||||
}))
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
vi.mock('drizzle-orm', () => ({ eq: vi.fn(() => ({})) }))
|
||||
|
||||
import { buildUserSkillTool, LOAD_USER_SKILL_TOOL_NAME } from './skills'
|
||||
|
||||
describe('buildUserSkillTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns null without a workspace id', async () => {
|
||||
expect(await buildUserSkillTool('')).toBeNull()
|
||||
expect(whereMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null when the workspace has no user skills', async () => {
|
||||
whereMock.mockResolvedValue([])
|
||||
expect(await buildUserSkillTool('ws-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('builds one load_user_skill tool with an enum of workspace skill names', async () => {
|
||||
whereMock.mockResolvedValue([
|
||||
{ name: 'posthog-playbook', description: 'PostHog steps' },
|
||||
{ name: 'brand-voice', description: 'Tone rules' },
|
||||
])
|
||||
|
||||
const tool = await buildUserSkillTool('ws-1')
|
||||
|
||||
expect(tool?.name).toBe(LOAD_USER_SKILL_TOOL_NAME)
|
||||
// Must NOT be executeLocally — it dispatches with executor "sim", not the browser client.
|
||||
expect(tool?.executeLocally).toBeUndefined()
|
||||
expect(tool?.params).toMatchObject({ mothershipToolKind: 'skill' })
|
||||
expect(tool?.input_schema).toMatchObject({
|
||||
type: 'object',
|
||||
properties: { skill_name: { enum: ['posthog-playbook', 'brand-voice'] } },
|
||||
required: ['skill_name'],
|
||||
})
|
||||
expect(tool?.description).toContain('posthog-playbook')
|
||||
expect(tool?.description).toContain('brand-voice')
|
||||
})
|
||||
|
||||
it('returns null when the skill query fails', async () => {
|
||||
whereMock.mockRejectedValue(new Error('db down'))
|
||||
expect(await buildUserSkillTool('ws-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { db, skill } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { ToolSchema } from '@/lib/copilot/chat/payload'
|
||||
|
||||
const logger = createLogger('MothershipUserSkills')
|
||||
|
||||
export const LOAD_USER_SKILL_TOOL_NAME = 'load_user_skill'
|
||||
|
||||
/**
|
||||
* Build the single load_user_skill tool that exposes all workspace
|
||||
* user-created skills to the mothership and its subagents.
|
||||
*
|
||||
* User skills live in the `skill` table (builtins are code-only and are treated
|
||||
* as defaults, so they are excluded here). The tool is a non-deferred,
|
||||
* sim-executed request-local tool: when the model calls it, Go forwards the
|
||||
* call and sim resolves the content via resolveSkillContent (see tools/index.ts).
|
||||
* It is available to all users — there is no super-admin gate, unlike the curated
|
||||
* mothership_settings tools.
|
||||
*
|
||||
* Embedded copilot/internal skills are not handled here: those are autoloaded
|
||||
* into each agent's system prompt on the Go side and never sent as loadable.
|
||||
*/
|
||||
export async function buildUserSkillTool(workspaceId: string): Promise<ToolSchema | null> {
|
||||
if (!workspaceId) return null
|
||||
|
||||
let rows: { name: string; description: string }[]
|
||||
try {
|
||||
rows = await db
|
||||
.select({ name: skill.name, description: skill.description })
|
||||
.from(skill)
|
||||
.where(eq(skill.workspaceId, workspaceId))
|
||||
} catch (error) {
|
||||
logger.error('Failed to load workspace skills for load_user_skill tool', { error, workspaceId })
|
||||
return null
|
||||
}
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const skillNames = rows.map((r) => r.name)
|
||||
const catalog = rows.map((r) => `- ${r.name}: ${r.description}`).join('\n')
|
||||
|
||||
return {
|
||||
name: LOAD_USER_SKILL_TOOL_NAME,
|
||||
description: `Load a user-created skill's full instructions. You MUST call this before following a skill: the list below only tells you which skills exist and when each applies — it is NOT the instructions. To use a skill, call load_user_skill with its exact name and follow the content it returns; never act on a skill's name or description alone. Available skills:\n${catalog}`,
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
skill_name: {
|
||||
type: 'string',
|
||||
description: 'Exact name of the user skill to load.',
|
||||
enum: skillNames,
|
||||
},
|
||||
},
|
||||
required: ['skill_name'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
// Do NOT set executeLocally: skill content is resolved on the sim backend
|
||||
// (DB), so Go must dispatch this with executor "sim". executeLocally maps to
|
||||
// ClientExecutable, which routes the call to the browser client (no handler)
|
||||
// and the load hangs. mothershipToolKind 'skill' is enough for sim routing.
|
||||
params: {
|
||||
mothershipToolKind: 'skill',
|
||||
mothershipToolName: LOAD_USER_SKILL_TOOL_NAME,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user