From 229ec6d18e349d9d8cea77b428f7324839781cfc Mon Sep 17 00:00:00 2001 From: Konstantinos Thermos Date: Mon, 20 Jul 2026 13:32:55 +0200 Subject: [PATCH] fix(notifications): RFC 2047-encode ntfy header umlauts (#1621) --- server/src/services/notifications.ts | 2 +- server/tests/unit/services/notifications.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts index cd753b83b..0162ccf49 100644 --- a/server/src/services/notifications.ts +++ b/server/src/services/notifications.ts @@ -513,7 +513,7 @@ export function resolveAdminNtfyUrl(adminCfg: NtfyConfig): string | null { function encodeHeaderValue(value: string): string { for (let i = 0; i < value.length; i++) { - if (value.charCodeAt(i) > 0xff) { + if (value.charCodeAt(i) > 0x7f) { return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`; } } diff --git a/server/tests/unit/services/notifications.test.ts b/server/tests/unit/services/notifications.test.ts index 7abb985e4..72051a732 100644 --- a/server/tests/unit/services/notifications.test.ts +++ b/server/tests/unit/services/notifications.test.ts @@ -488,6 +488,19 @@ describe('sendNtfy', () => { expect(Buffer.from(b64, 'base64').toString('utf8')).toBe('Buy →€ ticket'); }); + it('NTFY-009b — title with Latin-1 umlauts is RFC 2047 encoded, not sent as raw bytes', async () => { + const mockFetch = globalThis.fetch as unknown as ReturnType; + mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never); + + await sendNtfy(ntfyUrl, null, { ...payload, title: 'Frühstück in Zürich' }); + + const [, calledOpts] = mockFetch.mock.calls[0]; + const encoded = calledOpts.headers['Title'] as string; + expect(encoded).toMatch(/^=\?UTF-8\?B\?/); + const b64 = encoded.replace(/^=\?UTF-8\?B\?/, '').replace(/\?=$/, ''); + expect(Buffer.from(b64, 'base64').toString('utf8')).toBe('Frühstück in Zürich'); + }); + it('NTFY-010 — ASCII-only title is passed through verbatim', async () => { const mockFetch = globalThis.fetch as unknown as ReturnType; mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);