From 10d9cced84f5d3ac39cfc85c6d33241503d60939 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 01:28:35 -0700 Subject: [PATCH] fix(web): decode mail frames and attachments as UTF-8 (CL-8511) --- apps/web/src/chat-artifact-open.ts | 3 ++- apps/web/src/chat/threads-api.ts | 15 +++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/web/src/chat-artifact-open.ts b/apps/web/src/chat-artifact-open.ts index 56ab3db03..fd14dad3c 100644 --- a/apps/web/src/chat-artifact-open.ts +++ b/apps/web/src/chat-artifact-open.ts @@ -13,6 +13,7 @@ import { resolveRendererKindFromMediaType, } from "@/library"; import { artifactPreviewPath, type ArtifactDetail } from "./api"; +import { base64ToUtf8 } from "./chat/threads-api"; import type { CanvasArtifactContent } from "./shell/canvas-availability"; /** Decodes a Library artifact detail into the canvas's typed content — the @@ -76,7 +77,7 @@ export function artifactContentFromBlob( id: blobId, title: part.name, rendererKind, - content: atob(contentBase64), + content: base64ToUtf8(contentBase64), }; } diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index 9d4696b67..6cc0fdfd9 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -224,12 +224,19 @@ async function getJson(path: string, schema: (value: unknown) => T | type.err return parsed; } +/** Base64 to text as UTF-8. `atob` alone yields one char per byte, which + * turns any non-ASCII body into mojibake. */ +export function base64ToUtf8(base64: string): string { + const binary = atob(base64); + 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 = atob(raw); + decoded = base64ToUtf8(raw); } catch { return ""; } @@ -263,7 +270,7 @@ function textPart(entity: string): string | undefined { export function frameAttachments(raw: string): readonly MailAttachment[] { let decoded: string; try { - decoded = atob(raw); + decoded = base64ToUtf8(raw); } catch (cause) { reportError(cause, { operation: "chat_frame_attachments" }); return []; @@ -295,7 +302,7 @@ function attachmentParts(entity: string): MailAttachment[] { function decodeBase64(body: string): string { try { - return atob(body.replace(/\s+/g, "")); + return base64ToUtf8(body.replace(/\s+/g, "")); } catch (cause) { reportError(cause, { operation: "chat_attachment_decode" }); return ""; @@ -312,7 +319,7 @@ function extractAddress(raw: string): string { function toHeaderAddresses(raw: string): string[] { let decoded: string; try { - decoded = atob(raw); + decoded = base64ToUtf8(raw); } catch { return []; }