feat: read and write account metadata for shared Bulwark data

This commit is contained in:
Linus Rath
2026-10-28 01:14:30 +01:00
parent 42f52ab066
commit 7ea0a7d58d
4 changed files with 297 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { JMAPClient } from '../jmap/client';
import { MetadataTooLargeError, MetadataUnsupportedError, bulwarkEntries, isValidMetadataKey } from '../jmap/bulwark-metadata';
// Bulwark keeps shared tags and templates in the Inbox metadata of an
// account, one namespace per item (each namespace holds at most 8 KB on
// Stalwart v1.0.0). Servers without metadata, and the pre-release 1.0 build
// that drops the connection on these calls, count as "not supported".
const METADATA_CAP = {
'urn:ietf:params:jmap:metadata': {
dataTypes: { Mailbox: { namespaces: [], supportsVendorNamespaces: true, supportsPrivate: true, maxDepth: 8 } },
},
};
function session() {
return {
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:metadata': {} },
accounts: {
me: { name: 'me@example.org', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...METADATA_CAP } },
team: { name: 'team@example.org', isPersonal: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...METADATA_CAP } },
old: { name: 'old@example.org', isPersonal: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } },
},
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'me' },
apiUrl: 'https://mail.example.com/jmap/api',
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
eventSourceUrl: '',
};
}
const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
let fetchSpy: ReturnType<typeof vi.spyOn>;
let client: JMAPClient;
let requests: Array<{ using: string[]; methodCalls: Array<[string, Record<string, unknown>, string]> }>;
let answer: (body: (typeof requests)[number]) => Response | Promise<Response>;
beforeEach(async () => {
vi.useRealTimers();
fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValueOnce(json(session()));
client = new JMAPClient('https://mail.example.com', 'me@example.org', 'pw');
await client.connect();
fetchSpy.mockReset();
requests = [];
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body = JSON.parse(String(init?.body ?? '{}'));
requests.push(body);
return answer(body);
});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => vi.restoreAllMocks());
const inboxAnswer = (metadata: unknown, property = 'metadata') => json({
methodResponses: [
['Mailbox/query', { ids: ['inbox-1'] }, 'q'],
['Mailbox/get', { list: [{ id: 'inbox-1', [property]: metadata }] }, 'g'],
],
});
describe('getBulwarkMetadata', () => {
it('reads Bulwark\'s namespaces of the Inbox in a request of its own', async () => {
answer = () => inboxAnswer({ 'org.bulwarkmail.tags': [{ id: 'urgent' }], 'org.bulwarkmail.template.a': { name: 'A' }, 'other.example': 1 });
expect(await client.getBulwarkMetadata('team')).toEqual({ tags: [{ id: 'urgent' }], 'template.a': { name: 'A' } });
expect(requests).toHaveLength(1);
expect(requests[0].using).toContain('urn:ietf:params:jmap:metadata');
expect(requests[0].methodCalls.map((c) => c[0])).toEqual(['Mailbox/query', 'Mailbox/get']);
expect(requests[0].methodCalls[1][1].properties).toEqual(['id', 'metadata']);
});
it('reads private metadata when asked', async () => {
answer = () => inboxAnswer({ 'org.bulwarkmail.templates': { x: 1 } }, 'privateMetadata');
expect(await client.getBulwarkMetadata('me', { private: true })).toEqual({ templates: { x: 1 } });
expect(requests[0].methodCalls[1][1].properties).toEqual(['id', 'privateMetadata']);
});
it('asks nothing of an account without the capability', async () => {
answer = () => { throw new Error('should not be asked'); };
expect(client.supportsBulwarkMetadata('old')).toBe(false);
expect(await client.getBulwarkMetadata('old')).toBeNull();
expect(requests).toHaveLength(0);
});
it('treats a dropped connection as unsupported and leaves the account alone afterwards', async () => {
answer = () => { throw new TypeError('Failed to fetch'); };
expect(await client.getBulwarkMetadata('team')).toBeNull();
const asked = requests.length;
expect(client.supportsBulwarkMetadata('team')).toBe(false);
expect(await client.getBulwarkMetadata('team')).toBeNull();
expect(requests).toHaveLength(asked);
// Other accounts are not affected.
expect(client.supportsBulwarkMetadata('me')).toBe(true);
});
});
describe('setBulwarkMetadata', () => {
it('patches one namespace of the Inbox and nothing else', async () => {
answer = (body) => body.methodCalls[0][0] === 'Mailbox/set'
? json({ methodResponses: [['Mailbox/set', { updated: { 'inbox-1': null } }, '0']] })
: inboxAnswer({});
await client.setBulwarkMetadata('team', 'tags', [{ id: 'urgent', color: '#f00' }]);
const set = requests.find((r) => r.methodCalls[0][0] === 'Mailbox/set')!;
expect(set.methodCalls[0][1]).toEqual({
accountId: 'team',
update: { 'inbox-1': { 'metadata/org.bulwarkmail.tags': [{ id: 'urgent', color: '#f00' }] } },
});
});
it('refuses an entry over 8 KB before asking, and passes on the server\'s size refusal', async () => {
answer = (body) => body.methodCalls[0][0] === 'Mailbox/set'
? json({ methodResponses: [['Mailbox/set', { notUpdated: { 'inbox-1': { type: 'invalidProperties', description: 'Metadata size 70413 exceeds the maximum of 65536 bytes.' } } }, '0']] })
: inboxAnswer({});
await expect(client.setBulwarkMetadata('team', 'template.big', { body: 'x'.repeat(9000) })).rejects.toBeInstanceOf(MetadataTooLargeError);
expect(requests.some((r) => r.methodCalls[0][0] === 'Mailbox/set')).toBe(false);
await expect(client.setBulwarkMetadata('team', 'template.ok', { body: 'small' })).rejects.toBeInstanceOf(MetadataTooLargeError);
});
it('says unsupported for an account without metadata', async () => {
await expect(client.setBulwarkMetadata('old', 'tags', [])).rejects.toBeInstanceOf(MetadataUnsupportedError);
});
});
describe('helpers', () => {
it('accepts lowercase dotted keys only', () => {
expect(isValidMetadataKey('template.abc-1')).toBe(true);
expect(isValidMetadataKey('Tags')).toBe(false);
expect(isValidMetadataKey('a/b')).toBe(false);
expect(isValidMetadataKey('.x')).toBe(false);
});
it('ignores other vendors and non-objects', () => {
expect(bulwarkEntries(null)).toEqual({});
expect(bulwarkEntries({ 'org.bulwarkmail': 1, 'org.bulwarkmailx.a': 2, 'org.bulwarkmail.a': 3 })).toEqual({ a: 3 });
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* Bulwark's own data in JMAP metadata (urn:ietf:params:jmap:metadata) of an
* account's Inbox: shared tag definitions and team templates in a group
* account, personal templates in the user's own account.
*
* What Stalwart v1.0.0 does (local-data/org-features-facts.md):
* - vendor namespaces need a dot ("org.bulwarkmail" works, "bulwark" is
* refused); each namespace holds at most 8 KB, one mailbox at most 64 KB
* of shared and 16 KB of private metadata;
* - `metadata/<namespace>` patches one namespace and leaves the others;
* - every member of a group account sees its shared metadata; private
* metadata is per user.
*
* So every item gets a namespace of its own, `org.bulwarkmail.<key>`. 0.16
* has no metadata, and a pre-release 1.0 build advertises it but drops the
* connection on Mailbox/get with `metadata`; the calls go in a request of
* their own and any failure means "not supported" for that account.
*/
export const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata';
export const BULWARK_NAMESPACE = 'org.bulwarkmail';
/** Largest value Stalwart's default config takes in one namespace. */
export const MAX_METADATA_ENTRY_BYTES = 8192;
const KEY_RE = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$/;
export class MetadataTooLargeError extends Error {
constructor(message: string) {
super(message);
this.name = 'MetadataTooLargeError';
}
}
export class MetadataUnsupportedError extends Error {
constructor(message = 'This server keeps no metadata for Bulwark') {
super(message);
this.name = 'MetadataUnsupportedError';
}
}
/** `tags`, `template.<id>`: lowercase, digits, dashes, dot-separated. */
export function isValidMetadataKey(key: string): boolean {
return key.length <= 120 && KEY_RE.test(key);
}
export function namespaceFor(key: string): string {
if (!isValidMetadataKey(key)) throw new Error(`Invalid metadata key: ${key}`);
return `${BULWARK_NAMESPACE}.${key}`;
}
/**
* 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; supportsPrivate?: boolean }> }
| undefined;
return cap?.dataTypes?.Mailbox?.supportsVendorNamespaces === true;
}
/** Bulwark's entries of a `metadata` or `privateMetadata` object, by key. */
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;
}
/** The size Stalwart counts for a value: its JSON in UTF-8. */
export function metadataValueBytes(value: unknown): number {
return new TextEncoder().encode(JSON.stringify(value)).length;
}
+4
View File
@@ -610,6 +610,10 @@ export interface IJMAPClient {
listPrincipals?(targetAccountId?: string, options?: { throwOnError?: boolean }): Promise<PrincipalListing>;
/** Principal type by id ("individual", "group"); null when unknown. Optional (demo client). */
getPrincipalTypes?(ids: string[]): Promise<Record<string, string> | null>;
// Bulwark data in Inbox metadata (lib/jmap/bulwark-metadata.ts). Optional (demo client).
supportsBulwarkMetadata?(accountId?: string): boolean;
getBulwarkMetadata?(accountId?: string, options?: { private?: boolean }): Promise<Record<string, unknown> | null>;
setBulwarkMetadata?(accountId: string | undefined, key: string, value: unknown, options?: { private?: boolean }): Promise<void>;
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise<void>;
+79
View File
@@ -1,3 +1,4 @@
import { advertisesMailboxMetadata, bulwarkEntries, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
import { IncompleteDeleteError } from "./delete-error";
import { ArchiveEmailsError } from "./archive-error";
import { generateUUID } from '@/lib/utils';
@@ -7076,6 +7077,84 @@ export class JMAPClient implements IJMAPClient {
}
}
/**
* Accounts whose metadata request failed, with when: not asked again for
* ten minutes. A dropped connection (the pre-release 1.0 build) looks the
* same as a network blip, so the pause is not for the whole session.
*/
private metadataUnsupported = new Map<string, number>();
/** Whether Bulwark may keep data in this account's Inbox metadata (lib/jmap/bulwark-metadata.ts). */
supportsBulwarkMetadata(accountId?: string): boolean {
const id = accountId || this.accountId;
const failedAt = this.metadataUnsupported.get(id);
if (failedAt !== undefined && Date.now() - failedAt < 10 * 60_000) return false;
return advertisesMailboxMetadata(this.accounts[id]?.accountCapabilities as Record<string, unknown> | undefined);
}
/**
* Bulwark's metadata entries of the account's Inbox, by key, shared or the
* user's private ones. Null when the server has no usable metadata.
*/
async getBulwarkMetadata(accountId?: string, options?: { private?: boolean }): Promise<Record<string, unknown> | null> {
const id = accountId || this.accountId;
if (!this.supportsBulwarkMetadata(id)) return null;
const property = options?.private ? "privateMetadata" : "metadata";
try {
// A request of its own: a server that can't answer it must not take
// other calls down with it.
const response = await this.request([
["Mailbox/query", { accountId: id, filter: { role: "inbox" } }, "q"],
["Mailbox/get", { accountId: id, "#ids": { resultOf: "q", name: "Mailbox/query", path: "/ids" }, properties: ["id", property] }, "g"],
], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", METADATA_CAPABILITY]);
const get = response.methodResponses?.find((r) => r[0] === "Mailbox/get");
const inbox = (get?.[1] as { list?: Array<Record<string, unknown>> } | undefined)?.list?.[0];
if (!inbox) throw new MetadataUnsupportedError("No Inbox metadata in the answer");
this.metadataInboxIds.set(id, inbox.id as string);
this.metadataUnsupported.delete(id);
return bulwarkEntries(inbox[property]);
} catch (error) {
debug.warn('jmap', `Metadata unavailable for account ${id}:`, error);
this.metadataUnsupported.set(id, Date.now());
return null;
}
}
private metadataInboxIds = new Map<string, string>();
/**
* Writes one Bulwark metadata entry of the account's Inbox (null removes
* it), leaving every other entry as it is. Throws MetadataTooLargeError
* when the server refuses the size, MetadataUnsupportedError when the
* account has no metadata.
*/
async setBulwarkMetadata(accountId: string | undefined, key: string, value: unknown, options?: { private?: boolean }): Promise<void> {
const id = accountId || this.accountId;
if (!this.supportsBulwarkMetadata(id)) throw new MetadataUnsupportedError();
const property = options?.private ? "privateMetadata" : "metadata";
if (value !== null && metadataValueBytes(value) > MAX_METADATA_ENTRY_BYTES) {
throw new MetadataTooLargeError(`The entry is larger than ${MAX_METADATA_ENTRY_BYTES} bytes`);
}
let inboxId = this.metadataInboxIds.get(id);
if (!inboxId) {
await this.getBulwarkMetadata(id, options);
inboxId = this.metadataInboxIds.get(id);
if (!inboxId) throw new MetadataUnsupportedError();
}
const response = await this.request([
["Mailbox/set", { accountId: id, update: { [inboxId]: { [`${property}/${namespaceFor(key)}`]: value } } }, "0"],
], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", METADATA_CAPABILITY]);
const [method, result] = response.methodResponses?.[0] ?? [];
if (method !== "Mailbox/set") {
throw jmapError((result as JmapErrorInfo | undefined)?.description || "Failed to save metadata", result as JmapErrorInfo | undefined);
}
const notUpdated = (result as { notUpdated?: Record<string, { type?: string; description?: string }> }).notUpdated?.[inboxId];
if (notUpdated) {
if (/exceeds the maximum/i.test(notUpdated.description ?? "")) throw new MetadataTooLargeError(notUpdated.description!);
throw new Error(notUpdated.description || `Failed to save metadata (${notUpdated.type ?? "unknown"})`);
}
}
/**
* The busy periods of a principal between `utcStart` and `utcEnd`
* (Principal/getAvailability). Event details are never requested: this