fix(identities): namespace all accounts consistently in the Pro composer

Follow-up to the calendar/contacts consistent-namespacing fix. The multi-account
identity aggregation left the ACTIVE account's identity ids raw while namespacing
the others, so an identity id changed form when the active account changed. Now
every aggregated account is namespaced (`<accountId>::<id>`), matching the
calendar/contact stores.

Safe: the composer already resolves the raw id + owning client via
stripCrossAccountIdentityPrefix everywhere it hits JMAP (currentIdentityRawId /
composerClient), reply-identity resolves by email, and preferredIdentityIds is
applied against the raw identity store (unaffected). No consumer relies on the
active account's identity id being raw. Single-account mode (hook disabled) keeps
the store's raw ids.

Full unit suite + typecheck clean.
This commit is contained in:
Stefan Hildebrandt
2026-08-13 21:31:50 +02:00
parent eef184f3d3
commit 44cdd083a3
+22 -23
View File
@@ -16,10 +16,12 @@ interface AccountIdentityGroup {
const CROSS_ACCOUNT_IDENTITY_DELIMITER = '::';
/** Cross-account identity IDs are namespaced to avoid collisions between
* JMAP servers that happen to issue the same opaque ID. The active
* account's IDs are left untouched so existing single-account code paths
* (reply-identity resolution, S/MIME bindings) keep working unchanged.
/** Cross-account identity IDs are namespaced to avoid collisions between JMAP
* servers that happen to issue the same opaque ID. EVERY aggregated account is
* namespaced (including the active one) so an id doesn't change form when the
* active account changes; consumers resolve the raw id via
* stripCrossAccountIdentityPrefix. Single-account mode (hook disabled) uses the
* identity store's raw ids directly.
*/
export function isCrossAccountIdentityId(id: string): boolean {
return id.includes(CROSS_ACCOUNT_IDENTITY_DELIMITER);
@@ -94,35 +96,32 @@ export function useProMultiAccountIdentities(): {
const groups = useMemo<AccountIdentityGroup[]>(() => {
if (!enabled) return [];
// Namespace EVERY account's identity ids, including the active one, so an id
// stably identifies (account, identity) regardless of which account is
// active. Consumers resolve the raw id via stripCrossAccountIdentityPrefix.
// Mirrors the calendar/contact stores' consistent-namespacing invariant.
const group = (accountId: string, list: Identity[], label: string): AccountIdentityGroup => ({
localAccountId: accountId,
accountLabel: label,
identities: list.map((id) => ({
...id,
id: `${accountId}${CROSS_ACCOUNT_IDENTITY_DELIMITER}${id.id}`,
localAccountId: accountId,
accountName: label,
})),
});
const out: AccountIdentityGroup[] = [];
if (activeAccountId) {
const active = accounts.find((a) => a.id === activeAccountId);
const label = active?.label || active?.email || active?.username || activeAccountId;
out.push({
localAccountId: activeAccountId,
accountLabel: label,
identities: activeIdentities.map((id) => ({
...id,
localAccountId: activeAccountId,
accountName: label,
})),
});
out.push(group(activeAccountId, activeIdentities, label));
}
for (const account of accounts) {
if (!account.isConnected || account.id === activeAccountId) continue;
const list = remoteIdentities[account.id];
if (!list || list.length === 0) continue;
const label = account.label || account.email || account.username;
out.push({
localAccountId: account.id,
accountLabel: label,
identities: list.map((id) => ({
...id,
id: `${account.id}${CROSS_ACCOUNT_IDENTITY_DELIMITER}${id.id}`,
localAccountId: account.id,
accountName: label,
})),
});
out.push(group(account.id, list, label));
}
return out;
}, [enabled, accounts, activeAccountId, activeIdentities, remoteIdentities]);