feat: show the shared tags of group mailboxes, read from their metadata on Stalwart 1.0

This commit is contained in:
Linus Rath
2026-10-29 03:43:27 +01:00
parent 484cac58b7
commit b7b9c7e076
14 changed files with 644 additions and 13 deletions
@@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const METADATA = 'urn:ietf:params:jmap:metadata';
const { state } = vi.hoisted(() => ({
state: {
session: null as unknown,
requests: [] as Array<{ calls: Array<[string, Record<string, unknown>, string]>; using?: string[] }>,
answer: null as null | ((calls: Array<[string, Record<string, unknown>, string]>) => unknown),
},
}));
vi.mock('../jmap-client', () => ({
jmapClient: {
serverUrl: 'https://mail.example.org',
get currentSession() {
return state.session;
},
request: vi.fn(async (calls: Array<[string, Record<string, unknown>, string]>, using?: string[]) => {
state.requests.push({ calls, using });
return state.answer!(calls);
}),
},
}));
import { getBulwarkMetadata, resetBulwarkMetadataPauses, supportsBulwarkMetadata } from '../bulwark-metadata';
const VENDOR = { supportsVendorNamespaces: true, supportsPrivate: true, namespaces: [], maxDepth: 8 };
// Stalwart 1.0.0 lists Metadata per account; 0.16 has none.
function session(version: '1.0' | '0.16') {
const caps = version === '1.0' ? { [METADATA]: { dataTypes: { Mailbox: VENDOR, Email: VENDOR } } } : {};
return {
capabilities: version === '1.0' ? { [METADATA]: {} } : {},
accounts: {
own: { accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...caps } },
team: { accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...caps } },
},
};
}
// As Stalwart 1.0.0 answers the query and the get (checked live 2026-10-29).
function inboxWith(metadata: unknown) {
return () => ({
methodResponses: [
['Mailbox/query', { accountId: 'team', ids: ['a'] }, 'q'],
['Mailbox/get', { accountId: 'team', list: [{ id: 'a', ...(metadata === undefined ? {} : { metadata }) }] }, 'g'],
],
});
}
beforeEach(() => {
state.requests = [];
state.answer = null;
resetBulwarkMetadataPauses();
});
describe('getBulwarkMetadata', () => {
it('sends nothing to an account that advertises no metadata (Stalwart 0.16)', async () => {
state.session = session('0.16');
expect(supportsBulwarkMetadata('team')).toBe(false);
expect(await getBulwarkMetadata('team')).toBeNull();
expect(state.requests).toHaveLength(0);
});
it("reads the Inbox's Bulwark namespaces in a request of its own", async () => {
state.session = session('1.0');
state.answer = inboxWith({
'org.bulwarkmail.tags': { tags: { urgent: { label: 'Urgent', color: 'red' } } },
'com.example.other': { x: 1 },
});
expect(await getBulwarkMetadata('team')).toEqual({ tags: { tags: { urgent: { label: 'Urgent', color: 'red' } } } });
expect(state.requests).toHaveLength(1);
const [{ calls, using }] = state.requests;
expect(using).toContain(METADATA);
expect(calls.map(([name]) => name)).toEqual(['Mailbox/query', 'Mailbox/get']);
expect(calls[0][1]).toEqual({ accountId: 'team', filter: { role: 'inbox' } });
expect(calls[1][1].properties).toEqual(['id', 'metadata']);
});
it('takes an answer without the property for no metadata, and pauses the account', async () => {
state.session = session('1.0');
state.answer = inboxWith(undefined);
expect(await getBulwarkMetadata('team')).toBeNull();
expect(supportsBulwarkMetadata('team')).toBe(false);
expect(await getBulwarkMetadata('team')).toBeNull();
expect(state.requests).toHaveLength(1);
// Another account is not paused with it.
expect(supportsBulwarkMetadata('own')).toBe(true);
});
it('pauses an account whose request fails (the pre-release build dropped it)', async () => {
state.session = session('1.0');
state.answer = () => { throw new Error('socket hang up'); };
expect(await getBulwarkMetadata('team')).toBeNull();
expect(supportsBulwarkMetadata('team')).toBe(false);
});
});
@@ -239,11 +239,18 @@ describe('JMAP Metadata in the request-building sources', () => {
/(['"`])metadata(\/[^'"`\n]*)?\1/,
];
// Stalwart 1.0.0 serves Metadata. The one module that reads it (a group's
// shared tags) asks only accounts that advertise it, in a request of its
// own; its tests cover that.
const METADATA_READER = 'src/api/bulwark-metadata.ts';
it('never names a JMAP Metadata property, argument or capability in code', () => {
const files = [...sourceFiles('src'), 'App.tsx'];
expect(files).toContain('src/api/email.ts');
expect(files).toContain(METADATA_READER);
const hits: string[] = [];
for (const file of files) {
if (file === METADATA_READER) continue;
const code = withoutComments(readFileSync(path.join(root, file), 'utf8'));
for (const pattern of FORBIDDEN) {
const match = code.match(pattern);
@@ -48,6 +48,8 @@ import {
createSieveScript, deleteSieveScript, getSieveScriptContent, getSieveScripts, validateSieveScript,
} from '../sieve';
import { inlineContactMediaForExport, writeContactWithNewPhoto } from '../../lib/contact-media';
import { getBulwarkMetadata, resetBulwarkMetadataPauses, supportsBulwarkMetadata } from '../bulwark-metadata';
import { parseSharedTags, SHARED_TAGS_KEY } from '../../lib/shared-tags';
import { generateVCard, parseVCard } from '../../lib/vcard';
import type { ContactCard } from '../types';
@@ -352,6 +354,43 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => {
expect(first).toHaveProperty('myRights');
}, 60_000);
// Shared tags (#1310) live in a group's Inbox metadata. The test login
// is in no group, so this checks the shape on its own Inbox: written the
// way the webmail writes it, read the way the app reads it.
it('reads shared tags from Inbox metadata on 1.0 and asks nothing of 0.16', async () => {
resetBulwarkMetadataPauses();
const accountId = client.accountId;
if (!is10()) {
expect(supportsBulwarkMetadata(accountId)).toBe(false);
expect(await getBulwarkMetadata(accountId)).toBeNull();
return;
}
const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:metadata'];
const inbox = (await getMailboxes()).find((m) => m.role === 'inbox')!.id;
const before = await getBulwarkMetadata(accountId);
expect(before).not.toBeNull();
const setTags = (value: unknown, path = '') => client.request([
['Mailbox/set', { accountId, update: { [inbox]: { [`metadata/org.bulwarkmail.tags${path}`]: value } } }, '0'],
], using);
try {
const written = await setTags({ tags: { [`live-${RUN}`]: { label: 'Live check', color: 'teal-dark', note: 'kept' } } });
expect(written.methodResponses[0][1].updated).toHaveProperty(inbox);
// One tag patched on its own, as the webmail saves a change.
await setTags({ label: 'Second', color: 'blue' }, `/tags/second-${RUN}`);
resetBulwarkMetadataPauses();
const entries = await getBulwarkMetadata(accountId);
expect(parseSharedTags(entries?.[SHARED_TAGS_KEY])).toEqual([
{ id: `live-${RUN}`, label: 'Live check', color: 'teal' },
{ id: `second-${RUN}`, label: 'Second', color: 'blue' },
]);
} finally {
await setTags(before?.[SHARED_TAGS_KEY] ?? null);
}
const after = await getBulwarkMetadata(accountId);
expect(after?.[SHARED_TAGS_KEY]).toEqual(before?.[SHARED_TAGS_KEY]);
}, 60_000);
it('creates, reads, validates and deletes a Sieve script', async () => {
const content = `require ["fileinto"];\r\n# ${RUN} */\r\nif header :contains "subject" "${RUN}" { fileinto "INBOX"; }\r\n`;
expect((await validateSieveScript(content)).isValid).toBe(true);
+91
View File
@@ -0,0 +1,91 @@
// Bulwark's own data in JMAP Metadata (RFC draft, urn:ietf:params:jmap:metadata)
// of an account's Inbox: the shared tag definitions of a group account
// (namespace `org.bulwarkmail.tags`). Port of the reading half of the
// webmail's lib/jmap/bulwark-metadata.ts and client.getBulwarkMetadata.
//
// Stalwart 1.0.0 serves Metadata; 0.16 advertises none, and a pre-release
// 1.0 build advertised it but dropped the connection on a Mailbox/get that
// selects it (42751a6b). So this is the only module that asks for it: only
// for an account whose capabilities offer vendor namespaces on mailboxes,
// in a request of its own, and any failure pauses that account for ten
// minutes. Every other Mailbox/get keeps its explicit property list.
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
export const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata';
export const BULWARK_NAMESPACE = 'org.bulwarkmail';
const PAUSE_AFTER_FAILURE_MS = 10 * 60_000;
/**
* Whether the account's capabilities offer vendor-namespaced metadata on
* mailboxes. Says nothing about whether the server really implements it.
*/
export function advertisesMailboxMetadata(accountCapabilities: Record<string, unknown> | undefined): boolean {
const cap = accountCapabilities?.[METADATA_CAPABILITY] as
| { dataTypes?: Record<string, { supportsVendorNamespaces?: boolean }> }
| undefined;
return cap?.dataTypes?.Mailbox?.supportsVendorNamespaces === true;
}
/** Bulwark's entries of a `metadata` object, by key (`tags`, `template.<id>`). */
export function bulwarkEntries(metadata: unknown): Record<string, unknown> {
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return {};
const prefix = `${BULWARK_NAMESPACE}.`;
const out: Record<string, unknown> = {};
for (const [namespace, value] of Object.entries(metadata as Record<string, unknown>)) {
if (namespace.startsWith(prefix)) out[namespace.slice(prefix.length)] = value;
}
return out;
}
// Accounts whose last read failed, by server and account, with when.
const failedAt = new Map<string, number>();
const pauseKey = (accountId: string) => `${jmapClient.serverUrl ?? ''}|${accountId}`;
/** Whether the app may ask this account for its Inbox metadata now. */
export function supportsBulwarkMetadata(accountId: string): boolean {
const failed = failedAt.get(pauseKey(accountId));
if (failed !== undefined && Date.now() - failed < PAUSE_AFTER_FAILURE_MS) return false;
const account = jmapClient.currentSession?.accounts?.[accountId] as
| { accountCapabilities?: Record<string, unknown> }
| undefined;
return advertisesMailboxMetadata(account?.accountCapabilities);
}
/**
* Bulwark's shared metadata entries of the account's Inbox, by key. Null
* when the account has none to offer (0.16) or the read failed; no request
* goes out for an account that doesn't advertise Metadata.
*/
export async function getBulwarkMetadata(accountId: string): Promise<Record<string, unknown> | null> {
if (!supportsBulwarkMetadata(accountId)) return null;
const property = 'metadata';
try {
const res = await jmapClient.request([
['Mailbox/query', { accountId, filter: { role: 'inbox' } }, 'q'],
['Mailbox/get', {
accountId,
'#ids': { resultOf: 'q', name: 'Mailbox/query', path: '/ids' },
properties: ['id', property],
}, 'g'],
], [CAPABILITIES.CORE, CAPABILITIES.MAIL, METADATA_CAPABILITY]);
const get = res.methodResponses?.find((r) => r[0] === 'Mailbox/get');
const inbox = (get?.[1] as { list?: Array<Record<string, unknown>> } | undefined)?.list?.[0];
// An answer without the property is a server that doesn't keep it,
// never "every entry was removed".
if (!inbox || !(property in inbox)) throw new Error('No Inbox metadata in the answer');
failedAt.delete(pauseKey(accountId));
return bulwarkEntries(inbox[property]);
} catch (err) {
console.warn('[bulwark-metadata] unavailable for account', accountId, err);
failedAt.set(pauseKey(accountId), Date.now());
return null;
}
}
/** Forget the failure pauses (sign-out, tests). */
export function resetBulwarkMetadataPauses(): void {
failedAt.clear();
}