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
+7
View File
@@ -1316,6 +1316,13 @@
}
}
},
"shared_tags": {
"description": "Tags of your group mailboxes. Everyone in the group sees the same names and colors on its mail.",
"empty": "No shared tags yet.",
"loading": "Loading…",
"title": "Shared tags",
"unsupported": "This mail server can't keep shared tags, so this mailbox shows your own tags."
},
"sharing": {
"directory_failed": "Couldn't load the list of users and groups.",
"directory_not_browsable": "Your server doesn't let you browse its user directory.",
@@ -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();
}
+1
View File
@@ -84,6 +84,7 @@ export function MessageContent({
onToggleStar={onToggleStar}
onAddressPress={onAddressPress}
compact={compact}
jmapAccountId={jmapAccountId}
/>
{attachmentPosition === 'beside-sender' && chips}
</View>
+5 -3
View File
@@ -9,7 +9,7 @@ import { useColors } from '../../theme/colors';
import SenderAvatar from '../SenderAvatar';
import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useKeywordsStore } from '../../stores/keywords-store';
import { useAccountKeywordDefs } from '../../stores/keywords-store';
import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date';
import {
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity,
@@ -30,6 +30,8 @@ interface Props {
onAddressPress: (address: EmailAddress) => void;
/** Compact variant for collapsed thread cards. */
compact?: boolean;
/** JMAP account the message lives in (a group's shared tags name its tags). */
jmapAccountId?: string;
}
function addressLabel(a: EmailAddress): string {
@@ -94,13 +96,13 @@ function DetailRow({ label, value, styles, mono }: { label: string; value?: stri
* "Show details" panel (recipients & routing, authentication, identifiers,
* mailing list, properties) the webmail viewer shows.
*/
export function MessageHeader({ email, identities, headerInfo, onToggleStar, onAddressPress, compact }: Props) {
export function MessageHeader({ email, identities, headerInfo, onToggleStar, onAddressPress, compact, jmapAccountId }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const locale = useLocaleStore((s) => s.locale);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const keywordDefs = useKeywordsStore((s) => s.keywords);
const keywordDefs = useAccountKeywordDefs(jmapAccountId ?? email.jmapAccountId);
const showVerificationCodes = useSettingsStore((s) => s.showVerificationCodes);
const [showDetails, setShowDetails] = React.useState(false);
React.useEffect(() => { setShowDetails(false); }, [email.id]);
+69 -1
View File
@@ -4,7 +4,9 @@ import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from '../icons'
import { SettingsSection } from './settings-section';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useKeywordsStore, type KeywordDef } from '../../stores/keywords-store';
import { useKeywordsStore, currentTagsLogin, type KeywordDef } from '../../stores/keywords-store';
import { useEmailStore } from '../../stores/email-store';
import { supportsBulwarkMetadata } from '../../api/bulwark-metadata';
import { DARK_COLORS } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store';
import { discoverKeywords } from '../../api/keyword-discovery';
@@ -203,10 +205,72 @@ export function KeywordSettings() {
</View>
)}
</View>
<SharedTagsSection />
</SettingsSection>
);
}
/**
* The shared tags of each group mailbox in the sidebar (#1310): what every
* member sees on its mail, read from the group's Inbox metadata. Shown, not
* edited here; Bulwark on the web edits them.
*/
function SharedTagsSection() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const mailboxes = useEmailStore((s) => s.mailboxes);
const sharedTags = useKeywordsStore((s) => s.sharedTags);
const loadSharedTags = useKeywordsStore((s) => s.loadSharedTags);
const accounts = React.useMemo(() => {
const seen = new Map<string, string>();
for (const m of mailboxes) {
if (m.isShared && m.accountId && !seen.has(m.accountId)) seen.set(m.accountId, m.accountName ?? m.accountId);
}
return [...seen].map(([id, name]) => ({ id, name }));
}, [mailboxes]);
useEffect(() => {
for (const account of accounts) void loadSharedTags(account.id);
}, [accounts, loadSharedTags]);
if (accounts.length === 0) return null;
const login = currentTagsLogin();
return (
<View style={styles.sharedBlock}>
<Text style={styles.sharedTitle}>{t('shared_tags.title', 'Shared tags')}</Text>
<Text style={styles.scanStatus}>
{t('shared_tags.description', 'Tags of your group mailboxes. Everyone in the group sees the same names and colors on its mail.')}
</Text>
{accounts.map((account) => {
const entry = sharedTags[account.id];
const tags = entry && entry.login === login ? entry.tags : null;
return (
<View key={account.id} style={styles.sharedAccount}>
<Text style={styles.sharedAccountName}>{account.name}</Text>
{tags === null ? (
<Text style={styles.scanStatus}>
{supportsBulwarkMetadata(account.id)
? t('shared_tags.loading', 'Loading…')
: t('shared_tags.unsupported', "This mail server can't keep shared tags, so this mailbox shows your own tags.")}
</Text>
) : tags.length === 0 ? (
<Text style={styles.scanStatus}>{t('shared_tags.empty', 'No shared tags yet.')}</Text>
) : (
tags.map((kw) => (
<View key={kw.id} style={styles.kwRow}>
<View style={[styles.kwDot, { backgroundColor: c.tags[kw.color]?.dot ?? c.textMuted }]} />
<Text style={styles.kwLabel}>{kw.label}</Text>
<Text style={styles.kwId}>$label:{kw.id}</Text>
</View>
))
)}
</View>
);
})}
</View>
);
}
interface KeywordFormProps {
initial?: Keyword;
existingIds: string[];
@@ -335,6 +399,10 @@ function makeStyles(c: ThemePalette) {
outlineBtnText: { ...typography.caption, color: c.mutedForeground },
scanStatus: { ...typography.caption, color: c.mutedForeground, paddingTop: spacing.xs },
scanResults: { gap: spacing.sm },
sharedBlock: { gap: spacing.sm, marginTop: spacing.lg },
sharedTitle: { ...typography.bodySemibold, color: c.text },
sharedAccount: { gap: spacing.sm },
sharedAccountName: { ...typography.caption, color: c.textSecondary, fontWeight: '600' },
form: {
gap: spacing.md,
padding: spacing.md,
+64
View File
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { keywordColorFromPalette, mergeSharedTags, parseSharedTags } from '../shared-tags';
describe('parseSharedTags', () => {
// The map shape the webmail writes (lib/shared-tags.ts).
it('reads the map by tag id, sorted by name, keeping unknown fields out', () => {
expect(parseSharedTags({
tags: {
urgent: { label: 'Urgent', color: 'red-dark', visibility: 'show', by: 'x' },
billing: { label: ' Billing ', color: 'teal' },
unnamed: { color: 'blue' },
},
version: 2,
})).toEqual([
{ id: 'billing', label: 'Billing', color: 'teal' },
{ id: 'unnamed', label: 'unnamed', color: 'blue' },
{ id: 'urgent', label: 'Urgent', color: 'red' },
]);
});
it('reads the earlier list and bare-array shapes, once per id', () => {
const list = [{ id: 'a', label: 'A', color: 'green' }, { id: 'A', label: 'dup' }, { label: 'no id' }];
expect(parseSharedTags({ list })).toEqual([{ id: 'a', label: 'A', color: 'green' }]);
expect(parseSharedTags(list)).toEqual([{ id: 'a', label: 'A', color: 'green' }]);
});
it('drops ids no keyword can hold and caps long names', () => {
const tags = parseSharedTags({
tags: { 'has space': { label: 'x' }, 'a*b': { label: 'y' }, ok: { label: 'z'.repeat(50) } },
});
expect(tags.map((t) => t.id)).toEqual(['ok']);
expect(tags[0].label).toHaveLength(30);
});
it('reads nothing from junk', () => {
expect(parseSharedTags(undefined)).toEqual([]);
expect(parseSharedTags('tags')).toEqual([]);
expect(parseSharedTags({ tags: [1, 2] })).toEqual([]);
});
});
describe('keywordColorFromPalette', () => {
it("maps the webmail's shades onto the app's colours and unknown ones to grey", () => {
expect(keywordColorFromPalette('blue-light')).toBe('blue');
expect(keywordColorFromPalette('amber')).toBe('amber');
expect(keywordColorFromPalette('magenta')).toBe('gray');
expect(keywordColorFromPalette(7)).toBe('gray');
});
});
describe('mergeSharedTags', () => {
it('lets the shared definition name a tag and keeps the own ones nobody shared', () => {
const shared = [{ id: 'Red', label: 'Escalated', color: 'red' as const }];
const personal = [
{ id: 'red', label: 'Red', color: 'red' as const },
{ id: 'blue', label: 'Blue', color: 'blue' as const },
];
expect(mergeSharedTags(shared, personal)).toEqual([
{ id: 'Red', label: 'Escalated', color: 'red' },
{ id: 'blue', label: 'Blue', color: 'blue' },
]);
expect(mergeSharedTags([], personal)).toBe(personal);
});
});
+98
View File
@@ -0,0 +1,98 @@
// Shared tags of a group account: the tag names and colours every member of
// a shared mailbox sees, kept in the metadata of the group's Inbox
// (namespace `org.bulwarkmail.tags`, src/api/bulwark-metadata.ts). Port of
// the reading half of the webmail's lib/shared-tags.ts (#1310, #470).
//
// Keywords in a group account are the same for every member, so
// `$label:urgent` on a team message is the team's tag; the shared
// definitions name it for everyone. Stored as a map by tag id,
// `{ "tags": { "<id>": { "label", "color", ... } } }`; the earlier
// `{ "list": [...] }` and bare-array shapes are read too. The app only
// reads them: editing them is the webmail's (Settings > Tags).
import { colors } from '../theme/tokens';
import type { KeywordColor, KeywordDef } from '../stores/keywords-store';
// keywords-store's FALLBACK_KEYWORD_COLOR; not imported, that store imports this module.
const FALLBACK_KEYWORD_COLOR: KeywordColor = 'gray';
/** The metadata key (namespace `org.bulwarkmail.tags`). */
export const SHARED_TAGS_KEY = 'tags';
/** Longest name a shared tag keeps, as in the webmail's tag editor. */
export const MAX_SHARED_TAG_LABEL = 30;
// RFC 8621 keywords are at most 128 bytes, `$label:` included.
const MAX_KEYWORD_ID_LENGTH = 128 - '$label:'.length;
/**
* Characters RFC 8621 keywords can't hold (and whitespace), so a tag id
* from the server that could never be on a message is dropped.
*/
const BAD_ID_CHARS = /[\s(){\]%*"\\]/;
function isKeywordSafe(id: string): boolean {
if (!id || id.length > MAX_KEYWORD_ID_LENGTH || BAD_ID_CHARS.test(id)) return false;
for (let i = 0; i < id.length; i++) {
const code = id.charCodeAt(i);
if (code < 0x20 || code === 0x7f) return false;
}
return true;
}
const isObject = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
/**
* The app's colour for a webmail palette key. The webmail has a light and a
* dark shade of each colour (`blue-light`, `blue-dark`); the app has one.
* A colour neither knows shows grey.
*/
export function keywordColorFromPalette(color: unknown): KeywordColor {
if (typeof color !== 'string') return FALLBACK_KEYWORD_COLOR;
const base = color.replace(/-(?:light|dark)$/, '');
return Object.prototype.hasOwnProperty.call(colors.tags, base) ? base as KeywordColor : FALLBACK_KEYWORD_COLOR;
}
/** The raw entry of each tag by id, from any of the stored shapes. */
function entriesOf(value: unknown): Record<string, Record<string, unknown>> {
const entries: Record<string, Record<string, unknown>> = {};
if (isObject(value) && isObject(value.tags)) {
for (const [id, entry] of Object.entries(value.tags)) {
if (isKeywordSafe(id) && isObject(entry)) entries[id] = entry;
}
return entries;
}
const items = Array.isArray(value) ? value : isObject(value) && Array.isArray(value.list) ? value.list : [];
const seen = new Set<string>();
for (const item of items) {
if (!isObject(item) || typeof item.id !== 'string') continue;
const id = item.id.trim();
if (!isKeywordSafe(id) || seen.has(id.toLowerCase())) continue;
seen.add(id.toLowerCase());
entries[id] = item;
}
return entries;
}
/** The shared definitions in the namespace's value, by name. */
export function parseSharedTags(value: unknown): KeywordDef[] {
return Object.entries(entriesOf(value))
.map(([id, entry]) => {
const label = typeof entry.label === 'string' ? entry.label.trim().slice(0, MAX_SHARED_TAG_LABEL) : '';
return { id, label: label || id, color: keywordColorFromPalette(entry.color) };
})
.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
}
/**
* What a message of a group account is tagged with: the shared definitions
* first, then the member's own for ids nobody shared, so a tag on a team
* message never loses its name just because the team didn't define it.
* Ids compare without case, as keywords do.
*/
export function mergeSharedTags(shared: readonly KeywordDef[], personal: readonly KeywordDef[]): KeywordDef[] {
if (shared.length === 0) return personal as KeywordDef[];
const named = new Set(shared.map((d) => d.id.toLowerCase()));
return [...shared, ...personal.filter((d) => !named.has(d.id.toLowerCase()))];
}
+26 -7
View File
@@ -28,11 +28,12 @@ import type { LoadListAttachments } from '../lib/list-attachments';
import { useNetworkStore } from '../stores/network-store';
import {
useEmailStore, effectiveFolderScope, spansAccounts, accountIdOfRow, deleteDestroysAcrossAccounts,
searchSkipsTrashAndJunk, isTrashOrJunkRole, SCOPE_EXCEPT_TRASH_JUNK,
searchSkipsTrashAndJunk, isTrashOrJunkRole, SCOPE_EXCEPT_TRASH_JUNK, spannedAccounts,
type EmailFilters,
} from '../stores/email-store';
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../stores/settings-store';
import { useKeywordsStore, type KeywordDef } from '../stores/keywords-store';
import { useKeywordsStore, currentTagsLogin, type KeywordDef } from '../stores/keywords-store';
import { mergeSharedTags } from '../lib/shared-tags';
import { useBackWhileFocused } from '../lib/use-back-while-focused';
import { listBackStep } from '../lib/list-back';
import { reportFailure } from '../lib/report-failure';
@@ -476,6 +477,24 @@ export default function EmailListScreen({
const currentRole = currentMailbox?.role ?? null;
// The JMAP account behind the open folder (undefined = the user's own).
const currentOwnerAccountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
// A group account's messages carry its shared tags (#1310): read them for
// every shared account in the sidebar, and name a row's tags by the
// account it lives in.
const sharedTags = useKeywordsStore((s) => s.sharedTags);
const loadSharedTags = useKeywordsStore((s) => s.loadSharedTags);
React.useEffect(() => {
for (const accountId of spannedAccounts(mailboxes)) if (accountId) void loadSharedTags(accountId);
}, [mailboxes, loadSharedTags]);
const keywordDefsByAccount = React.useMemo(() => {
const login = currentTagsLogin();
const out = new Map<string, KeywordDef[]>();
for (const [accountId, entry] of Object.entries(sharedTags)) {
if (entry.login === login) out.set(accountId, mergeSharedTags(entry.tags, keywordDefs));
}
return out;
}, [sharedTags, keywordDefs]);
const folderKeywordDefs = (currentOwnerAccountId && keywordDefsByAccount.get(currentOwnerAccountId)) || keywordDefs;
const inJunk = currentRole === 'junk' || currentRole === 'spam';
const showRecipient = currentRole === 'sent' || currentRole === 'drafts';
@@ -788,7 +807,7 @@ export default function EmailListScreen({
showPreview={showPreview}
showRecipient={showRecipient}
tagIds={rowTagIds.get(key) ?? ''}
keywordDefs={keywordDefs}
keywordDefs={(item.jmapAccountId && keywordDefsByAccount.get(item.jmapAccountId)) || folderKeywordDefs}
disableAvatarImages={inJunk && !showAvatarsInJunk}
answered={flags?.answered ?? false}
forwarded={flags?.forwarded ?? false}
@@ -805,7 +824,7 @@ export default function EmailListScreen({
[
selectedIds, activeRowKey, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction,
swipeMode, handleRowSwipe, disableThreading, rowFlags, rowTagIds, threadCountFor,
showPreview, showRecipient, keywordDefs, inJunk, showAvatarsInJunk,
showPreview, showRecipient, keywordDefsByAccount, folderKeywordDefs, inJunk, showAvatarsInJunk,
loadAttachments, openAttachment,
],
);
@@ -1020,8 +1039,8 @@ export default function EmailListScreen({
const keywordFilterLabel = React.useMemo(() => {
if (!filters.keyword) return null;
const id = filters.keyword.replace(/^\$label:/, '').replace(/^\$color:/, '');
return keywordDefs.find((k) => k.id === id)?.label ?? id;
}, [filters.keyword, keywordDefs]);
return folderKeywordDefs.find((k) => k.id === id)?.label ?? id;
}, [filters.keyword, folderKeywordDefs]);
const setFolderScope = (scope: string) => {
const updated: EmailFilters = { ...filters };
// The scope a search gets by default stays unset, so it keeps following
@@ -1909,7 +1928,7 @@ export default function EmailListScreen({
<TagSheet
visible={tagSheetOpen}
onClose={() => setTagSheetOpen(false)}
keywords={keywordDefs}
keywords={folderKeywordDefs}
selectedEmails={selectedEmails}
onToggle={handleBatchTagToggle}
/>
+3 -2
View File
@@ -33,7 +33,7 @@ import {
type FlagsHint,
} from '../lib/email-detail-cache';
import { shareEmailEml } from '../lib/email-export';
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
import { useKeywordsStore, useAccountKeywordDefs, keywordToken, type KeywordDef } from '../stores/keywords-store';
import { useSheetDrag } from '../lib/use-sheet-drag';
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
import { useLayout } from '../lib/use-layout';
@@ -336,7 +336,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
[bottomActions],
);
const keywordDefs = useKeywordsStore((s) => s.keywords);
// A group account's messages are tagged with its shared tags (#1310).
const keywordDefs = useAccountKeywordDefs(ownerAccountId);
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]);
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { client, metadata } = vi.hoisted(() => ({
client: { username: 'me@example.org' as string | null, serverUrl: 'https://mail.example.org', connectedAccountId: 'own' },
metadata: vi.fn(),
}));
vi.mock('../../api/jmap-client', () => ({ jmapClient: client }));
vi.mock('../../api/bulwark-metadata', () => ({ getBulwarkMetadata: metadata }));
import { useKeywordsStore, currentTagsLogin } from '../keywords-store';
const TEAM_TAGS = { tags: { tags: { urgent: { label: 'Urgent', color: 'red' } } } };
beforeEach(() => {
metadata.mockReset();
client.username = 'me@example.org';
useKeywordsStore.setState({ sharedTags: {} });
});
describe('loadSharedTags', () => {
it("reads a group account's shared tags and stamps them with the login", async () => {
metadata.mockResolvedValue(TEAM_TAGS);
await useKeywordsStore.getState().loadSharedTags('team');
expect(metadata).toHaveBeenCalledWith('team');
const entry = useKeywordsStore.getState().sharedTags.team;
expect(entry.tags).toEqual([{ id: 'urgent', label: 'Urgent', color: 'red' }]);
expect(entry.login).toBe(currentTagsLogin());
});
it("never asks for the user's own account, and doesn't ask again while fresh", async () => {
metadata.mockResolvedValue(TEAM_TAGS);
await useKeywordsStore.getState().loadSharedTags('own');
expect(metadata).not.toHaveBeenCalled();
await useKeywordsStore.getState().loadSharedTags('team');
await useKeywordsStore.getState().loadSharedTags('team');
expect(metadata).toHaveBeenCalledTimes(1);
});
it('keeps the last good tags when a read fails', async () => {
metadata.mockResolvedValueOnce(TEAM_TAGS).mockResolvedValueOnce(null);
await useKeywordsStore.getState().loadSharedTags('team');
await useKeywordsStore.getState().loadSharedTags('team', { force: true });
expect(useKeywordsStore.getState().sharedTags.team.tags).toHaveLength(1);
});
it('drops an answer that arrives after the user switched to another login', async () => {
let resolve!: (v: unknown) => void;
metadata.mockReturnValue(new Promise((r) => { resolve = r; }));
const loading = useKeywordsStore.getState().loadSharedTags('team');
client.username = 'other@example.org';
resolve(TEAM_TAGS);
await loading;
expect(useKeywordsStore.getState().sharedTags.team).toBeUndefined();
});
});
+79
View File
@@ -1,6 +1,10 @@
import React from 'react';
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { colors } from '../theme/tokens';
import { jmapClient } from '../api/jmap-client';
import { getBulwarkMetadata } from '../api/bulwark-metadata';
import { SHARED_TAGS_KEY, mergeSharedTags, parseSharedTags } from '../lib/shared-tags';
const STORAGE_KEY = 'webmail:keywords:v1';
@@ -37,9 +41,28 @@ export function keywordToken(id: string): string {
return `$label:${id}`;
}
/** A group account's shared tags (lib/shared-tags.ts), as last read. */
export interface SharedTagsEntry {
/** Server and user that read them: account ids repeat across servers. */
login: string;
tags: KeywordDef[];
loadedAt: number;
}
/** Shared tags older than this are read again when a view asks for them. */
export const SHARED_TAGS_STALE_MS = 2 * 60_000;
interface KeywordsState {
keywords: KeywordDef[];
hydrated: boolean;
/** Shared tag definitions of the group accounts, by JMAP account id. */
sharedTags: Record<string, SharedTagsEntry>;
/**
* Read a group account's shared tags from its Inbox metadata, unless they
* were read for this login in the last two minutes. Nothing goes out for
* the user's own account or one that keeps no metadata (Stalwart 0.16).
*/
loadSharedTags: (accountId: string, options?: { force?: boolean }) => Promise<void>;
hydrate: () => Promise<void>;
add: (kw: KeywordDef) => void;
update: (id: string, patch: Partial<Omit<KeywordDef, 'id'>>) => void;
@@ -47,6 +70,15 @@ interface KeywordsState {
resetDefaults: () => void;
}
/** The login the JMAP client is connected as, '' before a session. */
export function currentTagsLogin(): string {
const user = jmapClient.username;
const server = jmapClient.serverUrl;
return user && server ? `${server}|${user}` : '';
}
const sharedInflight = new Map<string, Promise<void>>();
function persist(keywords: KeywordDef[]): void {
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keywords)).catch((err) => {
console.warn('[keywords-store] persist failed', err);
@@ -56,6 +88,34 @@ function persist(keywords: KeywordDef[]): void {
export const useKeywordsStore = create<KeywordsState>((set, get) => ({
keywords: DEFAULT_KEYWORDS,
hydrated: false,
sharedTags: {},
loadSharedTags: (accountId, options) => {
const login = currentTagsLogin();
if (!login || !accountId || accountId === jmapClient.connectedAccountId) return Promise.resolve();
const current = get().sharedTags[accountId];
if (!options?.force && current?.login === login && Date.now() - current.loadedAt < SHARED_TAGS_STALE_MS) {
return Promise.resolve();
}
const key = `${login}|${accountId}`;
const running = sharedInflight.get(key);
if (running) return running;
const run = (async () => {
const entries = await getBulwarkMetadata(accountId);
// The user switched accounts meanwhile: these tags are another login's.
if (currentTagsLogin() !== login) return;
// A failed read (or none to make) keeps the last good tags.
if (!entries) return;
set((s) => ({
sharedTags: {
...s.sharedTags,
[accountId]: { login, tags: parseSharedTags(entries[SHARED_TAGS_KEY]), loadedAt: Date.now() },
},
}));
})().finally(() => sharedInflight.delete(key));
sharedInflight.set(key, run);
return run;
},
hydrate: async () => {
if (get().hydrated) return;
@@ -97,3 +157,22 @@ export const useKeywordsStore = create<KeywordsState>((set, get) => ({
persist(DEFAULT_KEYWORDS);
},
}));
/**
* The tag definitions for messages of a JMAP account: a group account's
* shared tags first (read from its Inbox metadata, Stalwart 1.0), then the
* user's own; just the user's own for their own account or before the
* shared ones are known. `accountId` undefined means the user's own.
*/
export function useAccountKeywordDefs(accountId: string | undefined): KeywordDef[] {
const personal = useKeywordsStore((s) => s.keywords);
const entry = useKeywordsStore((s) => (accountId ? s.sharedTags[accountId] : undefined));
const loadSharedTags = useKeywordsStore((s) => s.loadSharedTags);
React.useEffect(() => {
if (accountId) void loadSharedTags(accountId);
}, [accountId, loadSharedTags]);
return React.useMemo(
() => (entry && entry.login === currentTagsLogin() ? mergeSharedTags(entry.tags, personal) : personal),
[entry, personal],
);
}