Files
simstudioai--sim/apps/sim/lib/mothership/inbox/response.ts
T
wehub-resource-sync d25d482dc2
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

318 lines
8.0 KiB
TypeScript

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)
}