diff --git a/apps/web/package.json b/apps/web/package.json index dff6d9359..70699f228 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,6 +25,7 @@ "@corbits/url-path": "workspace:*", "@corbits/worker": "workspace:*", "@corbits/workflows": "workspace:*", + "@intx/mime": "catalog:", "@intx/types": "workspace:*", "@isomorphic-git/lightning-fs": "4.7.0", "@radix-ui/react-dialog": "^1.1.15", diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index 5206b97cb..f81272231 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -2,6 +2,7 @@ // timeline, and the deploy roster every workbench surface shares. import { type } from "arktype"; +import { decodeMail } from "@intx/mime"; import { WorkflowDeploymentResponse } from "@intx/types"; import { reportError } from "@corbits/error-sink"; @@ -196,82 +197,49 @@ export function base64ToUtf8(base64: string): string { return new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))); } -/** The readable text of an RFC 5322 frame: the bytes after the header - * section, or the first `text/plain` part of a multipart one. */ -export function frameBody(raw: string): string { - let decoded: string; - try { - decoded = base64ToUtf8(raw); - } catch { - return ""; - } - return textPart(decoded.replace(/\r\n/g, "\n")) ?? ""; -} - -/** First `text/plain` leaf of a MIME entity, descending nested multiparts - * (an agent reply is `multipart/signed` around `multipart/mixed`). The - * boundary is matched case-sensitively: it is a token, not a header name. */ -function textPart(entity: string): string | undefined { - const split = entity.indexOf("\n\n"); - if (split < 0) return undefined; - const headers = entity.slice(0, split); - const body = entity.slice(split + 2); - const boundary = /boundary="?([^";\n]+)"?/i.exec(headers)?.[1]; - if (boundary === undefined) { - return /content-type:\s*text\/plain/i.test(headers) || !/content-type:/i.test(headers) - ? body.trim() - : undefined; - } - for (const part of body.split(`--${boundary}`).slice(1)) { - if (part.startsWith("--")) break; - const found = textPart(part.replace(/^\n/, "")); - if (found !== undefined) return found; - } - return undefined; -} +const utf8 = new TextDecoder(); -/** Every named, non-inline MIME leaf of a frame, decoded. A part counts as - * an attachment when it carries a filename; the text body has none. */ -export function frameAttachments(raw: string): readonly MailAttachment[] { - let decoded: string; +// Decoded leaf parts of a frame; an undecodable frame has none. +function frameParts(raw: string, operation: string) { try { - decoded = base64ToUtf8(raw); + const mail = decodeMail(Uint8Array.from(atob(raw), (char) => char.charCodeAt(0))); + // RFC 2045: a frame with no Content-Type is text/plain; the decoder + // reports it as octet-stream. + const bare = mail.rawHeaders["content-type"] === undefined; + return mail.parts.map((leaf) => + bare && leaf.contentType === "application/octet-stream" + ? { ...leaf, contentType: "text/plain" } + : leaf, + ); } catch (cause) { - reportError(cause, { operation: "chat_frame_attachments" }); + reportError(cause, { operation }); return []; } - return attachmentParts(decoded.replace(/\r\n/g, "\n")); } -function attachmentParts(entity: string): MailAttachment[] { - const split = entity.indexOf("\n\n"); - if (split < 0) return []; - const headers = entity.slice(0, split).replace(/\n[ \t]+/g, " "); - const body = entity.slice(split + 2); - const boundary = /boundary="?([^";\n]+)"?/i.exec(headers)?.[1]; - if (boundary !== undefined) { - const found: MailAttachment[] = []; - for (const part of body.split(`--${boundary}`).slice(1)) { - if (part.startsWith("--")) break; - found.push(...attachmentParts(part.replace(/^\n/, ""))); - } - return found; - } - const name = /(?:filename|name)\*?="?([^";\n]+)"?/i.exec(headers)?.[1]; - if (name === undefined) return []; - const contentType = - /content-type:\s*([^;\n]+)/i.exec(headers)?.[1]?.trim() ?? "application/octet-stream"; - const base64 = /content-transfer-encoding:\s*base64/i.test(headers); - return [{ name, contentType, text: base64 ? decodeBase64(body) : body.trim() }]; +/** The readable text of an RFC 5322 frame: its first unnamed `text/plain` + * leaf, however deeply the signed and mixed wrappers nest it. */ +export function frameBody(raw: string): string { + const part = frameParts(raw, "chat_frame_body").find( + (leaf) => leaf.filename === undefined && leaf.contentType === "text/plain", + ); + return part === undefined ? "" : utf8.decode(part.content).trim(); } -function decodeBase64(body: string): string { - try { - return base64ToUtf8(body.replace(/\s+/g, "")); - } catch (cause) { - reportError(cause, { operation: "chat_attachment_decode" }); - return ""; - } +/** Every named MIME leaf of a frame, decoded. A part counts as an + * attachment when it carries a filename; the text body has none. */ +export function frameAttachments(raw: string): readonly MailAttachment[] { + return frameParts(raw, "chat_frame_attachments").flatMap((leaf) => + leaf.filename === undefined + ? [] + : [ + { + name: leaf.filename, + contentType: leaf.contentType, + text: utf8.decode(leaf.content), + }, + ], + ); } function extractAddress(raw: string): string { diff --git a/bun.lock b/bun.lock index f5574d54c..c1da6db24 100644 --- a/bun.lock +++ b/bun.lock @@ -125,6 +125,7 @@ "@corbits/url-path": "workspace:*", "@corbits/worker": "workspace:*", "@corbits/workflows": "workspace:*", + "@intx/mime": "catalog:", "@intx/types": "workspace:*", "@isomorphic-git/lightning-fs": "4.7.0", "@radix-ui/react-dialog": "^1.1.15", @@ -1835,11 +1836,11 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/codex-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#3028f86", { "dependencies": { "@intx/db": "^0.3.0", "@intx/hub-api": "^0.3.0", "@intx/hub-common": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "2.2.3", "drizzle-orm": "^0.45.2", "hono": "^4.11.9" } }, "corbitsdev-corbits-oauth-core-3028f86", "sha512-KMxRZgEi7S99tqoWhXqAAm5Bv4ef5tn3eYFcfbiHHX/E+GPnbedF44/ISbGs2gedoR2U9EtA1gnmh+q+Qfg00w=="], - "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@corbits/xai-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#3028f86", { "dependencies": { "@intx/db": "^0.3.0", "@intx/hub-api": "^0.3.0", "@intx/hub-common": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "2.2.3", "drizzle-orm": "^0.45.2", "hono": "^4.11.9" } }, "corbitsdev-corbits-oauth-core-3028f86", "sha512-KMxRZgEi7S99tqoWhXqAAm5Bv4ef5tn3eYFcfbiHHX/E+GPnbedF44/ISbGs2gedoR2U9EtA1gnmh+q+Qfg00w=="], + "@corbits/mcp/@corbits/credential-mcp": ["@corbits/credential-mcp@github:corbitsdev/corbits-credential-mcp#fb9ec3a", { "peerDependencies": { "@intx/harness": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-credential-mcp-fb9ec3a", "sha512-Co4jR+L3pfBbVYY7I6jRftoHeSb5+LCxzEEaAhVLYA3aY2PrPHZV37+DWxj3fcK/KdzboqbT3XSnqU8DyHRU9Q=="], + + "@corbits/worker/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#4a2a3c2", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/agent": "^0.3.0", "@intx/hub-api": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-4a2a3c2", "sha512-UY5nkQ31kGmYgqgIOSlbigwvm7c/s2kpqEbF6Oqo0rzoS1RejpPp+w/Yim6h2MRpYCl5CXgAET1MhkDboHuWBA=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -1857,17 +1858,11 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#4a2a3c2", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/agent": "^0.3.0", "@intx/hub-api": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-4a2a3c2", "sha512-UY5nkQ31kGmYgqgIOSlbigwvm7c/s2kpqEbF6Oqo0rzoS1RejpPp+w/Yim6h2MRpYCl5CXgAET1MhkDboHuWBA=="], - - "@workbench/hub/@corbits/codex-provider": ["@corbits/codex-provider@github:corbitsdev/corbits-codex-provider#a1f475f", { "dependencies": { "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core", "@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses", "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-codex-provider-a1f475f", "sha512-D6d0lblWx/4KudfLwQoQGrLB8GKRLP/HVp1cx08L2/hOGZAgH6txLPpeEtBqjl+R520qXSSPfM0kDC+3XNaAfw=="], - - "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#65590a8", { "dependencies": { "@hono/standard-validator": "0.2.3", "@intx/crypto": "0.3.0", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-65590a8", "sha512-tK7xXcL780iX3I+5R68gmyqgEA0uwYO1qdTDNn/LF6tOiFIm0lt2szjspJ8JMKMTzKC6SSai03p723rEGM1jOg=="], - "@workbench/hub/@corbits/mcp": ["@corbits/mcp@github:corbitsdev/corbits-mcp#9df1ab8", { "dependencies": { "@corbits/credential-mcp": "github:corbitsdev/corbits-credential-mcp#fb9ec3a6754348cd33c388c70c0830b8844c83de", "@intx/db": "^0.3.0", "@intx/hub-api": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "2.1.20", "drizzle-orm": "^0.45.2", "hono": "^4.11.9" }, "peerDependencies": { "@intx/agent": ">=0.3.0" } }, "corbitsdev-corbits-mcp-9df1ab8", "sha512-5ILvgNI4J/U2kWitFKZmIJO1qybCdIkKZ73ZELu6PWrbw6tARxO8/ffaGoK0nIMrT4EcBZaMd6YkR0vLdPOWnw=="], "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#8be608b", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/types": "^0.3.0", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-8be608b", "sha512-7NcVcz3IHFTfx6ICQT+4gjX1PBNLqjlCqoEOPDFVW7wc4e2+Ueg9wOZQ+0MVttpQCaDg6MObl4kie410UQm/sQ=="], - "@workbench/hub/@corbits/xai-provider": ["@corbits/xai-provider@github:corbitsdev/corbits-xai-provider#9f2d4ba", { "dependencies": { "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core", "@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses", "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-xai-provider-9f2d4ba", "sha512-ZjvKxGcp+QzCh8zeXHg7kNbtkpR/itZkmQAcdG1aOAOjHaNwfGVpkPc/c/XtwPNsQyaDjF8W9CHb4rH1xI2uCA=="], + "@workbench/web/@corbits/credential-mcp": ["@corbits/credential-mcp@github:corbitsdev/corbits-credential-mcp#fb9ec3a", { "peerDependencies": { "@intx/harness": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-credential-mcp-fb9ec3a", "sha512-Co4jR+L3pfBbVYY7I6jRftoHeSb5+LCxzEEaAhVLYA3aY2PrPHZV37+DWxj3fcK/KdzboqbT3XSnqU8DyHRU9Q=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], @@ -1963,14 +1958,6 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@workbench/hub/@corbits/codex-provider/@corbits/openai-responses": ["@corbits/openai-responses@github:corbitsdev/corbits-openai-responses#7efe000", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-openai-responses-7efe000", "sha512-pH6C8T1WDJjqM+YbnQpofd7HgP5krEj48JCCQK16KI98a6kaz+gffpgdOdQpU3aNVZnFlyADXod1Z4tqUG/3ww=="], - - "@workbench/hub/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - - "@workbench/hub/@corbits/mcp/@corbits/credential-mcp": ["@corbits/credential-mcp@github:corbitsdev/corbits-credential-mcp#fb9ec3a", { "peerDependencies": { "@intx/harness": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-credential-mcp-fb9ec3a", "sha512-Co4jR+L3pfBbVYY7I6jRftoHeSb5+LCxzEEaAhVLYA3aY2PrPHZV37+DWxj3fcK/KdzboqbT3XSnqU8DyHRU9Q=="], - - "@workbench/hub/@corbits/xai-provider/@corbits/openai-responses": ["@corbits/openai-responses@github:corbitsdev/corbits-openai-responses#7efe000", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-openai-responses-7efe000", "sha512-pH6C8T1WDJjqM+YbnQpofd7HgP5krEj48JCCQK16KI98a6kaz+gffpgdOdQpU3aNVZnFlyADXod1Z4tqUG/3ww=="], - "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],