From 97e6256c0e6db8a347757bb97fd745247cc7d5ce Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:51:20 +0100 Subject: [PATCH] fix: show the inbox in the demo when a category tab filters on a header --- lib/__tests__/demo-client-filter.test.ts | 45 ++++++++++++++++++++++++ lib/demo/demo-client.ts | 15 ++++++-- 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 lib/__tests__/demo-client-filter.test.ts diff --git a/lib/__tests__/demo-client-filter.test.ts b/lib/__tests__/demo-client-filter.test.ts new file mode 100644 index 000000000..33003e4a2 --- /dev/null +++ b/lib/__tests__/demo-client-filter.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { DemoJMAPClient } from '@/lib/demo/demo-client'; + +// A `header` condition used to be ignored, so it matched every message. The +// category tabs' Promotions rule has one, which left the default tab (NOT all +// the other tabs) empty on an Inbox full of mail. +describe('DemoJMAPClient header filter', () => { + async function inboxOf(client: DemoJMAPClient) { + return (await client.getMailboxes()).find((m) => m.role === 'inbox')!; + } + + it('matches only messages that carry the header', async () => { + const client = new DemoJMAPClient(); + const inbox = await inboxOf(client); + const all = await client.getEmails(inbox.id, undefined, 500); + const listed = await client.getEmails(inbox.id, undefined, 500, 0, undefined, false, { header: ['List-Id'] }); + + expect(listed.total).toBeGreaterThan(0); + expect(listed.total).toBeLessThan(all.total); + for (const e of listed.emails) { + expect(Object.keys(e.headers ?? {}).map((h) => h.toLowerCase())).toContain('list-id'); + } + }); + + it('compares the value when the condition has one', async () => { + const client = new DemoJMAPClient(); + const inbox = await inboxOf(client); + const hit = await client.getEmails(inbox.id, undefined, 500, 0, undefined, false, { header: ['list-id', 'BEACON.meridianlabs'] }); + const miss = await client.getEmails(inbox.id, undefined, 500, 0, undefined, false, { header: ['List-Id', 'no-such-list'] }); + + expect(hit.total).toBeGreaterThan(0); + expect(miss.total).toBe(0); + }); + + it('leaves mail in a default tab that excludes a header rule', async () => { + const client = new DemoJMAPClient(); + const inbox = await inboxOf(client); + const primary = await client.getEmails(inbox.id, undefined, 500, 0, undefined, false, { + operator: 'NOT', + conditions: [{ operator: 'OR', conditions: [{ from: 'mailchimp' }, { header: ['List-Unsubscribe'] }] }], + }); + + expect(primary.total).toBeGreaterThan(0); + }); +}); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 8a9377ec4..b1eae913d 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -523,8 +523,9 @@ export class DemoJMAPClient implements IJMAPClient { /** * Minimal JMAP filter evaluator for demo mode: supports the conditions the * message-list category tabs and the folder-less search scopes use - * (hasKeyword / notKeyword / from / text / inMailbox / inMailboxOtherThan - * and AND / OR / NOT operators). Unknown conditions are ignored. + * (hasKeyword / notKeyword / from / text / header / inMailbox / + * inMailboxOtherThan and AND / OR / NOT operators). Unknown conditions are + * ignored. */ private matchesFilter(e: Email, filter: Record): boolean { if (typeof filter.operator === 'string') { @@ -564,6 +565,16 @@ export class DemoJMAPClient implements IJMAPClient { const bodyText = Object.values(e.bodyValues || {}).map(v => v.value).join(' ').toLowerCase(); if (!bodyText.includes(q)) return false; } + // RFC 8621 4.4.1: [name] tests that the header exists, [name, value] that + // its value contains the text. Ignoring it would match every message. + if (Array.isArray(filter.header) && typeof filter.header[0] === 'string') { + const wanted = filter.header[0].toLowerCase(); + const text = typeof filter.header[1] === 'string' ? filter.header[1].toLowerCase() : null; + const match = Object.entries(e.headers ?? {}).some(([name, value]) => + name.toLowerCase() === wanted + && (text === null || (Array.isArray(value) ? value : [value]).some(v => String(v).toLowerCase().includes(text)))); + if (!match) return false; + } if (typeof filter.inMailbox === 'string' && !e.mailboxIds[filter.inMailbox]) return false; if (Array.isArray(filter.inMailboxOtherThan)) { const excluded = new Set(filter.inMailboxOtherThan as string[]);