d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
161 lines
4.6 KiB
TypeScript
161 lines
4.6 KiB
TypeScript
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
|
|
}
|