mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-06 08:07:23 +00:00
feat: show the shared tags of group mailboxes, read from their metadata on Stalwart 1.0
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user