fix(notifications): strip emoji from routine reminder push content

Reminder pushes (medication-due, mood, briefing, cycle) carried the
colour-coded phase markers the Telegram channel uses, which leaked onto the
iOS lock screen and other push channels as decorative bubbles. Strip emoji
in the APNs, Web Push and ntfy senders for every event except SYSTEM_ALERT,
so routine reminders render as plain text while system / failure alerts keep
their severity glyphs. Telegram is unchanged — emoji stay idiomatic there.
This commit is contained in:
Marc-André Bombeck committed 2026-06-06 16:27:59 +02:00
1 parent e796968555
commit e391150455
5 files changed
+114 -6

No files matched your search

@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import {
stripDecorativeEmoji,
shouldStripEmoji,
plainPushText,
} from "@/lib/notifications/strip-emoji";
describe("stripDecorativeEmoji", () => {
it("removes the colour-coded reminder phase markers and tidies whitespace", () => {
expect(stripDecorativeEmoji("🟢 Reminder: Ramipril")).toBe(
"Reminder: Ramipril",
);
expect(stripDecorativeEmoji("🔴 Missed: Metformin")).toBe(
"Missed: Metformin",
);
});
it("strips clock / skip / check emoji and regional indicators", () => {
expect(stripDecorativeEmoji("🕐 1h ⏭ Skip ✅")).toBe("1h Skip");
});
it("leaves plain text untouched", () => {
expect(stripDecorativeEmoji("Due soon: Vitamin D (1 tablet)")).toBe(
"Due soon: Vitamin D (1 tablet)",
);
});
it("spares ™ © ® so brand names survive", () => {
expect(stripDecorativeEmoji("Tylenol® reminder")).toBe("Tylenol® reminder");
});
});
describe("shouldStripEmoji / plainPushText", () => {
it("strips for routine reminder events", () => {
expect(shouldStripEmoji("MEDICATION_REMINDER")).toBe(true);
expect(shouldStripEmoji("MOOD_REMINDER")).toBe(true);
expect(plainPushText("🟢 Take Ramipril", "MEDICATION_REMINDER")).toBe(
"Take Ramipril",
);
});
it("keeps emoji for system / failure alerts", () => {
expect(shouldStripEmoji("SYSTEM_ALERT")).toBe(false);
expect(plainPushText("⚠️ Sync failed", "SYSTEM_ALERT")).toBe(
"⚠️ Sync failed",
);
});
});
+5 -1
View File
@@ -30,6 +30,7 @@ import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import type { SendOutcome } from "@/lib/notifications/retry-policy";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
import { plainPushText } from "@/lib/notifications/strip-emoji";
export interface ApnsPayload {
/** APNs `aps.alert.title` + `aps.alert.body`. */
@@ -615,7 +616,10 @@ export async function sendViaApns(
deviceToken: device.apnsToken,
environment: attemptEnv,
payload: {
alert: { title: payload.title, body: stripHtml(payload.message) },
alert: {
title: plainPushText(payload.title, payload.eventType),
body: plainPushText(stripHtml(payload.message), payload.eventType),
},
data: {
eventType: routingEvent,
// In discreet mode also drop cycle-semantic metadata (e.g. `phase`)
+7 -3
View File
@@ -7,6 +7,7 @@ import { classifyHttpStatus } from "@/lib/notifications/retry-policy";
import { getEvent } from "@/lib/logging/context";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
import { safeFetch } from "@/lib/safe-fetch";
import { plainPushText } from "@/lib/notifications/strip-emoji";
/**
* Send notification via ntfy (simple HTTP POST).
@@ -26,7 +27,7 @@ export async function sendViaNtfy(
const url = `${config.serverUrl.replace(/\/$/, "")}/${encodeURIComponent(config.topic)}`;
const headers: Record<string, string> = {
Title: payload.title,
Title: plainPushText(payload.title, payload.eventType),
Priority:
payload.eventType === "MEDICATION_REMINDER" ? "high" : "default",
// Discreet mode (cycle privacy): the X-Tags header is visible on the
@@ -41,8 +42,11 @@ export async function sendViaNtfy(
headers["Authorization"] = `Bearer ${config.authToken}`;
}
// Strip HTML tags for ntfy (plain text only)
const body = payload.message.replace(/<[^>]*>/g, "");
// Strip HTML tags for ntfy (plain text only) + emoji on routine reminders
const body = plainPushText(
payload.message.replace(/<[^>]*>/g, ""),
payload.eventType,
);
// requirePublicHost adds the DNS-rebinding pin (issue #217) on top
// of the input-time isPublicUrl guard already enforced when the
+6 -2
View File
@@ -7,6 +7,7 @@ import { getVapidConfig } from "@/lib/notifications/vapid-config";
import { getEvent } from "@/lib/logging/context";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
import { isPublicUrl } from "@/lib/validations/notifications";
import { plainPushText } from "@/lib/notifications/strip-emoji";
/**
* Send Web Push notification to all subscribed devices of a user.
@@ -78,8 +79,11 @@ export async function sendViaWebPush(
}
const pushPayload = JSON.stringify({
title: payload.title,
body: payload.message.replace(/<[^>]*>/g, ""),
title: plainPushText(payload.title, payload.eventType),
body: plainPushText(
payload.message.replace(/<[^>]*>/g, ""),
payload.eventType,
),
// Discreet mode (cycle privacy): the coalescing `tag` must not name the
// event. Web-Push payloads are VAPID-encrypted (only the SW sees this),
// but the discreet contract still requires no event name on the wire —
+47
View File
@@ -0,0 +1,47 @@
/**
* Strip decorative emoji from push notification content.
*
* Operator + iOS contract (2026-06): routine reminder pushes
* (medication-due, mood-nudge, daily-briefing, cycle reminders) must be
* plain text — no emoji, no decorative bubbles. iOS renders the APNs
* `title`/`body` verbatim, so the colour-coded phase markers (🟢🟡🟠🔴) the
* reminder strings carry for the Telegram channel leak onto the lock
* screen. Emoji stay acceptable only for system / failure alerts
* (`SYSTEM_ALERT`), where they flag severity.
*
* The push senders (APNs, Web Push, ntfy) run reminder content through
* this; Telegram keeps the emoji (idiomatic there, and its inline keyboard
* relies on glyph-labelled action buttons). `shouldStripEmoji` keeps the
* SYSTEM_ALERT carve-out in one place.
*
* The range set targets the actual emoji blocks (supplementary
* pictographs, misc symbols, dingbats, geometric/arrow emoji, regional
* indicators, variation selectors, keycap) and deliberately spares the
* BMP punctuation that doubles as text — ™ © ® and the like — so a brand
* name in a medication title is not mangled.
*/
const EMOJI_RE =
/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE00}-\u{FE0F}\u{1F1E6}-\u{1F1FF}\u{20E3}\u{231A}\u{231B}\u{23E9}-\u{23FA}]/gu;
/** Remove decorative emoji and collapse the whitespace they leave behind. */
export function stripDecorativeEmoji(text: string): string {
return text.replace(EMOJI_RE, "").replace(/\s+/g, " ").trim();
}
/**
* Whether a push channel should strip emoji for this event. Everything
* except `SYSTEM_ALERT` is a routine notification and goes plain. Accepts a
* bare string so it composes with senders that carry `eventType` widened to
* `string` on the wire payload.
*/
export function shouldStripEmoji(eventType: string): boolean {
return eventType !== "SYSTEM_ALERT";
}
/**
* Apply the reminder plain-text rule to a single push field: strip emoji
* unless the event is a system / failure alert.
*/
export function plainPushText(text: string, eventType: string): string {
return shouldStripEmoji(eventType) ? stripDecorativeEmoji(text) : text;
}