From 3d9691b11a786aaf9ef1f83ef539cbbbf68f6a81 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 11 Aug 2026 18:30:29 -0700 Subject: [PATCH] fix: stop HTML-escaping email subject lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subjects were compiled with default Handlebars escaping, so the app-user-feedback subject rendered a title like "Bob's App & Games" as "Bob's App & Games" — literal entities in the recipient's mail client. Subjects are plain-text headers, not HTML; compile them with noEscape. Header safety is unaffected: the transport encodes newlines and free-form values collapse whitespace upstream. --- src/backend/clients/email/EmailClient.test.ts | 23 +++++++++++++++++++ src/backend/clients/email/EmailClient.ts | 9 +++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/backend/clients/email/EmailClient.test.ts b/src/backend/clients/email/EmailClient.test.ts index 340c31d91..a409f6e75 100644 --- a/src/backend/clients/email/EmailClient.test.ts +++ b/src/backend/clients/email/EmailClient.test.ts @@ -179,6 +179,29 @@ describe('EmailClient — template rendering', () => { expect(captured.html).toContain('424242'); }); + it('does not HTML-escape values in the plain-text subject header', async () => { + const client = startClient(); + const captured: { subject?: string } = {}; + vi.spyOn(client, 'sendRaw').mockImplementation(async (options) => { + captured.subject = options.subject; + return null; + }); + + await client.send('dev@example.test', 'app-user-feedback', { + owner_username: 'dev', + sender_username: 'user', + sender_email: null, + app_title: "Bob's App & Games", + app_name: 'bobs-app', + app_link: 'https://puter.example/app/bobs-app', + message: 'hi', + }); + + // A subject is not HTML — entities would render literally in the + // recipient's mail client. + expect(captured.subject).toBe("New user feedback for Bob's App & Games"); + }); + it('escapes html and converts newlines in nl2br values', async () => { const client = startClient(); let html = ''; diff --git a/src/backend/clients/email/EmailClient.ts b/src/backend/clients/email/EmailClient.ts index 6548e0652..e12318976 100644 --- a/src/backend/clients/email/EmailClient.ts +++ b/src/backend/clients/email/EmailClient.ts @@ -291,7 +291,14 @@ export class EmailClient extends PuterClient { private compileTemplates(): void { for (const [name, template] of Object.entries(EMAIL_TEMPLATES)) { this.compiledTemplates[name as EmailTemplateName] = { - subject: handlebars.compile(template.subject), + // Subjects are plain-text headers: HTML-escaping would put + // literal entities in front of the recipient (& etc.). + // Header safety is handled elsewhere — the transport encodes + // newlines, and free-form values (e.g. app_title) collapse + // whitespace upstream. + subject: handlebars.compile(template.subject, { + noEscape: true, + }), html: handlebars.compile(dedent(template.html)), }; }