fix(notifications): publish ntfy messages as JSON so non-ASCII labels need no header encoding

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 13:43:35 +02:00
1 parent bc91a542e2
commit 4ff9aa7e39
5 files changed
+206 -140

No files matched your search

+8 -1
View File
@@ -179,6 +179,12 @@ HealthLog publishes to that topic.
**Server URL** (defaults to `https://ntfy.sh`; point it at your own
server if you self-host ntfy) and **Topic**, then save.
HealthLog publishes each message as JSON to the server's root URL (the topic
travels in the body, see ntfy's
[publish as JSON](https://docs.ntfy.sh/publish/#publish-as-json)), so titles
and button labels in any language arrive as written. A reverse proxy in front
of your own ntfy has to pass `POST /` through, not only `POST /<topic>`.
If your ntfy server requires auth, an access token can be attached; the
server URL is validated at save time and dialled through the egress guard
(`src/lib/notifications/senders/ntfy.ts`). A self-hosted ntfy on your own
@@ -474,7 +480,8 @@ once per event type, and every channel carries the same target:
How each channel carries it:
- **ntfy**: the `Click` header, so tapping the notification opens the page.
- **ntfy**: the message's `click` field, so tapping the notification opens
the page.
- **Telegram**: an **Open in HealthLog** button under the message, below any
action buttons. Telegram only accepts `https` button links on a public
host, so a plain-HTTP instance gets a text link instead, and an instance
@@ -1,8 +1,8 @@
/**
* ntfy sender unit tests.
*
* Covers the outbound shape (topic appended to the server URL, priority and
* tag headers, bearer token), the outcome classification, and (#947) the
* Covers the outbound shape (a JSON publish to the server root carrying
* topic, priority and tags, bearer token), the outcome classification, and (#947) the
* NOTIFICATION_PRIVATE_ORIGINS grant: a listed server rides the
* operator-approved pin, an unlisted one keeps the public pin, and a listed
* origin admits nothing beside itself.
@@ -85,7 +85,7 @@ afterEach(() => {
});
describe("sendViaNtfy", () => {
it("POSTs the topic URL through safeFetch under the public pin and returns ok on 200", async () => {
it("POSTs a JSON message to the server root through safeFetch under the public pin and returns ok on 200", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
const result = await sendViaNtfy(config, payload());
@@ -96,10 +96,17 @@ describe("sendViaNtfy", () => {
{ method?: string; headers?: Record<string, string>; body?: string },
EgressOpts,
];
expect(url).toBe("https://ntfy.example.com/health");
expect(url).toBe("https://ntfy.example.com/");
expect(init.method).toBe("POST");
expect(init.headers?.Authorization).toBe("Bearer tk_secret");
expect(init.body).toBe("Body");
expect(init.headers?.["Content-Type"]).toBe("application/json");
expect(JSON.parse(init.body!)).toEqual({
topic: "health",
title: "Title",
message: "Body",
priority: 3,
tags: ["system-alert"],
});
expect(opts.requirePublicHost).toBe(true);
expect(opts.operatorApprovedPrivateOrigin).toBeUndefined();
expect(recordPushAttemptMock).toHaveBeenCalledWith(
@@ -164,7 +171,7 @@ describe("sendViaNtfy", () => {
unknown,
EgressOpts,
];
expect(url).toBe("http://ntfy.lan:8080/health");
expect(url).toBe("http://ntfy.lan:8080/");
expect(opts.requirePublicHost).toBe(false);
expect(opts.operatorApprovedPrivateOrigin).toBe("http://ntfy.lan:8080");
expect(annotateMock).toHaveBeenCalledWith({
@@ -270,15 +277,19 @@ describe("sendViaNtfy — what the server said (#947)", () => {
});
describe("sendViaNtfy — tap target and quick actions", () => {
function headersOfFirstCall(): Record<string, string> {
function messageOfFirstCall(): Record<string, unknown> {
const [, init] = safeFetchMock.mock.calls[0] as [
string,
{ headers: Record<string, string> },
{ headers: Record<string, string>; body: string },
];
return init.headers;
// Nothing of the message rides a header any more.
expect(Object.keys(init.headers).sort()).toEqual(
["Authorization", "Content-Type"].sort(),
);
return JSON.parse(init.body) as Record<string, unknown>;
}
it("sets Click to the resolved absolute tap URL", async () => {
it("sets click to the resolved absolute tap URL", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaNtfy(
config,
@@ -291,17 +302,18 @@ describe("sendViaNtfy — tap target and quick actions", () => {
},
}),
);
expect(headersOfFirstCall().Click).toBe("https://health.example/");
expect(headersOfFirstCall().Actions).toBeUndefined();
const message = messageOfFirstCall();
expect(message.click).toBe("https://health.example/");
expect(message.actions).toBeUndefined();
});
it("omits Click when no public origin produced a URL", async () => {
it("omits click when no public origin produced a URL", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaNtfy(
config,
payload({ tap: { path: "/", url: null, label: "Open" } }),
);
expect(headersOfFirstCall().Click).toBeUndefined();
expect(messageOfFirstCall().click).toBeUndefined();
});
it("sends the quick answers as three POST http actions that clear the notification", async () => {
@@ -317,18 +329,66 @@ describe("sendViaNtfy — tap target and quick actions", () => {
],
}),
);
expect(headersOfFirstCall().Actions).toBe(
"http, Good, https://h.example/a/1, method=POST, clear=true; " +
"http, Okay, https://h.example/a/2, method=POST, clear=true; " +
"http, Bad, https://h.example/a/3, method=POST, clear=true",
expect(messageOfFirstCall().actions).toEqual([
{
action: "http",
label: "Good",
url: "https://h.example/a/1",
method: "POST",
clear: true,
},
{
action: "http",
label: "Okay",
url: "https://h.example/a/2",
method: "POST",
clear: true,
},
{
action: "http",
label: "Bad",
url: "https://h.example/a/3",
method: "POST",
clear: true,
},
]);
});
it("carries non-ASCII titles and labels as plain UTF-8, URLs untouched", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaNtfy(
config,
payload({
eventType: "MOOD_REMINDER",
title: "기분은 어떠세요?",
quickActions: [
{ id: "mood-good", label: "Świetnie", url: "https://h.example/a/1" },
{ id: "mood-okay", label: "보통", url: "https://h.example/a/2" },
{ id: "mood-bad", label: "Così così", url: "https://h.example/a/3" },
],
}),
);
const message = messageOfFirstCall();
expect(message.title).toBe("기분은 어떠세요?");
const actions = message.actions as { label: string; url: string }[];
expect(actions.map((a) => a.label)).toEqual([
"Świetnie",
"보통",
"Così così",
]);
expect(actions.map((a) => a.url)).toEqual([
"https://h.example/a/1",
"https://h.example/a/2",
"https://h.example/a/3",
]);
expect(JSON.stringify(message)).not.toContain("=?UTF-8?");
});
});
describe("ntfy header helpers", () => {
it("caps the actions at three and strips separators from labels", async () => {
const { buildNtfyActionsHeader } = await import("../ntfy");
const header = buildNtfyActionsHeader({
describe("ntfy message helpers", () => {
it("caps the actions at three and keeps labels verbatim", async () => {
const { buildNtfyActions } = await import("../ntfy");
const actions = buildNtfyActions({
quickActions: [
{ id: "mood-good", label: 'Go;od, "yes"', url: "https://h/1" },
{ id: "mood-okay", label: "B", url: "https://h/2" },
@@ -336,20 +396,20 @@ describe("ntfy header helpers", () => {
{ id: "mood-bad", label: "D", url: "https://h/4" },
],
});
expect(header?.split("; ")).toHaveLength(3);
expect(header).toContain("http, Go od yes, https://h/1");
expect(header).not.toContain("https://h/4");
expect(buildNtfyActionsHeader({ quickActions: [] })).toBeNull();
expect(actions).toHaveLength(3);
// JSON needs no separator escaping: the label reaches ntfy as written.
expect(actions[0]!.label).toBe('Go;od, "yes"');
expect(actions.map((a) => a.url)).not.toContain("https://h/4");
expect(buildNtfyActions({ quickActions: [] })).toEqual([]);
});
it("RFC 2047-encodes a non-ASCII header value and leaves ASCII alone", async () => {
const { ntfyHeaderValue } = await import("../ntfy");
expect(ntfyHeaderValue("Morning score 78")).toBe("Morning score 78");
const encoded = ntfyHeaderValue("아침 점수 78");
expect(encoded).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/);
const inner = encoded.slice("=?UTF-8?B?".length, -2);
expect(Buffer.from(inner, "base64").toString("utf8")).toBe("아침 점수 78");
// `ü` is Latin-1, which fetch would send as one invalid UTF-8 byte.
expect(ntfyHeaderValue("Morgenüberblick")).toMatch(/^=\?UTF-8\?B\?/);
it("maps a medication reminder to high priority (4)", async () => {
const { buildNtfyMessage } = await import("../ntfy");
expect(
buildNtfyMessage(
"t",
payload({ eventType: "MEDICATION_REMINDER" }) as never,
).priority,
).toBe(4);
});
});
@@ -104,6 +104,15 @@ beforeEach(() => {
afterEach(() => vi.clearAllMocks());
/** The JSON message the ntfy sender published on call `i`. */
function ntfyMessage(i: number): {
priority: number;
tags: string[];
actions?: unknown;
} {
return JSON.parse((safeFetchMock.mock.calls[i][1] as { body: string }).body);
}
describe("ntfy urgent mapping", () => {
it("urgent → Priority 5 (max) + warning tag", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
@@ -111,10 +120,8 @@ describe("ntfy urgent mapping", () => {
{ serverUrl: "https://ntfy.example.com", topic: "t" },
payload({ urgent: true }),
);
const [, init] = safeFetchMock.mock.calls[0];
const headers = (init as { headers: Record<string, string> }).headers;
expect(headers.Priority).toBe("5");
expect(headers.Tags).toContain("warning");
expect(ntfyMessage(0).priority).toBe(5);
expect(ntfyMessage(0).tags).toContain("warning");
});
it("non-urgent SYSTEM_ALERT → default priority, no warning tag", async () => {
@@ -123,13 +130,8 @@ describe("ntfy urgent mapping", () => {
{ serverUrl: "https://ntfy.example.com", topic: "t" },
payload(),
);
const headers = (
safeFetchMock.mock.calls[0][1] as {
headers: Record<string, string>;
}
).headers;
expect(headers.Priority).toBe("default");
expect(headers.Tags).not.toContain("warning");
expect(ntfyMessage(0).priority).toBe(3);
expect(ntfyMessage(0).tags).not.toContain("warning");
});
it("discreet urgent keeps the generic tag (no warning leak)", async () => {
@@ -138,16 +140,11 @@ describe("ntfy urgent mapping", () => {
{ serverUrl: "https://ntfy.example.com", topic: "t" },
payload({ urgent: true, discreet: true }),
);
const headers = (
safeFetchMock.mock.calls[0][1] as {
headers: Record<string, string>;
}
).headers;
expect(headers.Priority).toBe("5");
expect(headers.Tags).toBe("reminder");
expect(ntfyMessage(0).priority).toBe(5);
expect(ntfyMessage(0).tags).toEqual(["reminder"]);
});
it("does not send ntfy action headers to a managed Guardian", async () => {
it("does not send ntfy actions to a managed Guardian", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaNtfy(
{ serverUrl: "https://ntfy.example.com", topic: "t" },
@@ -157,12 +154,7 @@ describe("ntfy urgent mapping", () => {
recipientUserId: "guardian-1",
}),
);
const headers = (
safeFetchMock.mock.calls[0][1] as {
headers: Record<string, string>;
}
).headers;
expect(headers.Actions).toBeUndefined();
expect(ntfyMessage(0).actions).toBeUndefined();
});
});
@@ -245,10 +237,7 @@ describe("APNs-less instance degrades gracefully", () => {
expect(ntfy.ok).toBe(true);
expect(web.ok).toBe(true);
expect(
(safeFetchMock.mock.calls[0][1] as { headers: Record<string, string> })
.headers.Priority,
).toBe("5");
expect(ntfyMessage(0).priority).toBe(5);
expect(
(generateRequestDetailsMock.mock.calls[0][2] as { urgency?: string })
.urgency,
+84 -74
View File
@@ -41,48 +41,24 @@ export async function sendViaNtfy(
// can open.
let policyReason: OriginReason | null = null;
try {
const url = `${config.serverUrl.replace(/\/$/, "")}/${encodeURIComponent(config.topic)}`;
// v1.18.4 — an explicitly urgent payload escalates to ntfy's top
// priority (`5` / max) so it bypasses the relay's batching and surfaces
// loudest; MEDICATION_REMINDER keeps its long-standing `high` (4); the
// rest stay `default` (3). `urgent` also adds a tag so the lock-screen
// entry reads as an alert (unless discreet privacy collapses tags).
const urgent = isUrgentPayload(payload) && payload.urgent === true;
const priority = urgent
? "5"
: payload.eventType === "MEDICATION_REMINDER"
? "high"
: "default";
const eventTag = payload.discreet
? "reminder"
: payload.eventType.toLowerCase().replace(/_/g, "-");
// Published as JSON to the server root rather than as a body with
// headers to the topic URL. A header carries only Latin-1 (`fetch`
// refuses anything above U+00FF, and ntfy reads a Latin-1 byte as invalid
// UTF-8), so a Korean title or a Polish action label had to travel as an
// RFC 2047 encoded word, and an `Actions` header encoded whole, URLs and
// all, is not a form ntfy's action parser is documented to accept, so a
// reminder with an Italian, Korean or Polish label risked a 400 and never
// arrived. A JSON body is UTF-8 end to end and has no header encoding
// at all (https://docs.ntfy.sh/publish/#publish-as-json).
const url = `${config.serverUrl.replace(/\/$/, "")}/`;
const message = buildNtfyMessage(config.topic, payload);
const headers: Record<string, string> = {
Title: ntfyHeaderValue(plainPushText(payload.title, payload.eventType)),
Priority: priority,
// Discreet mode (cycle privacy): the X-Tags header is visible on the
// lock screen, so collapse it to a generic tag instead of leaking the
// cycle event name. An urgent (non-discreet) event also carries a
// `warning` tag so the entry renders as an alert.
Tags: urgent && !payload.discreet ? `warning,${eventTag}` : eventTag,
"Content-Type": "application/json",
};
if (config.authToken) {
headers["Authorization"] = `Bearer ${config.authToken}`;
}
// Tapping the notification opens this URL (ntfy `Click`). Absent when
// the operator has not configured a public origin.
if (payload.tap?.url) {
headers["Click"] = payload.tap.url;
}
const actions = buildNtfyActionsHeader(payload);
if (actions) {
headers["Actions"] = ntfyHeaderValue(actions);
}
// Strip HTML tags for ntfy (plain text only) + emoji on routine reminders
const body = plainPushText(stripHtml(payload.message), payload.eventType);
const body = JSON.stringify(message);
// The DNS-rebinding pin (issue #217) sits on top of the input-time
// guard enforced when the user saved the serverUrl; the authToken would
@@ -201,47 +177,81 @@ export async function sendViaNtfy(
/** ntfy shows at most three actions per notification. */
const NTFY_MAX_ACTIONS = 3;
/**
* A label safe inside the `Actions` header's short format: `,` and `;`
* separate fields and actions there, and a quote would open a quoted value.
*/
function ntfyActionLabel(label: string): string {
return label.replace(/[,;"'\r\n]/g, " ").trim() || "?";
/** One `http` action button in ntfy's JSON form. */
export interface NtfyHttpAction {
action: "http";
label: string;
url: string;
method: "POST";
clear: true;
}
/** The JSON publish body (https://docs.ntfy.sh/publish/#publish-as-json). */
export interface NtfyMessage {
topic: string;
title: string;
message: string;
priority: 3 | 4 | 5;
tags: string[];
click?: string;
actions?: NtfyHttpAction[];
}
/**
* The `Actions` header for the payload's quick answers, in ntfy's short
* format (https://docs.ntfy.sh/publish/#action-buttons):
* The message ntfy publishes for a payload.
*
* http, <label>, <url>, method=POST, clear=true; …
*
* `clear=true` dismisses the notification once the request succeeded, so a
* reminder answered from the shade does not linger. The token in the URL is
* the request's only credential (see `action-token.ts`).
* - Priority: an explicitly urgent payload escalates to ntfy's top priority
* (5, max) so it bypasses the relay's batching and surfaces loudest;
* MEDICATION_REMINDER keeps its long-standing high (4); the rest stay at
* the default (3).
* - Tags: discreet mode (cycle privacy) collapses them to a generic tag,
* because tags are visible on the lock screen and would name the cycle
* event. An urgent, non-discreet event also carries `warning` so the entry
* renders as an alert.
* - Click: tapping the notification opens the resolved tap URL; absent when
* the operator configured no public origin.
* - Actions: the payload's quick answers, at most three, as `http` POST
* buttons with `clear` so an answered reminder leaves the shade. The token
* in each URL is the request's only credential (see `action-token.ts`).
*/
export function buildNtfyActionsHeader(
export function buildNtfyMessage(
topic: string,
payload: NotificationPayload,
): NtfyMessage {
const urgent = isUrgentPayload(payload) && payload.urgent === true;
const priority = urgent
? 5
: payload.eventType === "MEDICATION_REMINDER"
? 4
: 3;
const eventTag = payload.discreet
? "reminder"
: payload.eventType.toLowerCase().replace(/_/g, "-");
const message: NtfyMessage = {
topic,
title: plainPushText(payload.title, payload.eventType),
// Plain text only, and no emoji on routine reminders.
message: plainPushText(stripHtml(payload.message), payload.eventType),
priority,
tags: urgent && !payload.discreet ? ["warning", eventTag] : [eventTag],
};
if (payload.tap?.url) message.click = payload.tap.url;
const actions = buildNtfyActions(payload);
if (actions.length > 0) message.actions = actions;
return message;
}
/** The payload's quick answers as ntfy `http` actions, at most three. */
export function buildNtfyActions(
payload: Pick<NotificationPayload, "quickActions">,
): string | null {
const actions = (payload.quickActions ?? []).slice(0, NTFY_MAX_ACTIONS);
if (actions.length === 0) return null;
return actions
.map(
(action) =>
`http, ${ntfyActionLabel(action.label)}, ${action.url}, method=POST, clear=true`,
)
.join("; ");
}
/**
* A header value fetch can send and ntfy reads as UTF-8.
*
* `fetch` refuses any header character above U+00FF outright (a Korean
* title or action label would fail the whole send), and a Latin-1 byte such
* as `ü` reaches ntfy as invalid UTF-8. ntfy decodes RFC 2047 encoded words
* in every header, so a value with anything beyond printable ASCII travels as
* one `=?UTF-8?B?…?=` word; plain ASCII stays readable on the wire.
*/
export function ntfyHeaderValue(value: string): string {
if (/^[\x20-\x7e]*$/.test(value)) return value;
return `=?UTF-8?B?${Buffer.from(value, "utf8").toString("base64")}?=`;
): NtfyHttpAction[] {
return (payload.quickActions ?? [])
.slice(0, NTFY_MAX_ACTIONS)
.map((action) => ({
action: "http",
label: action.label.trim() || "?",
url: action.url,
method: "POST",
clear: true,
}));
}
+1 -1
View File
@@ -5,7 +5,7 @@
* the dispatcher resolves the path once per delivery and every channel
* carries the same target in its own vocabulary:
*
* - ntfy → `Click` header (absolute URL)
* - ntfy → `click` in the JSON publish body (absolute URL)
* - Telegram → an inline URL button (absolute `https` URL only, see
* `telegramLinkMode`)
* - Web Push → `url` in the service-worker payload (same-origin path)