Send a filter the server can read

Enabling background notifications failed with "Invalid filter". The
subscription asked to be notified about mail matching:

    filter: { inMailbox: null, notKeyword: "$seen" }

`inMailbox: null` meant "the inbox" in my head and nothing at all to
Stalwart, which needs a mailbox id there. It refused the whole
subscription, so the feature did not work at all for anyone who tried
it.

The Inbox's id is now passed in and used. Where it is not known the
condition is left out rather than sent empty: notifying more widely is a
worse default than filtering to the Inbox, but it is a working one, and
sending a malformed filter is not a fallback.

Two reasons this got out, both worth fixing rather than just the bug:

  - The tests checked the properties list and its ordering, and never
    looked at the filter. There is now one that walks every condition
    and fails on a null or undefined value, for both the known-inbox and
    unknown-inbox cases.

  - The mock accepted it happily, so nothing local disagreed with the
    code. It now refuses a filter condition with a null value and
    answers "Invalid filter.", which is what the live server said.
    Reproduced: the old payload is rejected, the new one accepted.
This commit is contained in:
2026-08-26 14:01:56 -07:00
parent d7e9e94794
commit 2263aa494f
4 changed files with 77 additions and 4 deletions
+12
View File
@@ -520,6 +520,18 @@ const handlers: Record<string, Handler> = {
notCreated[cid] = { type: "invalidProperties", properties: ["url"], description: "Push endpoint must be https." }; notCreated[cid] = { type: "invalidProperties", properties: ["url"], description: "Push endpoint must be https." };
continue; continue;
} }
// A filter condition with a null value is not a filter -- the real server
// answers "Invalid filter" and refuses the whole subscription. ihasmail
// shipped `inMailbox: null` meaning "the inbox", which meant nothing at
// all here, and the mock accepted it happily. It does not any more.
const badFilter = Object.entries((o.emailPush ?? {}) as Obj).find(([, cfg]) => {
const f = ((cfg as Obj)?.filter ?? {}) as Obj;
return Object.values(f).some((v) => v === null || v === undefined);
});
if (badFilter) {
notCreated[cid] = { type: "invalidArguments", properties: ["emailPush"], description: "Invalid filter." };
continue;
}
// One per device: re-subscribing replaces rather than accumulates. // One per device: re-subscribing replaces rather than accumulates.
const deviceId = String(o.deviceClientId ?? ""); const deviceId = String(o.deviceClientId ?? "");
const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId); const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId);
+47
View File
@@ -128,3 +128,50 @@ describe("availability", () => {
expect(webPushAvailable()).toBe(false); expect(webPushAvailable()).toBe(false);
}); });
}); });
describe("the emailPush filter", () => {
/**
* This is the bug that reached production: `inMailbox: null` read as "the
* inbox" and meant nothing to the server, which answered "Invalid filter"
* and refused the subscription outright. The original tests checked the
* property ordering and never looked at the filter at all.
*/
const fakeSub = {
endpoint: "https://push.example/abc",
toJSON: () => ({ keys: { p256dh: "cGRoLWtleQ", auth: "YXV0aA" } }),
getKey: () => null,
} as unknown as PushSubscription;
const withEmailPush = () => {
client.session = session({
"urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: LIVE_KEY },
"urn:ietf:params:jmap:emailpush": {},
});
};
it("never sends a condition with a null or undefined value", () => {
withEmailPush();
for (const inbox of ["mb1", null]) {
const body = subscriptionPayload(fakeSub, "a1", inbox) as Record<string, any>;
const filter = body.emailPush.a1.filter as Record<string, unknown>;
for (const [k, v] of Object.entries(filter)) {
expect(v, `${k} was ${String(v)} with inbox=${String(inbox)}`).not.toBeNull();
expect(v, k).not.toBeUndefined();
}
}
});
it("uses the real mailbox id when it knows one", () => {
withEmailPush();
const body = subscriptionPayload(fakeSub, "a1", "mbInbox") as Record<string, any>;
expect(body.emailPush.a1.filter.inMailbox).toBe("mbInbox");
});
it("leaves inMailbox out entirely when it does not, rather than sending null", () => {
withEmailPush();
const filter = (subscriptionPayload(fakeSub, "a1", null) as Record<string, any>).emailPush.a1.filter;
expect(filter).not.toHaveProperty("inMailbox");
// Still narrowed to unread: notifying more widely beats not notifying.
expect(filter.notKeyword).toBe("$seen");
});
});
+15 -3
View File
@@ -105,8 +105,17 @@ export function deviceClientId(): string {
} }
} }
/** What to send Stalwart for a browser subscription. */ /**
export function subscriptionPayload(sub: PushSubscription, accountId: Id | null): Record<string, unknown> { * What to send Stalwart for a browser subscription.
*
* `inboxId` is the Inbox's mailbox id. It is a parameter rather than something
* looked up here because an `inMailbox` condition needs a real id: the first
* version of this passed `null`, meaning "the inbox" in the author's head and
* nothing at all to the server, which answered "Invalid filter" and refused the
* whole subscription. Without an id the filter simply leaves `inMailbox` out
* and notifies more widely, which is a worse default but a working one.
*/
export function subscriptionPayload(sub: PushSubscription, accountId: Id | null, inboxId: Id | null = null): Record<string, unknown> {
const json = sub.toJSON(); const json = sub.toJSON();
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
deviceClientId: deviceClientId(), deviceClientId: deviceClientId(),
@@ -121,7 +130,10 @@ export function subscriptionPayload(sub: PushSubscription, accountId: Id | null)
[accountId]: { [accountId]: {
// Only mail that actually lands in the inbox. Filtering here rather // Only mail that actually lands in the inbox. Filtering here rather
// than in the service worker means spam never leaves the server. // than in the service worker means spam never leaves the server.
filter: { inMailbox: null, notKeyword: "$seen" }, // Unread mail only, and only in the Inbox when we know which it is.
// Filtering here rather than in the service worker means spam and
// filed mail never leave the server at all.
filter: { ...(inboxId ? { inMailbox: inboxId } : {}), notKeyword: "$seen" },
properties: PAYLOAD_PROPS, properties: PAYLOAD_PROPS,
urgency: "normal", urgency: "normal",
}, },
+3 -1
View File
@@ -7,6 +7,7 @@
*/ */
import { CAP } from "@/jmap/client"; import { CAP } from "@/jmap/client";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { useMail } from "@/store/mail";
import { import {
applicationServerKey, applicationServerKey,
createSubscription, createSubscription,
@@ -78,7 +79,8 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
applicationServerKey: decodeApplicationServerKey(key), applicationServerKey: decodeApplicationServerKey(key),
})); }));
const accountId = useSession.getState().accountFor(CAP.mail); const accountId = useSession.getState().accountFor(CAP.mail);
await createSubscription(subscriptionPayload(sub, accountId)); const inboxId = useMail.getState().roleId("inbox");
await createSubscription(subscriptionPayload(sub, accountId, inboxId));
listenForVerification(); listenForVerification();
return { ok: true }; return { ok: true };
} catch (err) { } catch (err) {