diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index b06ac7b19..ebf46edd7 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -271,6 +271,13 @@ describe("S2 — the URL-token authentication set is frozen", () => { // renders nothing about the profile before the preview answers. "app/auth/claim/page.tsx": "carries the handover token from the query string into the claim requests' body", + // v1.42.2 — notification quick answers. A notification app (ntfy's + // `http` action) calls it with no session, so the signed, user- and + // action-bound, short-lived token in the path is the credential. Rate + // limited before verification, single-use through the mood entry's unique + // key, and scrubbed from logs by `PATH_SECRET_PATHS`. + "app/api/notifications/action/[token]/route.ts": + "signed single-use quick-answer token from a notification action", }; /** @@ -304,6 +311,7 @@ describe("S2 — the URL-token authentication set is frozen", () => { expect(bySegment.length).toBeGreaterThan(0); expect(bySegment).toEqual([ "app/api/c/[token]/unlock/route.ts", + "app/api/notifications/action/[token]/route.ts", "app/api/whoop/webhook/[token]/route.ts", "app/api/withings/webhook/[token]/route.ts", "app/c/[token]/d/[id]/route.ts", diff --git a/src/app/api/notifications/action/[token]/route.ts b/src/app/api/notifications/action/[token]/route.ts new file mode 100644 index 000000000..bd6e22f0f --- /dev/null +++ b/src/app/api/notifications/action/[token]/route.ts @@ -0,0 +1,152 @@ +/** + * `POST /api/notifications/action/` — a quick answer tapped inside a + * notification. + * + * The caller is a notification app (ntfy's `http` action), not a browser + * with a session, so the signed path segment is the whole credential. What + * the token proves and how it is kept single-use is in + * `src/lib/notifications/action-token.ts`; this route only applies it: + * + * 1. Rate-limit per client address BEFORE looking at the token, so the + * route is no oracle for guessing one (the anonymous-surface limiter + * collapses a broken proxy trust chain into one tight bucket). + * 2. Verify the signature (constant-time) and the expiry. + * 3. Re-check what may have changed since the reminder went out: the + * account still exists and its mood module is still on. + * 4. Write the mood entry under the reminder's nonce. The unique + * `(userId, source, externalId)` key makes the first answer the only + * one: a repeat tap, or a different answer on the same reminder, is + * refused with 409 and writes nothing. + * + * The token travels in the path, so it is scrubbed from every log line by + * the `PATH_SECRET_PATHS` entry for this prefix, and the route sits on the + * URL-credential inventory in `session-surface-guard.test.ts`. + */ +import { NextRequest } from "next/server"; + +import { Prisma } from "@/generated/prisma/client"; +import { apiHandler } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { logTelegramMood } from "@/lib/mood/create-from-telegram"; +import { + MOOD_QUICK_ACTIONS, + quickActionExternalId, + verifyActionToken, +} from "@/lib/notifications/action-token"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; + +export const dynamic = "force-dynamic"; + +/** Generous for one person tapping, tight for anyone enumerating. */ +const RATE_LIMIT = 20; +const RATE_WINDOW_MS = 10 * 60 * 1000; + +interface RouteContext { + params: Promise<{ token: string }>; +} + +function isUniqueViolation(err: unknown): boolean { + return ( + err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002" + ); +} + +export const POST = apiHandler( + async (request: NextRequest, context: RouteContext) => { + annotate({ action: { name: "notification.action.invoke" } }); + + const rl = await checkAuthSurfaceRateLimit( + request, + "notification-action", + RATE_LIMIT, + RATE_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { token } = await context.params; + const verdict = verifyActionToken(token); + if (!verdict.ok) { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: verdict.reason }, + }); + return verdict.reason === "expired" + ? apiError("This answer has expired", 410, { + errorCode: "notification_action.expired", + }) + : apiError("Invalid action", 401, { + errorCode: "notification_action.invalid", + }); + } + + const user = await prisma.user.findUnique({ + where: { id: verdict.userId }, + select: { id: true, timezone: true }, + }); + if (!user) { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: "unknown_user" }, + }); + return apiError("Invalid action", 401, { + errorCode: "notification_action.invalid", + }); + } + getEvent()?.setAuth({ + user_id: user.id, + auth_method: "notification_action_token", + }); + + if (!(await isModuleEnabled(user.id, "mood"))) { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: "module_disabled" }, + }); + return apiError("Mood tracking is turned off", 403, { + errorCode: "notification_action.module_disabled", + }); + } + + const score = MOOD_QUICK_ACTIONS[verdict.action]; + let created: boolean; + try { + const result = await logTelegramMood({ + userId: user.id, + score, + tz: user.timezone, + externalId: quickActionExternalId(verdict.nonce), + source: "NOTIFICATION", + }); + created = result.created; + } catch (err) { + // Two taps raced past the existence check; the unique key let one in. + if (!isUniqueViolation(err)) throw err; + created = false; + } + + if (!created) { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: "already_used" }, + }); + return apiError("This reminder was already answered", 409, { + errorCode: "notification_action.already_used", + }); + } + + annotate({ + action: { name: "notification.action.mood_logged" }, + meta: { action: verdict.action }, + }); + return apiSuccess({ logged: true, action: verdict.action }); + }, +); diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index d97fe4327..03602aad7 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -1395,6 +1395,8 @@ function formatMoodSource( return t("mood.sourceTelegram"); case "DAYLIO": return t("mood.sourceDaylio"); + case "NOTIFICATION": + return t("mood.sourceNotification"); default: return source; } diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts index ef4bbf2ef..84f9ce9df 100644 --- a/src/lib/logging/redact.ts +++ b/src/lib/logging/redact.ts @@ -68,6 +68,10 @@ export const PATH_SECRET_PATHS: ReadonlyArray<{ // and its last segment is a route name. A full URL carrying the token is // still scrubbed, by the `hlp_` rule in `redactSecrets`. { prefix: "/claim/", pathStart: true }, + // v1.42.2 — notification quick answers (ntfy `http` actions). The signed + // single-use action token is the request's only credential and rides the + // trailing path segment of `/api/notifications/action/`. + { prefix: "/api/notifications/action/" }, ]; function redactPathSegments(input: string): string { diff --git a/src/lib/logging/types.ts b/src/lib/logging/types.ts index 8a5119cdb..086357230 100644 --- a/src/lib/logging/types.ts +++ b/src/lib/logging/types.ts @@ -59,7 +59,8 @@ export interface WideEvent { | "api_key" | "cron_secret" | "telegram_webhook" - | "webhook_secret"; + | "webhook_secret" + | "notification_action_token"; /** * v1.36.0 — the account whose record this request read or wrote, when it * is not the caller's own. `user_id` above always stays the ACTOR: the diff --git a/src/lib/mood/create-from-telegram.ts b/src/lib/mood/create-from-telegram.ts index e54e527eb..298ac79d4 100644 --- a/src/lib/mood/create-from-telegram.ts +++ b/src/lib/mood/create-from-telegram.ts @@ -7,8 +7,9 @@ * write so the two entry points agree on the canonical shape: the same * `getScoreForMood` / `moodDateKey` derivation, the same per-user `tz` * anchoring, the same cache invalidation + DAY-rollup recompute the route - * fires. `source` is pinned to "TELEGRAM" - * (already an accepted free-text source value, no enum migration needed). + * fires. `source` is "TELEGRAM" by default; a notification quick answer + * (`/api/notifications/action/`) writes the same shape under + * "NOTIFICATION". Both are free-text source values, no enum migration. * * The `userId` is NEVER taken from the Telegram payload — the caller * passes the id it resolved from the stored `telegramChatId` binding. @@ -25,6 +26,9 @@ import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { getEvent } from "@/lib/logging/context"; +/** The quick-write sources that share this helper. */ +export type QuickMoodSource = "TELEGRAM" | "NOTIFICATION"; + export interface TelegramMoodResult { /** True when a new row was written; false when the idempotency key already had one. */ created: boolean; @@ -47,8 +51,11 @@ export async function logTelegramMood(input: { /** Stable per-tap id for Telegram redelivery dedup. */ externalId: string; note?: string | null; + /** Provenance of the quick write. Default "TELEGRAM". */ + source?: QuickMoodSource; client?: PrismaClient | Prisma.TransactionClient; }): Promise { + const source = input.source ?? "TELEGRAM"; const prisma = (input.client ?? defaultPrisma) as PrismaClient; const mood = MOOD_ENUM_BY_SCORE[input.score]; if (!mood) { @@ -63,7 +70,7 @@ export async function logTelegramMood(input: { where: { userId_source_externalId: { userId: input.userId, - source: "TELEGRAM", + source, externalId: input.externalId, }, }, @@ -88,7 +95,7 @@ export async function logTelegramMood(input: { // v1.23 — encrypt the note at rest; legacy plaintext column nulled. note: null, noteEncrypted: encryptNote(input.note ?? null), - source: "TELEGRAM", + source, externalId: input.externalId, moodLoggedAt, }, diff --git a/src/lib/notifications/action-token.ts b/src/lib/notifications/action-token.ts new file mode 100644 index 000000000..9870d248c --- /dev/null +++ b/src/lib/notifications/action-token.ts @@ -0,0 +1,197 @@ +/** + * Signed, short-lived, single-use tokens behind notification quick actions. + * + * A quick action is a button inside a notification ("Good", "Okay", "Bad" + * on the mood reminder) that the notification app calls directly, with no + * HealthLog session: ntfy's `http` action POSTs to a URL and nothing else. + * The URL therefore carries the whole credential, and this module is what + * makes that safe: + * + * - Bound. The token names the user and the one action it performs. A + * token minted for one user cannot act for another (the user id is inside + * the signed payload), and a token for "okay" cannot log "bad". + * - Signed. HMAC-SHA256 over the payload, under a subkey derived from + * `API_TOKEN_HMAC_KEY` with a fixed label, so the tag can never collide + * with an OAuth state or a Bearer-token hash made from the same root key. + * Compared with `timingSafeEqual`. + * - Short-lived. The expiry is inside the signed payload; a token is good + * for `ACTION_TOKEN_TTL_MS` after the reminder was sent. + * - Single-use. The three answers of one reminder share one nonce, and the + * write it authorises is keyed on that nonce (`MoodEntry`'s unique + * `(userId, source, externalId)`), so the first answer wins and every + * later tap on the same reminder, whichever button, is refused. The + * database constraint is the arbiter, so two concurrent taps cannot both + * write. + * + * Layout: `na1.....`. Every field is + * free of `.` by construction (a cuid, a fixed action id, digits, base64url), + * so the split is unambiguous, and every field is URL-path safe. + */ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +/** + * The mood quick answers and the five-point mood scale value each one logs + * (`MOOD_ENUM_BY_SCORE`: 4 = GUT, 3 = OKAY, 2 = SCHLECHT). Three answers, + * because ntfy shows at most three actions; the scale's outer points + * (1 and 5) stay in the app and in the Telegram keyboard, which shows all + * five. + */ +export const MOOD_QUICK_ACTIONS = { + "mood-good": 4, + "mood-okay": 3, + "mood-bad": 2, +} as const; + +export type QuickActionId = keyof typeof MOOD_QUICK_ACTIONS; + +export const QUICK_ACTION_IDS = Object.keys( + MOOD_QUICK_ACTIONS, +) as QuickActionId[]; + +/** + * Six hours: long enough to answer a reminder sent at the chosen evening + * hour before going to bed, short enough that a notification left on a + * phone overnight no longer writes anything the next day. + */ +export const ACTION_TOKEN_TTL_MS = 6 * 60 * 60 * 1000; + +const VERSION = "na1"; +const SEP = "."; +const MAX_TOKEN_LENGTH = 300; +const KEY_LABEL = "healthlog:notification-action:v1"; + +function signingKey(): Buffer { + const root = process.env.API_TOKEN_HMAC_KEY; + if (!root || root.length < 32) { + throw new Error( + "API_TOKEN_HMAC_KEY must be set (>=32 chars) to sign notification actions", + ); + } + return createHmac("sha256", root).update(KEY_LABEL).digest(); +} + +function sign(payload: string): string { + return createHmac("sha256", signingKey()).update(payload).digest("base64url"); +} + +function isQuickActionId(value: string): value is QuickActionId { + return Object.prototype.hasOwnProperty.call(MOOD_QUICK_ACTIONS, value); +} + +/** A fresh nonce shared by every action offered on one notification. */ +export function newActionNonce(): string { + return randomBytes(16).toString("base64url"); +} + +export function mintActionToken(input: { + userId: string; + action: QuickActionId; + nonce: string; + now?: number; +}): string { + const expiry = (input.now ?? Date.now()) + ACTION_TOKEN_TTL_MS; + const payload = [ + VERSION, + input.userId, + input.action, + String(expiry), + input.nonce, + ].join(SEP); + return `${payload}${SEP}${sign(payload)}`; +} + +export type ActionTokenVerdict = + | { + ok: true; + userId: string; + action: QuickActionId; + nonce: string; + expiry: number; + } + | { ok: false; reason: "malformed" | "bad_signature" | "expired" }; + +/** + * Verify a token. The signature is checked before the expiry, so an expired + * verdict is only ever given for a token this server actually minted; a + * forged or altered token reads as `bad_signature` whatever its expiry says. + * Never throws on bad input (only on a missing server key, which is an + * operator misconfiguration). + */ +export function verifyActionToken( + token: string | null | undefined, + now: number = Date.now(), +): ActionTokenVerdict { + if (!token || token.length > MAX_TOKEN_LENGTH) { + return { ok: false, reason: "malformed" }; + } + const parts = token.split(SEP); + if (parts.length !== 6) return { ok: false, reason: "malformed" }; + const [version, userId, action, expiryStr, nonce, tag] = parts as [ + string, + string, + string, + string, + string, + string, + ]; + if (version !== VERSION || !userId || !nonce || !tag) { + return { ok: false, reason: "malformed" }; + } + if (!isQuickActionId(action)) return { ok: false, reason: "malformed" }; + const expiry = Number.parseInt(expiryStr, 10); + if (!Number.isFinite(expiry) || String(expiry) !== expiryStr) { + return { ok: false, reason: "malformed" }; + } + + const expected = Buffer.from( + sign([version, userId, action, expiryStr, nonce].join(SEP)), + ); + const received = Buffer.from(tag); + if ( + received.length !== expected.length || + !timingSafeEqual(received, expected) + ) { + return { ok: false, reason: "bad_signature" }; + } + if (now >= expiry) return { ok: false, reason: "expired" }; + + return { ok: true, userId, action, nonce, expiry }; +} + +/** The `MoodEntry.externalId` that makes one reminder's answers single-use. */ +export function quickActionExternalId(nonce: string): string { + return `notification-action:${nonce}`; +} + +/** The public route a quick action calls. */ +export const QUICK_ACTION_ROUTE_PREFIX = "/api/notifications/action/"; + +export interface NotificationQuickAction { + id: QuickActionId; + label: string; + /** Absolute URL the notification app POSTs to. */ + url: string; +} + +/** + * The three mood answers for one reminder, sharing one nonce. Needs the + * public origin: an app on a phone can only call an absolute URL. + */ +export function buildMoodQuickActions(input: { + userId: string; + origin: string; + labels: Record; + now?: number; +}): NotificationQuickAction[] { + const nonce = newActionNonce(); + return QUICK_ACTION_IDS.map((id) => ({ + id, + label: input.labels[id], + url: `${input.origin}${QUICK_ACTION_ROUTE_PREFIX}${mintActionToken({ + userId: input.userId, + action: id, + nonce, + now: input.now, + })}`, + })); +} diff --git a/src/lib/notifications/delivery-links.ts b/src/lib/notifications/delivery-links.ts new file mode 100644 index 000000000..fb955d4c8 --- /dev/null +++ b/src/lib/notifications/delivery-links.ts @@ -0,0 +1,105 @@ +/** + * Attach the tap target and the quick actions to a delivery. + * + * Called by the dispatcher once per recipient, after the delivery identity + * is known and before the channel cascade, so every channel of one dispatch + * carries the same target and the same (single-use) action tokens. The + * senders only translate these fields into their own wire format; none of + * them decides where a notification leads. + */ +import { prisma } from "@/lib/db"; +import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { getEvent } from "@/lib/logging/context"; +import { + buildMoodQuickActions, + type QuickActionId, +} from "@/lib/notifications/action-token"; +import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery"; +import { + absoluteAppUrl, + configuredAppOrigin, + resolveTapPath, +} from "@/lib/notifications/tap-target"; +import type { NotificationPayload } from "@/lib/notifications/types"; + +const QUICK_ACTION_LABEL_KEYS: Record = { + "mood-good": "notifications.quickActions.moodGood", + "mood-okay": "notifications.quickActions.moodOkay", + "mood-bad": "notifications.quickActions.moodBad", +}; + +async function recipientLocale(userId: string): Promise { + try { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + const value = row?.locale; + if (value && (locales as readonly string[]).includes(value)) { + return value as Locale; + } + } catch { + // A failed read only costs the label its language. + } + return defaultLocale; +} + +/** + * Whether this delivery may offer the mood quick answers: a mood reminder + * delivered to the account it is about. A Guardian never gets a button that + * writes into a record they are only notified about. + */ +function offersMoodActions(payload: NotificationPayload): boolean { + return ( + payload.eventType === "MOOD_REMINDER" && + !payload.suppressActions && + !payload.discreet && + !isManagedGuardianDelivery(payload) + ); +} + +export async function withDeliveryLinks( + payload: NotificationPayload, + recipientUserId: string, + now: number = Date.now(), +): Promise { + const path = resolveTapPath(payload); + const moodActions = offersMoodActions(payload); + if (!path && !moodActions) return payload; + + const origin = configuredAppOrigin(); + const { t } = getServerTranslator(await recipientLocale(recipientUserId)); + + const next: NotificationPayload = { ...payload }; + if (path) { + next.tap = { + path, + url: absoluteAppUrl(path, origin), + label: t("notifications.links.open"), + }; + } + + if (moodActions && origin) { + try { + next.quickActions = buildMoodQuickActions({ + userId: recipientUserId, + origin, + labels: { + "mood-good": t(QUICK_ACTION_LABEL_KEYS["mood-good"]), + "mood-okay": t(QUICK_ACTION_LABEL_KEYS["mood-okay"]), + "mood-bad": t(QUICK_ACTION_LABEL_KEYS["mood-bad"]), + }, + now, + }); + } catch (err) { + // No signing key: the reminder still goes out, only without answers. + getEvent()?.addWarning( + `notification quick actions unavailable: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + return next; +} diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index e8ccc3f25..9edf7ad71 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -36,6 +36,7 @@ import { hasClientManagedApnsGate, } from "@/lib/notifications/client-managed-apns"; import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; +import { withDeliveryLinks } from "@/lib/notifications/delivery-links"; import { claimManagedGuardianEgressAuthorization, completeManagedGuardianEgressAuthorization, @@ -131,8 +132,10 @@ export async function dispatchNotification( recordUserId: delivery.recordUserId, recipientUserId: delivery.recipientUserId, }; - const recipientPayload = await renderForRecipient( - deliveryPayload, + // Tap target + quick actions, resolved once so every channel of this + // delivery carries the same target and the same single-use tokens. + const recipientPayload = await withDeliveryLinks( + await renderForRecipient(deliveryPayload, delivery.recipientUserId), delivery.recipientUserId, ); const channels = await prisma.notificationChannel.findMany({ diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index 118195217..8fb2e9ac3 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -33,6 +33,7 @@ import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-at import { plainPushText } from "@/lib/notifications/strip-emoji"; import { stripHtml } from "@/lib/notifications/strip-html"; import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery"; +import type { NotificationTap } from "@/lib/notifications/types"; export interface ApnsPayload { /** APNs `aps.alert.title` + `aps.alert.body`. */ @@ -597,6 +598,7 @@ export async function sendViaApns( suppressActions?: boolean; recordUserId?: string; recipientUserId?: string; + tap?: NotificationTap; }, ): Promise { const config = loadApnsConfig(); @@ -751,6 +753,13 @@ export async function sendViaApns( ? stripCycleMetadata(payload.metadata) : payload.metadata, ), + // The resolved tap target (`tap-target.ts`): a same-origin path + // the app opens on tap. Overrides a producer's `metadata.url`, + // which the resolver already took into account. Never on a + // managed delivery or a discreet one (the resolver returns null). + ...(!managedDelivery && payload.tap?.path + ? { url: payload.tap.path } + : {}), }, threadId: routingEvent, // The iOS app registers a `UNNotificationCategory` per event-type diff --git a/src/lib/notifications/senders/email.ts b/src/lib/notifications/senders/email.ts index 22011e45e..703edfc4f 100644 --- a/src/lib/notifications/senders/email.ts +++ b/src/lib/notifications/senders/email.ts @@ -163,7 +163,11 @@ export async function sendViaEmail( // Plain text only — strip HTML + decorative emoji on routine reminders. const subject = plainPushText(payload.title, payload.eventType); - const text = plainPushText(stripHtml(payload.message), payload.eventType); + const body = plainPushText(stripHtml(payload.message), payload.eventType); + // The tap target as a plain link line (plain text body, no markup). + const text = payload.tap?.url + ? `${body}\n\n${payload.tap.label}: ${payload.tap.url}` + : body; try { await transport.transporter.sendMail({ diff --git a/src/lib/notifications/senders/ntfy.ts b/src/lib/notifications/senders/ntfy.ts index a2fe63401..13b1fab78 100644 --- a/src/lib/notifications/senders/ntfy.ts +++ b/src/lib/notifications/senders/ntfy.ts @@ -58,7 +58,7 @@ export async function sendViaNtfy( ? "reminder" : payload.eventType.toLowerCase().replace(/_/g, "-"); const headers: Record = { - Title: plainPushText(payload.title, payload.eventType), + 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 @@ -71,6 +71,16 @@ export async function sendViaNtfy( 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); @@ -187,3 +197,51 @@ 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() || "?"; +} + +/** + * The `Actions` header for the payload's quick answers, in ntfy's short + * format (https://docs.ntfy.sh/publish/#action-buttons): + * + * http,