mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
feat: read and write account metadata for shared Bulwark data
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user