mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
feat: keep templates in the account on Stalwart 1.0 and offer team templates in the picker
This commit is contained in:
@@ -11,6 +11,10 @@ import path from 'path';
|
||||
* and the gets whose server default could grow to include metadata ask for an
|
||||
* explicit property list instead. Port of the webmail's
|
||||
* lib/__tests__/jmap-metadata-requests.test.ts.
|
||||
*
|
||||
* Stalwart 1.0.0 (the release) serves Mailbox metadata, and templates are
|
||||
* kept there (src/api/bulwark-metadata.ts, in requests of their own, only
|
||||
* for accounts that advertise it). Everything else still never asks for it.
|
||||
*/
|
||||
|
||||
const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata';
|
||||
@@ -239,8 +243,12 @@ describe('JMAP Metadata in the request-building sources', () => {
|
||||
/(['"`])metadata(\/[^'"`\n]*)?\1/,
|
||||
];
|
||||
|
||||
// The one module that may: Bulwark's own entries on the Inbox, asked for
|
||||
// in requests of their own.
|
||||
const METADATA_MODULE = 'src/api/bulwark-metadata.ts';
|
||||
|
||||
it('never names a JMAP Metadata property, argument or capability in code', () => {
|
||||
const files = [...sourceFiles('src'), 'App.tsx'];
|
||||
const files = [...sourceFiles('src'), 'App.tsx'].filter((file) => file !== METADATA_MODULE);
|
||||
expect(files).toContain('src/api/email.ts');
|
||||
const hits: string[] = [];
|
||||
for (const file of files) {
|
||||
@@ -252,4 +260,9 @@ describe('JMAP Metadata in the request-building sources', () => {
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps privateMetadata and ignoreMetadataOnlyChanges out of the metadata module too', () => {
|
||||
const code = withoutComments(readFileSync(path.join(root, METADATA_MODULE), 'utf8'));
|
||||
expect(code).not.toMatch(/privateMetadata|ignoreMetadataOnlyChanges/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// Bulwark's own data in JMAP Metadata (RFC draft, urn:ietf:params:jmap:metadata)
|
||||
// of an account's Inbox: personal templates in the user's own account, team
|
||||
// templates in a group account (webmail lib/jmap/bulwark-metadata.ts).
|
||||
//
|
||||
// What Stalwart does: 1.0.0 serves it (seen live 2026-10-29: Mailbox/get and
|
||||
// Mailbox/set with `metadata/<namespace>` pointers on the Inbox, at most
|
||||
// 8 KB per namespace, "Metadata value size … exceeds the maximum of 8192
|
||||
// bytes" above that). Vendor namespaces need a dot, so every item gets one
|
||||
// of its own: `org.bulwarkmail.<key>`. 0.16 has no metadata and refuses the
|
||||
// capability in `using` (notRequest); an account that doesn't advertise it
|
||||
// is never asked. A pre-release 1.0 build advertised it but dropped the
|
||||
// connection on such a request, so the calls go in requests of their own
|
||||
// and any failure pauses that account for ten minutes.
|
||||
//
|
||||
// This is the only module that names the Metadata capability or property
|
||||
// (src/api/__tests__/jmap-metadata-requests.test.ts).
|
||||
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES, type JMAPMethodCall } from './types';
|
||||
|
||||
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-]*)*$/;
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL, METADATA_CAPABILITY];
|
||||
const PAUSE_MS = 10 * 60_000;
|
||||
|
||||
export class MetadataTooLargeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'MetadataTooLargeError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The server refused a metadata change; `type` is its SetError type. */
|
||||
export class MetadataRefusedError extends Error {
|
||||
constructor(readonly type: string, message: string) {
|
||||
super(message);
|
||||
this.name = 'MetadataRefusedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class MetadataUnsupportedError extends Error {
|
||||
constructor(message = 'This server keeps no metadata for Bulwark') {
|
||||
super(message);
|
||||
this.name = 'MetadataUnsupportedError';
|
||||
}
|
||||
}
|
||||
|
||||
/** `templates`, `template.<id>`: lowercase, digits, dashes, dot-separated. */
|
||||
export function isValidMetadataKey(key: string): boolean {
|
||||
return key.length <= 120 && KEY_RE.test(key);
|
||||
}
|
||||
|
||||
function namespaceFor(key: string): string {
|
||||
if (!isValidMetadataKey(key)) throw new Error(`Invalid metadata key: ${key}`);
|
||||
return `${BULWARK_NAMESPACE}.${key}`;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Bulwark's entries of a metadata 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;
|
||||
}
|
||||
|
||||
// Account ids repeat across servers and logins.
|
||||
const keyOf = (accountId: string) => `${jmapClient.serverUrl ?? ''}\u0000${jmapClient.username ?? ''}\u0000${accountId}`;
|
||||
const failedAt = new Map<string, number>();
|
||||
const inboxIds = new Map<string, string>();
|
||||
|
||||
/** Forget paused accounts and Inbox ids (tests). */
|
||||
export function resetBulwarkMetadata(): void {
|
||||
failedAt.clear();
|
||||
inboxIds.clear();
|
||||
}
|
||||
|
||||
/** Whether the account advertises vendor metadata on mailboxes, whatever its last request did. */
|
||||
export function advertisesBulwarkMetadata(accountId: string): boolean {
|
||||
const caps = jmapClient.currentSession?.accounts?.[accountId]?.accountCapabilities as Record<string, unknown> | undefined;
|
||||
const cap = caps?.[METADATA_CAPABILITY] as
|
||||
| { dataTypes?: Record<string, { supportsVendorNamespaces?: boolean }> }
|
||||
| undefined;
|
||||
return cap?.dataTypes?.Mailbox?.supportsVendorNamespaces === true;
|
||||
}
|
||||
|
||||
/** Whether Bulwark may keep data in this account's Inbox metadata now. */
|
||||
export function supportsBulwarkMetadata(accountId: string): boolean {
|
||||
const at = failedAt.get(keyOf(accountId));
|
||||
if (at !== undefined && Date.now() - at < PAUSE_MS) return false;
|
||||
return advertisesBulwarkMetadata(accountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulwark's metadata entries of the account's Inbox, by key. Null when the
|
||||
* account has no usable metadata; a failure pauses the account.
|
||||
*/
|
||||
export async function getBulwarkMetadata(accountId: string): Promise<Record<string, unknown> | null> {
|
||||
if (!supportsBulwarkMetadata(accountId)) return null;
|
||||
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', 'metadata'],
|
||||
}, 'g'],
|
||||
] as JMAPMethodCall[], USING);
|
||||
const get = res.methodResponses.find(([name]) => name === '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 || !('metadata' in inbox)) throw new MetadataUnsupportedError('No Inbox metadata in the answer');
|
||||
inboxIds.set(keyOf(accountId), inbox.id as string);
|
||||
failedAt.delete(keyOf(accountId));
|
||||
return bulwarkEntries(inbox.metadata);
|
||||
} catch (err) {
|
||||
console.warn(`[metadata] unavailable for account ${accountId}:`, err);
|
||||
failedAt.set(keyOf(accountId), Date.now());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one Bulwark entry of the account's Inbox metadata (null removes it),
|
||||
* leaving every other entry as it is. Throws MetadataTooLargeError when the
|
||||
* value is over the size the server takes, MetadataRefusedError for another
|
||||
* refusal, MetadataUnsupportedError when the account has none.
|
||||
*/
|
||||
export async function setBulwarkMetadata(accountId: string, key: string, value: unknown): Promise<void> {
|
||||
if (!supportsBulwarkMetadata(accountId)) throw new MetadataUnsupportedError();
|
||||
if (value !== null && metadataValueBytes(value) > MAX_METADATA_ENTRY_BYTES) {
|
||||
throw new MetadataTooLargeError(`The entry is larger than ${MAX_METADATA_ENTRY_BYTES} bytes`);
|
||||
}
|
||||
let inboxId = inboxIds.get(keyOf(accountId));
|
||||
if (!inboxId) {
|
||||
await getBulwarkMetadata(accountId);
|
||||
inboxId = inboxIds.get(keyOf(accountId));
|
||||
if (!inboxId) throw new MetadataUnsupportedError();
|
||||
}
|
||||
const res = await jmapClient.request(
|
||||
[['Mailbox/set', { accountId, update: { [inboxId]: { [`metadata/${namespaceFor(key)}`]: value } } }, '0']],
|
||||
USING,
|
||||
);
|
||||
const [name, body] = res.methodResponses[0] ?? [];
|
||||
if (name !== 'Mailbox/set') {
|
||||
throw new Error((body as { description?: string } | undefined)?.description || 'Failed to save metadata');
|
||||
}
|
||||
const refused = (body as { notUpdated?: Record<string, { type?: string; description?: string }> }).notUpdated?.[inboxId];
|
||||
if (refused) {
|
||||
if (/exceeds the maximum/i.test(refused.description ?? '')) throw new MetadataTooLargeError(refused.description!);
|
||||
throw new MetadataRefusedError(refused.type ?? 'unknown', refused.description || `Failed to save metadata (${refused.type ?? 'unknown'})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The group accounts of the session: shared accounts whose principal is a
|
||||
* group (Stalwart uses the account id as the principal id). A mailbox
|
||||
* another person shared is not a team. Empty when the server won't say.
|
||||
*/
|
||||
export async function groupAccounts(): Promise<Array<{ id: string; name: string }>> {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session || !jmapClient.hasCapability(CAPABILITIES.PRINCIPALS)) return [];
|
||||
const own = jmapClient.accountId;
|
||||
const shared = Object.entries(session.accounts ?? {})
|
||||
.filter(([id, account]) => id !== own && account && account.isPersonal === false);
|
||||
if (shared.length === 0) return [];
|
||||
try {
|
||||
const res = await jmapClient.request(
|
||||
[['Principal/get', { accountId: own, ids: shared.map(([id]) => id), properties: ['id', 'type'] }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS],
|
||||
);
|
||||
const [name, body] = res.methodResponses[0] ?? [];
|
||||
if (name !== 'Principal/get') return [];
|
||||
const types = new Map(((body as { list?: Array<{ id: string; type?: string }> }).list ?? []).map((p) => [p.id, p.type]));
|
||||
return shared.filter(([id]) => types.get(id) === 'group').map(([id, account]) => ({ id, name: account.name }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user