Merge branch 'cu-reader' into catch-up-2026-11

# Conflicts:
#	docs/native-sync-2026-11.md
#	docs/parity/11-webmail-1.19.md
#	locales/rn/en.json
#	src/stores/settings-store.ts
This commit is contained in:
Linus Rath
2026-10-31 05:02:08 +01:00
86 changed files with 3473 additions and 234 deletions
+16
View File
@@ -0,0 +1,16 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('../jmap-client', () => ({ jmapClient: { accountId: 'acc-1', request: vi.fn() } }));
import { EMAIL_FULL_PROPERTIES, EMAIL_LIST_PROPERTIES, THREAD_HEADER_PROPERTIES } from '../email';
// Sort by "Date sent" compares sentAt on the device for a list merged from
// several accounts, but list rows never had it, so the order fell back to
// the received date (webmail #1219).
describe('list properties', () => {
it('include sentAt, once', () => {
for (const list of [EMAIL_LIST_PROPERTIES, EMAIL_FULL_PROPERTIES, THREAD_HEADER_PROPERTIES]) {
expect(list.filter((p) => p === 'sentAt')).toHaveLength(1);
}
});
});
@@ -37,7 +37,11 @@ import {
searchEmails,
sendEmail,
MAILBOX_PROPERTIES,
queryEmailPage,
} from '../email';
import { senderWarningOf } from '../../lib/email-headers';
import { scheduledEmailIdsInThread } from '../thread-scheduled';
import { fetchTagCounts, KEYWORDS_CAPABILITY } from '../tag-counts';
import { uploadBytes } from '../blob';
import { getIdentities, updateIdentity } from '../identity';
import { isJMAPSetError } from '../jmap-result';
@@ -460,4 +464,75 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => {
}
}, 60_000);
});
describe('mail list (reader pass)', () => {
it('lists the Authentication-Results headers so a forged From gets the warning shield (bulwarkmail/webmail#1439)', async () => {
const subject = `forged ${mailTag}`;
const raw = [
'Authentication-Results: mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example',
'From: Bank <ceo@bank.example>',
`To: ${USER}`,
`Subject: ${subject}`,
`Date: ${new Date().toUTCString()}`,
`Message-ID: <forged-${RUN}@live.test>`,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=utf-8',
'',
'hello',
'',
].join('\r\n');
const { blobId } = await uploadBytes(new TextEncoder().encode(raw), 'message/rfc822');
const id = await importEmailBlob(blobId, scratch, {});
const page = await queryEmailPage(scratch, { limit: 10 });
const row = page.list.find((e) => e.id === id);
expect(row).toBeDefined();
expect(senderWarningOf(row!)?.status).toBe('failed');
// The Date header, for sorting by date sent in merged lists (bulwarkmail/webmail#1219).
expect(typeof row!.sentAt).toBe('string');
// The full fetch reads the same verdict from `headers`.
const [full] = await getFullEmails([id]);
expect(senderWarningOf(full)?.status).toBe('failed');
}, 60_000);
it("finds a conversation's message that waits for a scheduled send (bulwarkmail/webmail#1404)", async () => {
const [identity] = await getIdentities();
const result = await sendEmail(
{ from: [{ email: USER }], to: [{ email: USER }], subject: `scheduled reply ${mailTag}`, textBody: 'live' },
identity.id, sent, 3600, { draftsMailboxId: drafts },
);
try {
const [filed] = await getFullEmails([result.emailId!]);
const ids = await scheduledEmailIdsInThread(filed.threadId);
expect([...ids]).toEqual([result.emailId]);
} finally {
await cancelScheduledSend(result.emailSubmissionId!).catch(() => undefined);
}
// Once cancelled it can be replied to again.
const [filed] = await getFullEmails([result.emailId!]);
expect((await scheduledEmailIdsInThread(filed.threadId)).size).toBe(0);
}, 90_000);
it('leaves Trash out of the tag counts (bulwarkmail/webmail#1156)', async (ctx) => {
const boxes = await getMailboxes();
const trash = boxes.find((b) => b.role === 'trash')!.id;
const tag = 'readerpass';
const keyword = `$label:${tag}`;
const live = await importMessage(scratch, `tag live ${mailTag}`, 'a');
const gone = await importMessage(trash, `tag deleted ${mailTag}`, 'b', { $seen: true });
// Set afterwards: 0.16 drops the keywords Email/import is given.
await patchKeywordsForEmails([live, gone], { [keyword]: true });
const flags = await getEmailFlags([live]);
if (!flags.list[0]?.keywords?.[keyword]) {
// Seen on the 0.16.27 test server: Email/set answers "updated" but
// keeps only system keywords for this account. Nothing to count.
ctx.skip('the server did not keep the tag keyword');
return;
}
const all = await eventually(() => fetchTagCounts([tag]), ([c]) => c.total === 2);
expect(all).toEqual([{ id: tag, total: 2, unread: 1 }]);
expect(await fetchTagCounts([tag], [undefined], { '': [trash] })).toEqual([{ id: tag, total: 1, unread: 1 }]);
// Stalwart answers Keyword/get for no keyword counters (no capability).
expect(client.currentSession?.capabilities?.[KEYWORDS_CAPABILITY]).toBeUndefined();
}, 60_000);
});
});
+55 -3
View File
@@ -5,11 +5,13 @@ vi.mock('../jmap-client', () => ({
accountId: 'c',
request: vi.fn(),
getMaxCallsInRequest: vi.fn(() => 16),
currentSession: { capabilities: {} as Record<string, unknown> },
hasAccountCapability: vi.fn(() => true),
},
}));
import { jmapClient } from '../jmap-client';
import { fetchTagCounts } from '../tag-counts';
import { fetchTagCounts, KEYWORDS_CAPABILITY } from '../tag-counts';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
const mockMaxCalls = jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>;
@@ -30,6 +32,7 @@ function answer(totals: Record<string, { total: number; unread: number }>, faili
beforeEach(() => {
vi.clearAllMocks();
mockMaxCalls.mockReturnValue(16);
jmapClient.currentSession!.capabilities = {};
});
describe('fetchTagCounts', () => {
@@ -39,11 +42,11 @@ describe('fetchTagCounts', () => {
expect(await fetchTagCounts(['red'])).toEqual([{ id: 'red', total: 2, unread: 1 }]);
const calls = mockRequest.mock.calls[0][0];
expect(calls).toEqual([
['Email/query', { accountId: 'c', filter: { hasKeyword: '$label:red' }, limit: 0, calculateTotal: true }, 't0:red'],
['Email/query', { accountId: 'c', filter: { hasKeyword: '$label:red' }, limit: 1, calculateTotal: true }, 't0:red'],
['Email/query', {
accountId: 'c',
filter: { operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { notKeyword: '$seen' }] },
limit: 0,
limit: 1,
calculateTotal: true,
}, 'u0:red'],
]);
@@ -77,4 +80,53 @@ describe('fetchTagCounts', () => {
expect(await fetchTagCounts(['red'], [undefined, 'team'])).toEqual([{ id: 'red', total: 2, unread: 1 }]);
});
// The tag view leaves Trash and Junk out; its badge counted them (#1156).
it("leaves each account's Trash and Junk out of the counts", async () => {
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, unknown>, string]>) => ({
methodResponses: calls.map(([, , callId]) => ['Email/query', { ids: [], total: 1 }, callId]),
}));
await fetchTagCounts(['red'], [undefined, 'team'], { '': ['trash', 'junk'], team: ['t-trash'] });
const filters = mockRequest.mock.calls[0][0].map((c: unknown[]) => (c[1] as { filter: unknown }).filter);
expect(filters).toEqual([
{ operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { inMailboxOtherThan: ['trash', 'junk'] }] },
{ operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { notKeyword: '$seen' }, { inMailboxOtherThan: ['trash', 'junk'] }] },
{ operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { inMailboxOtherThan: ['t-trash'] }] },
{ operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { notKeyword: '$seen' }, { inMailboxOtherThan: ['t-trash'] }] },
]);
});
// One Keyword/get where the server counts keywords (#1399), else the queries.
it('asks Keyword/get when the server counts keywords, and falls back to queries when it fails', async () => {
jmapClient.currentSession!.capabilities = { [KEYWORDS_CAPABILITY]: { supportsCounts: true } };
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, unknown>, string]>) => {
if (calls[0][0] === 'Keyword/get') {
return { methodResponses: [['Keyword/get', { list: [{ id: '$LABEL:Red', total: 7, unread: 3 }] }, '0']] };
}
return { methodResponses: calls.map(([, , callId]) => ['Email/query', { ids: [], total: 1 }, callId]) };
});
expect(await fetchTagCounts(['red', 'blue'])).toEqual([
{ id: 'red', total: 7, unread: 3 },
{ id: 'blue', total: 0, unread: 0 },
]);
expect(mockRequest).toHaveBeenCalledTimes(1);
expect(mockRequest.mock.calls[0][0][0]).toEqual(['Keyword/get', { accountId: 'c', ids: ['$label:red', '$label:blue'] }, '0']);
mockRequest.mockClear();
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, unknown>, string]>) => (
calls[0][0] === 'Keyword/get'
? { methodResponses: [['error', { type: 'unknownMethod' }, '0']] }
: { methodResponses: calls.map(([, , callId]) => ['Email/query', { ids: [], total: 2 }, callId]) }
));
expect(await fetchTagCounts(['red'])).toEqual([{ id: 'red', total: 2, unread: 2 }]);
// Without supportsCounts nothing asks Keyword/get.
jmapClient.currentSession!.capabilities = { [KEYWORDS_CAPABILITY]: {} };
mockRequest.mockClear();
await fetchTagCounts(['red']);
expect(mockRequest.mock.calls[0][0][0][0]).toBe('Email/query');
});
});
@@ -45,6 +45,15 @@ describe('buildFilter (aggregated views)', () => {
});
});
describe("buildFilter: a server's own all mailbox (bulwarkmail/webmail#1241)", () => {
it('is left out of All mail and Unread', () => {
const withAll = [...MAILBOXES, mailbox('every', 'all')];
expect(buildFilter(withAll, { view: 'all' })).toEqual({
operator: 'OR', conditions: [{ inMailbox: 'in' }, { inMailbox: 'projects' }],
});
});
});
describe('buildFilter: searching from All mail', () => {
const all = [...MAILBOXES, mailbox('arch', 'archive'), mailbox('drafts', 'drafts'), mailbox('spam', 'junk')];
+18 -5
View File
@@ -19,19 +19,32 @@ import { hasTruncatedDisplayedBody } from '../lib/email-body';
import { withInlineBodyParts } from '../lib/email-inline-parts';
import { stalwartStateWentBack } from './stalwart-state';
import { isTransientNetworkError } from '../lib/network-error';
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
export const EMAIL_LIST_PROPERTIES = [
const EMAIL_SUMMARY_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
// Sorting by "Date sent" compares it on the device for a list merged from
// several accounts; without it the order fell back to receivedAt (#1219).
'sentAt',
];
export const EMAIL_LIST_PROPERTIES = [
...EMAIL_SUMMARY_PROPERTIES,
// The sender check, so a list row shows a warning shield instead of the
// favicon or photo of a forged From (webmail #1439).
LIST_AUTH_RESULTS_PROPERTY,
];
// The viewer derives reply threading (In-Reply-To/References), Reply-To
// handling, SPF/DKIM/DMARC chips, List-Unsubscribe and read-receipt requests
// from these; without `messageId`/`headers` none of that can work.
// from these; without `messageId`/`headers` none of that can work. The full
// `headers` carry Authentication-Results, so the list's header property is
// left out.
export const EMAIL_FULL_PROPERTIES = [
...EMAIL_LIST_PROPERTIES,
...EMAIL_SUMMARY_PROPERTIES,
'bodyStructure', 'textBody', 'htmlBody', 'bodyValues',
'attachments', 'blobId', 'bcc', 'replyTo', 'sentAt',
'attachments', 'blobId', 'bcc', 'replyTo',
'messageId', 'inReplyTo', 'references', 'headers',
];
@@ -1014,7 +1027,7 @@ export async function getEmailFlags(
}
// What a collapsed conversation card shows; `sentAt` is the date it prints.
export const THREAD_HEADER_PROPERTIES = [...EMAIL_LIST_PROPERTIES, 'sentAt'];
export const THREAD_HEADER_PROPERTIES = EMAIL_LIST_PROPERTIES;
/** A conversation's members, oldest first, without bodies. */
export interface ThreadHeaders {
+64 -6
View File
@@ -1,5 +1,8 @@
import { jmapClient } from './jmap-client';
import type { JMAPMethodCall } from './types';
import { CAPABILITIES, type JMAPMethodCall } from './types';
/** Bulwark's keyword counters (webmail lib/jmap/client.ts KEYWORDS_CAPABILITY). */
export const KEYWORDS_CAPABILITY = 'https://bulwarkmail.com/ns/jmap/keywords';
export interface TagCount {
/** Tag id (the part after `$label:`). */
@@ -17,33 +20,61 @@ export interface TagCount {
* allows. Port of the webmail's `fetchTagCounts` (stores/email-store.ts). A
* call that fails, such as for an account that can't be reached, counts 0
* rather than sinking the rest.
*
* `excluded` names each account's Trash and Junk (key '' = the own account):
* the counts leave them out like the tag view does (webmail #1156). An
* account whose server counts keywords itself (Bulwark's keywords capability
* with `supportsCounts`) is asked once with Keyword/get instead (#1399); its
* counters cover the whole account, as the webmail's do.
*/
export async function fetchTagCounts(
tagIds: string[],
accountIds: Array<string | undefined> = [undefined],
excluded: Record<string, string[]> = {},
): Promise<TagCount[]> {
if (tagIds.length === 0) return [];
const perRequest = Math.max(2, jmapClient.getMaxCallsInRequest());
const counts = new Map<string, TagCount>(tagIds.map((id) => [id, { id, total: 0, unread: 0 }]));
const queried: Array<string | undefined> = [];
for (const override of accountIds) {
const counted = countsKeywords(override ?? jmapClient.accountId)
? await tagCountsFromKeywords(tagIds, override ?? jmapClient.accountId)
: null;
if (!counted) {
queried.push(override);
continue;
}
for (const c of counted) {
const entry = counts.get(c.id)!;
entry.total += c.total;
entry.unread += c.unread;
}
}
const calls: JMAPMethodCall[] = [];
const targets = new Map<string, { id: string; kind: 'total' | 'unread' }>();
accountIds.forEach((override, index) => {
queried.forEach((override, index) => {
const accountId = override ?? jmapClient.accountId;
const skip = excluded[override ?? ''] ?? [];
const exclusion = skip.length > 0 ? [{ inMailboxOtherThan: skip }] : [];
for (const id of tagIds) {
const keyword = `$label:${id}`;
targets.set(`t${index}:${id}`, { id, kind: 'total' });
calls.push(['Email/query', {
accountId,
filter: { hasKeyword: keyword },
limit: 0,
filter: exclusion.length > 0
? { operator: 'AND', conditions: [{ hasKeyword: keyword }, ...exclusion] }
: { hasKeyword: keyword },
// 1, not 0: Stalwart takes 0 as "no limit" and returns every id.
limit: 1,
calculateTotal: true,
}, `t${index}:${id}`]);
targets.set(`u${index}:${id}`, { id, kind: 'unread' });
calls.push(['Email/query', {
accountId,
filter: { operator: 'AND', conditions: [{ hasKeyword: keyword }, { notKeyword: '$seen' }] },
limit: 0,
filter: { operator: 'AND', conditions: [{ hasKeyword: keyword }, { notKeyword: '$seen' }, ...exclusion] },
limit: 1,
calculateTotal: true,
}, `u${index}:${id}`]);
}
@@ -63,3 +94,30 @@ export async function fetchTagCounts(
}
return tagIds.map((id) => counts.get(id)!);
}
/** Whether the server reports every keyword's total and unread in Keyword/get. */
function countsKeywords(accountId: string): boolean {
const capability = jmapClient.currentSession?.capabilities?.[KEYWORDS_CAPABILITY] as { supportsCounts?: unknown } | undefined;
return capability?.supportsCounts === true && jmapClient.hasAccountCapability(KEYWORDS_CAPABILITY, accountId);
}
/** Counts from the server's keyword counters, or null so the caller queries instead. */
async function tagCountsFromKeywords(tagIds: string[], accountId: string): Promise<TagCount[] | null> {
try {
const res = await jmapClient.request(
[['Keyword/get', { accountId, ids: tagIds.map((id) => `$label:${id}`) }, '0']],
[CAPABILITIES.CORE, KEYWORDS_CAPABILITY],
);
const [name, body] = res.methodResponses?.[0] ?? [];
if (name !== 'Keyword/get' || !Array.isArray(body?.list)) return null;
// Keywords are case-insensitive (RFC 8621 4.1.1).
const byId = new Map((body.list as Array<{ id: string; total?: number; unread?: number }>)
.map((k) => [k.id.toLowerCase(), k]));
return tagIds.map((id) => {
const k = byId.get(`$label:${id}`.toLowerCase());
return { id, total: k?.total ?? 0, unread: k?.unread ?? 0 };
});
} catch {
return null;
}
}
+36
View File
@@ -0,0 +1,36 @@
import { jmapClient } from './jmap-client';
import { SUBMISSION_ONLY_USING } from './submissions';
/**
* Ids of a conversation's messages that wait for a scheduled send: a pending
* EmailSubmission whose send time is still ahead. The conversation hides
* Reply on them and points the quick reply past them, as it does for drafts
* (webmail 8b29254a6, #1404). Empty when the account can't schedule or the
* server can't answer: the cards then stay as they were.
*/
export async function scheduledEmailIdsInThread(threadId: string, accountId?: string): Promise<Set<string>> {
const account = accountId ?? jmapClient.accountId;
if (!account || !jmapClient.hasDelayedSend(account)) return new Set();
try {
const res = await jmapClient.request([
['EmailSubmission/query', { accountId: account, filter: { threadIds: [threadId], undoStatus: 'pending' } }, '0'],
['EmailSubmission/get', {
accountId: account,
'#ids': { resultOf: '0', name: 'EmailSubmission/query', path: '/ids' },
properties: ['emailId', 'sendAt', 'undoStatus'],
}, '1'],
], SUBMISSION_ONLY_USING);
const [name, body] = res.methodResponses?.[1] ?? [];
if (name !== 'EmailSubmission/get') return new Set();
const now = Date.now();
const ids = new Set<string>();
for (const s of (body.list as Array<{ emailId?: string; sendAt?: string; undoStatus?: string }>) ?? []) {
if (!s.emailId || s.undoStatus !== 'pending') continue;
const at = s.sendAt ? new Date(s.sendAt).getTime() : NaN;
if (Number.isFinite(at) && at > now) ids.add(s.emailId);
}
return ids;
} catch {
return new Set();
}
}
+10 -3
View File
@@ -8,6 +8,7 @@ import { keywordPointer, mailboxPointer } from './patch-pointer';
import { secureFetch } from '../lib/client-cert';
import { freshStoredCredentials } from '../lib/stored-token-refresh';
import { textConditions } from '../lib/search-utils';
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
// Aggregated views across accounts ("All inboxes", "All Sent", All mail /
// Unread / Starred). Because the JMAP client is a single-account singleton
@@ -20,7 +21,9 @@ import { textConditions } from '../lib/search-utils';
const EMAIL_LIST_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment', 'sentAt',
// The sender check, for the warning shield on a forged From (#1439).
LIST_AUTH_RESULTS_PROPERTY,
];
const SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
@@ -29,9 +32,13 @@ const MAILBOX_CACHE_TTL_MS = 60 * 1000;
export type UnifiedRole = 'inbox' | 'sent' | 'drafts' | 'junk' | 'archive' | 'trash';
export type CrossView = 'all' | 'unread' | 'starred';
/** Roles left out of the cross-account All mail / Unread / Starred views. */
/**
* Roles left out of the cross-account All mail / Unread / Starred views. A
* server's own "all" mailbox holds every message again, so listing it beside
* the real folders doubled them (webmail lib/jmap/types.ts, #1241).
*/
export const CROSS_EXCLUDED_ROLES: ReadonlySet<string> = new Set([
'junk', 'spam', 'sent', 'archive', 'trash', 'drafts',
'junk', 'spam', 'sent', 'archive', 'trash', 'drafts', 'all',
]);
export interface UnifiedEmail extends Email {