From adbec718efda7580c2d6d3d81b63d31293224db2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:40:02 +0100 Subject: [PATCH] feat: let admins keep sender and subject out of push notifications, enforced by the server The push preview route now reads the device policy of the login it serves (its scopes worked out from the login's own JMAP session, every non-personal account counted as a group) and under `pushWithoutContent` asks the mail server for the message's ids only, so sender, subject and text never reach the notification. The service worker shows "New mail" for such an answer. A policy that can't be read counts as on. The three existing push preview route tests now mock the config manager with the default policy: without it the route can't read a policy in the test environment and holds the text back, which those tests didn't mean to cover. No assertion changed. --- app/api/push/preview/route.ts | 27 +++- .../push-preview-device-policy.test.ts | 121 ++++++++++++++++++ .../push-preview-route-ambiguous.test.ts | 6 + .../push-preview-route-owner.test.ts | 6 + lib/__tests__/push-preview-route.test.ts | 6 + lib/__tests__/sw-push-redacted.test.ts | 54 ++++++++ lib/__tests__/web-push-properties.test.ts | 12 ++ lib/admin/server-device-policy.ts | 100 +++++++++++++++ lib/web-push.ts | 7 +- public/sw.js | 8 +- 10 files changed, 341 insertions(+), 6 deletions(-) create mode 100644 lib/__tests__/push-preview-device-policy.test.ts create mode 100644 lib/__tests__/sw-push-redacted.test.ts create mode 100644 lib/__tests__/web-push-properties.test.ts create mode 100644 lib/admin/server-device-policy.ts diff --git a/app/api/push/preview/route.ts b/app/api/push/preview/route.ts index 150ce009a..8e48073ba 100644 --- a/app/api/push/preview/route.ts +++ b/app/api/push/preview/route.ts @@ -13,6 +13,7 @@ import { rebaseApiUrl } from '@/lib/stalwart/jmap-api'; import { forwardedClientHeaders, loginClientAddress } from '@/lib/auth/client-address'; import { DisallowedUrlError } from '@/lib/security/url-guard'; import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readJsonCapped } from '@/lib/security/capped-body'; +import { serverDevicePolicy } from '@/lib/admin/server-device-policy'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -23,6 +24,10 @@ interface ResolvedTarget { accountId: string; /** See StalwartCredentials.trusted - false routes through the guarded fetch. */ trusted: boolean; + /** The login, for its device policy. */ + serverUrl: string; + username: string; + accountName?: string; /** * Cookie slot of the login that owns the account. Handed back so a click * on the notification opens the message in that login: the worker knows @@ -75,7 +80,7 @@ async function resolveTargetForAccount(accountId: string, forwarded: Record Devices > Push without sender and subject, for this + // login's scopes (worked out from its own session): the message's + // sender, subject and text are never read, so they can't reach the + // notification, the push service or the lock screen. Unknown counts as on. + const redacted = await serverDevicePolicy({ + serverUrl: target.serverUrl, + authHeader, + username: target.username, + accountName: target.accountName, + trusted, + }).then((devices) => devices.pushWithoutContent, () => true); + const emailProperties = redacted + ? ['id', 'threadId', 'receivedAt'] + : ['id', 'threadId', 'from', 'subject', 'preview', 'receivedAt']; if (!inboxId && !requestedEmailId) { return NextResponse.json({ @@ -333,10 +351,13 @@ export async function GET(request: NextRequest) { // Some servers build the preview from HTML without skipping