mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
feat: show the shared tags of group mailboxes, read from their metadata on Stalwart 1.0
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -84,6 +84,7 @@ export function MessageContent({
|
||||
onToggleStar={onToggleStar}
|
||||
onAddressPress={onAddressPress}
|
||||
compact={compact}
|
||||
jmapAccountId={jmapAccountId}
|
||||
/>
|
||||
{attachmentPosition === 'beside-sender' && chips}
|
||||
</View>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()))];
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user