From 5496c15ef19bf97c6ced616ca9edae21b7461707 Mon Sep 17 00:00:00 2001 From: "Brian J. Fox" Date: Tue, 3 Nov 2026 09:05:05 +0000 Subject: [PATCH] fix(hub): mail an attached file under a US-ASCII name The validator the hub runs on attachments lets a name through that the MIME assembler then refuses, since the name goes into a signed header with no encoding; a macOS screenshot name, with its narrow no-break space, failed the whole send with a 500. The material keeps the person's name; the mailed copy carries it made plain. --- apps/hub/src/artifacts/materials.test.ts | 15 +++++++++++++++ apps/hub/src/artifacts/materials.ts | 16 ++++++++++++++++ apps/hub/src/artifacts/service.ts | 7 ++++++- 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 apps/hub/src/artifacts/materials.test.ts diff --git a/apps/hub/src/artifacts/materials.test.ts b/apps/hub/src/artifacts/materials.test.ts new file mode 100644 index 00000000..9962dbbf --- /dev/null +++ b/apps/hub/src/artifacts/materials.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test"; +import { mailAttachmentName } from "./materials.ts"; + +test("a mailed attachment's name is the person's, made US-ASCII", () => { + // macOS names a screenshot with a narrow no-break space before AM. + expect( + mailAttachmentName("Screenshot 2026-11-03 at 8.51.17\u202fAM.png"), + ).toBe("Screenshot 2026-11-03 at 8.51.17 AM.png"); + expect(mailAttachmentName("r\u00e9sum\u00e9 \u2014 final.pdf")).toBe( + "resume final.pdf", + ); + expect(mailAttachmentName('say "hi".txt')).toBe("say 'hi'.txt"); + expect(mailAttachmentName("plain-name.png")).toBe("plain-name.png"); + expect(mailAttachmentName("\u5831\u544a.pdf")).toBe(".pdf"); +}); diff --git a/apps/hub/src/artifacts/materials.ts b/apps/hub/src/artifacts/materials.ts index bcc58315..b7c288b1 100644 --- a/apps/hub/src/artifacts/materials.ts +++ b/apps/hub/src/artifacts/materials.ts @@ -44,6 +44,22 @@ export function materialMimeType(file: { name: string; type: string }): string { return declared; } +// A mailed attachment's name sits in a signed MIME header with no encoding, so the assembler takes +// US-ASCII only; the validator the hub runs first does not check that. The material keeps the +// person's name; the mailed copy carries this one, which the coordinator still reads as the file. +const ASCII_SPACE_LIKE = /[\u00a0\u2000-\u200b\u202f\u205f\u3000]/g; + +/** The name a file is mailed under: the person's, with every non-ASCII character made plain. */ +export function mailAttachmentName(name: string): string { + const plain = name + .replace(ASCII_SPACE_LIKE, " ") + .normalize("NFKD") + .replace(/[^\x20-\x7e]/g, "") + .replace(/"/g, "'") + .trim(); + return plain === "" ? "attachment" : plain; +} + function utf8(bytes: Uint8Array): string | undefined { try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); diff --git a/apps/hub/src/artifacts/service.ts b/apps/hub/src/artifacts/service.ts index 3ad51b32..b69615b6 100644 --- a/apps/hub/src/artifacts/service.ts +++ b/apps/hub/src/artifacts/service.ts @@ -148,6 +148,7 @@ import { materialsNote } from "../config/materials.ts"; import { servingOffering } from "../workflows/install-for-tenant.ts"; import { acceptedMaterials, + mailAttachmentName, materialContent, materialMimeType, } from "./materials.ts"; @@ -1238,7 +1239,11 @@ export function createArtifactService( await mailRun( coordinator.runId, message.trim() || materialsNote(inputs.map((input) => input.name)), - inputs.map((input) => ({ ...input, data: base64Encode(input.data) })), + inputs.map((input) => ({ + name: mailAttachmentName(input.name), + mimeType: input.mimeType, + data: base64Encode(input.data), + })), ); return { materials }; },