diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts index 0458c27e..5f0fa31e 100644 --- a/server/src/services/notifications.ts +++ b/server/src/services/notifications.ts @@ -621,6 +621,15 @@ export function isNtfyConfiguredAdmin(): boolean { return !!(getAppSetting('admin_ntfy_topic')); } +function encodeHeaderValue(value: string): string { + for (let i = 0; i < value.length; i++) { + if (value.charCodeAt(i) > 0xFF) { + return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`; + } + } + return value; +} + export async function sendNtfy( url: string, token: string | null, @@ -638,11 +647,11 @@ export async function sendNtfy( // ntfy header-based API: POST to topic URL, body = plain text message, metadata in headers const headers: Record = { - 'Title': payload.title, + 'Title': encodeHeaderValue(payload.title), 'Priority': String(meta.priority), }; if (meta.tags.length > 0) headers['Tags'] = meta.tags.join(','); - if (payload.link) headers['Click'] = payload.link; + if (payload.link) headers['Click'] = encodeHeaderValue(payload.link); if (token) headers['Authorization'] = `Bearer ${token}`; try { diff --git a/server/tests/unit/services/notifications.test.ts b/server/tests/unit/services/notifications.test.ts index 6744dd62..42f8ef03 100644 --- a/server/tests/unit/services/notifications.test.ts +++ b/server/tests/unit/services/notifications.test.ts @@ -455,4 +455,27 @@ describe('sendNtfy', () => { expect(calledOpts.headers['Priority']).toBe('3'); expect(calledOpts.headers['Tags']).toBeUndefined(); // empty tags = no header }); + + it('NTFY-009 — title with non-Latin-1 chars is RFC 2047 encoded', async () => { + const mockFetch = globalThis.fetch as unknown as ReturnType; + mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never); + + await sendNtfy(ntfyUrl, null, { ...payload, title: 'Buy →€ ticket' }); + + 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('Buy →€ ticket'); + }); + + 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); + + await sendNtfy(ntfyUrl, null, { ...payload, title: 'Simple ASCII title' }); + + const [, calledOpts] = mockFetch.mock.calls[0]; + expect(calledOpts.headers['Title']).toBe('Simple ASCII title'); + }); });