From 3e77b91e989836fb46e502be3225d65dcc9f1ee2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 26 Sep 2026 17:44:44 -0700 Subject: [PATCH] fix: reply drafts honor Reply-To, add Re: and encode non-ASCII subjects Closes CL-9439 --- src/tools/create-tools.test.ts | 86 ++++++++++++++++++++++++++++++++++ src/tools/create-tools.ts | 23 +++++---- src/tools/drafts.ts | 44 +++++++++++++++-- src/tools/models.ts | 2 +- 4 files changed, 143 insertions(+), 12 deletions(-) diff --git a/src/tools/create-tools.test.ts b/src/tools/create-tools.test.ts index ad3ef63..f6ae99a 100644 --- a/src/tools/create-tools.test.ts +++ b/src/tools/create-tools.test.ts @@ -752,6 +752,92 @@ describe("createGmailTools", () => { ); }); + test("reply drafts address Reply-To, or the recipients of a sent message", async () => { + const original = (labelIds: string[]) => ({ + id: "message-1", + threadId: "thread-1", + labelIds, + payload: { + mimeType: "text/plain", + headers: [ + { name: "From", value: "List " }, + { name: "To", value: "Ada " }, + { + name: "Reply-To", + value: "Real , other@example.com", + }, + { name: "Subject", value: "Hello" }, + { name: "Message-ID", value: "" }, + ], + body: { data: encodeBase64Url("Original") }, + }, + }); + const headersFor = async (labelIds: string[], to?: string[]) => { + let raw = ""; + const tools = createGmailTools({ + capabilities: testCapabilities(async (input, init) => { + const url = new URL(String(input)); + if (url.pathname.endsWith("/messages/message-1")) { + return new Response(JSON.stringify(original(labelIds))); + } + raw = draftRawFromRequest(init); + return new Response(JSON.stringify({ id: "draft-1" })); + }), + }); + await tools.run( + { + id: "call-reply", + name: "gmail_create_draft", + arguments: { + replyToMessageId: "message-1", + body: "Thanks", + ...(to === undefined ? {} : { to }), + }, + }, + new AbortController().signal, + ); + await tools.dispose(); + return Buffer.from(raw, "base64url").toString().split("\r\n\r\n")[0]; + }; + + expect(await headersFor(["INBOX"])).toContain( + "To: real@example.com, other@example.com\r\nSubject: Re: Hello", + ); + expect(await headersFor(["INBOX"], [])).toContain( + "To: real@example.com, other@example.com", + ); + expect(await headersFor(["SENT"])).toContain("To: ada@example.com"); + }); + + test("keeps an existing Re: prefix on reply subjects", () => { + const raw = createRawDraft( + { body: "Reply" }, + { messageId: "", subject: "RE: Original" }, + ); + expect(Buffer.from(raw, "base64url").toString()).toContain( + "Subject: RE: Original\r\n", + ); + }); + + test("RFC 2047-encodes non-ASCII subjects", () => { + const subject = "Grüße – 日本 ".repeat(6); + const raw = Buffer.from( + createRawDraft({ to: ["ada@example.com"], subject, body: "x" }), + "base64url", + ).toString(); + const header = /^Subject: (.*?)\r\n(?! )/ms.exec(raw)?.[1] ?? ""; + const words = header.split("\r\n "); + expect(words.length).toBeGreaterThan(1); + for (const word of words) { + expect(word).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/); + expect(word.length).toBeLessThanOrEqual(75); + } + const decoded = words + .map((word) => Buffer.from(word.slice(10, -2), "base64").toString()) + .join(""); + expect(decoded).toBe(subject); + }); + test("applies and removes labels on messages and threads", async () => { const fetchImpl = createFetchImpl(async (input, init) => { const url = new URL(String(input)); diff --git a/src/tools/create-tools.ts b/src/tools/create-tools.ts index b9bbb55..108c615 100644 --- a/src/tools/create-tools.ts +++ b/src/tools/create-tools.ts @@ -18,6 +18,7 @@ import { import { GMAIL_CREDENTIAL_HANDLE, TOOL_DEFINITIONS } from "./definitions.js"; import { metadataHeadersForView, + recipients, toToolDraft, toThreadListMessage, toToolMessage, @@ -127,7 +128,11 @@ async function replyContext( const normalized = toToolMessage(original, "FULL_CONTENT"); const originalMessageId = headerValue(original, "Message-ID"); const originalReferences = headerValue(original, "References"); - const recipient = inputRecipients(original, normalized); + const recipient = inputRecipients( + original, + normalized, + headerValue(original, "Reply-To"), + ); return { ...(original.threadId === undefined ? {} : { threadId: original.threadId }), ...(originalMessageId === undefined @@ -152,14 +157,16 @@ async function replyContext( function inputRecipients( original: { labelIds?: readonly string[] }, normalized: ReturnType, + replyTo: string | undefined, ): readonly string[] | undefined { - const replyAddress = original.labelIds?.includes("SENT") - ? normalized.toRecipients?.[0] - : normalized.sender; - const recipient = plainEmailAddress(replyAddress); - return recipient === undefined || recipient.length === 0 - ? undefined - : [recipient]; + const addresses = original.labelIds?.includes("SENT") + ? normalized.toRecipients?.slice(0, 1) + : (recipients(replyTo) ?? [normalized.sender]); + const result = (addresses ?? []).flatMap((address) => { + const recipient = plainEmailAddress(address); + return recipient === undefined || recipient.length === 0 ? [] : [recipient]; + }); + return result.length === 0 ? undefined : result; } async function listDraftsForQuery( diff --git a/src/tools/drafts.ts b/src/tools/drafts.ts index 0819cbd..71528a3 100644 --- a/src/tools/drafts.ts +++ b/src/tools/drafts.ts @@ -34,6 +34,40 @@ function recipientHeader( return [`${name}: ${addresses.join(", ")}`]; } +function replySubject(subject: string): string { + return /^re:/i.test(subject) ? subject : `Re: ${subject}`; +} + +// RFC 2047 caps an encoded word at 75 characters; 45 bytes base64 to 60. +const MAX_ENCODED_WORD_BYTES = 45; + +function encodeSubject(subject: string): string { + if (/^[\x20-\x7e]*$/.test(subject)) return subject; + const encoder = new TextEncoder(); + const words: string[] = []; + let chunk = ""; + for (const character of subject) { + if ( + encoder.encode(chunk + character).length > MAX_ENCODED_WORD_BYTES && + chunk.length > 0 + ) { + words.push(chunk); + chunk = ""; + } + chunk += character; + } + words.push(chunk); + return words + .map((word) => { + let binary = ""; + for (const byte of encoder.encode(word)) { + binary += String.fromCharCode(byte); + } + return `=?UTF-8?B?${btoa(binary)}?=`; + }) + .join("\r\n "); +} + function escapeHtml(value: string): string { return value .replaceAll("&", "&") @@ -85,13 +119,17 @@ export function createRawDraft( input: DraftInput, reply?: ReplyContext, ): string { - const subject = input.subject ?? reply?.subject ?? ""; + const subject = + input.subject ?? + (reply?.subject === undefined ? "" : replySubject(reply.subject)); assertSafeHeader(subject, "subject"); + const to = + input.to === undefined || input.to.length === 0 ? reply?.to : input.to; const headers = [ - ...recipientHeader("To", input.to ?? reply?.to), + ...recipientHeader("To", to), ...recipientHeader("Cc", input.cc), ...recipientHeader("Bcc", input.bcc), - `Subject: ${subject}`, + `Subject: ${encodeSubject(subject)}`, "MIME-Version: 1.0", ]; if (reply?.messageId !== undefined) { diff --git a/src/tools/models.ts b/src/tools/models.ts index 7e991d6..4f6f8ad 100644 --- a/src/tools/models.ts +++ b/src/tools/models.ts @@ -101,7 +101,7 @@ function bodyForMimeType( return values.length === 0 ? undefined : values.join("\n\n"); } -function recipients(value: string | undefined): string[] | undefined { +export function recipients(value: string | undefined): string[] | undefined { if (value === undefined || value.length === 0) return undefined; const result: string[] = []; let start = 0;