Files
native/src/device-sync/jmap/session.ts
T
Linus Rath ecbd218c34 fix: sync only the primary account's collections by default #34
The settings chooser and the reminder filter treat only the capability's
primary account as the user's own, while the engine also counted any
account the server marks as personal. On a server that lists a second
such account, its address books and calendars synced although the
chooser showed them off. The engine now uses the same rule as the app.
2026-09-27 07:53:36 +02:00

92 lines
3.5 KiB
TypeScript

/**
* What device sync reads from a JMAP session: the accounts that hold address
* books or calendars, and the user's own calendar addresses.
*/
import { JMAP_CORE, type JmapSessionView } from '../types';
import type { JmapCaller } from './caller';
const JMAP_SUBMISSION = 'urn:ietf:params:jmap:submission';
export interface CapabilityAccount {
id: string;
name: string;
/** The capability's primary account (the user's own). */
primary: boolean;
/**
* Collections of the personal account are selected by default, those of the others are not. It is the
* capability's primary account only, whatever `isPersonal` says: one rule with the app's chooser and
* reminder filter, which know only the primary account per authority.
*/
personal: boolean;
readOnly: boolean;
}
/**
* The primary account of the capability (`primaryAccounts[capability]`)
* first, then every other account that advertises it (shared accounts).
*/
export function accountsWithCapability(session: JmapSessionView, capability: string): CapabilityAccount[] {
const out: CapabilityAccount[] = [];
const primary = session.primaryAccounts?.[capability];
const add = (id: string, isPrimary: boolean) => {
const account = session.accounts?.[id];
if (!account || out.some((a) => a.id === id)) return;
out.push({
id,
name: account.name || id,
primary: isPrimary,
personal: isPrimary,
readOnly: !!account.isReadOnly,
});
};
if (primary) add(primary, true);
for (const [id, account] of Object.entries(session.accounts ?? {})) {
if (id !== primary && account.accountCapabilities && capability in account.accountCapabilities) add(id, false);
}
return out;
}
export interface CalendarAddresses {
/** `OWNER_ACCOUNT` of our calendars: never empty (Etar crashes on a null owner). */
ownerAccount: string;
/** Every address of the user, lowercase, without `mailto:`. */
selfAddresses: string[];
}
function normalizeAddress(value: unknown): string | null {
if (typeof value !== 'string') return null;
const address = value.trim().replace(/^mailto:/i, '').toLowerCase();
return address.includes('@') ? address : null;
}
/**
* The login when it is an email address, else the first identity's address;
* aliases from identities count as "me" too (docs/device-sync.md,
* "Decisions"). Identities are read only when the session offers
* submission; a server without `Identity/get` leaves the login.
*/
export async function calendarAddresses(caller: JmapCaller, fallbackName: string): Promise<CalendarAddresses> {
const session = caller.session();
const login = normalizeAddress(session.username);
const identities: string[] = [];
const submissionAccount = session.primaryAccounts?.[JMAP_SUBMISSION];
if (submissionAccount) {
try {
const body = await caller.call<{ list?: Array<{ email?: string }> }>(
'Identity/get',
{ accountId: submissionAccount, properties: ['email'] },
[JMAP_CORE, JMAP_SUBMISSION],
);
for (const identity of body.list ?? []) {
const address = normalizeAddress(identity.email);
if (address) identities.push(address);
}
} catch {
// Without identities the login is the only address we know.
}
}
const selfAddresses = [...new Set([...(login ? [login] : []), ...identities])];
const ownerAccount = login ?? identities[0] ?? (session.username || fallbackName).toLowerCase();
return { ownerAccount, selfAddresses: selfAddresses.length ? selfAddresses : [ownerAccount] };
}