Files
webmail/lib/account-utils.ts
T
Linus Rath 3a2be9a7d4 fix: edit the name recipients see on the Account page and stop cutting account addresses
Settings > Account had a "Display name" that edited Stalwart's account
description, while mail went out with the identity's name, so changing
it changed nothing people saw. The page now shows "Your name", the
primary address's name, and saves it to that identity. The account
switcher and avatars take the same name; the principal description is
only used for an account without an identity name. The address editor
calls the field "Your name" too, and "identities" are "addresses" in
the Account section and its dialog, said once instead of twice.

Logged-in accounts show the server only for an account in trouble or
one sharing its address with another, with no drag hint or arrows for a
single account. The switcher wraps addresses instead of cutting them and
has no remove button per row; an account you're not in is removed from
Settings > Account.

Changed tests: auth-store-display-name now expects the identity name to
win over the principal; account-switcher-remove expects no remove
button on the rows; settings-editors-unsaved uses the renamed "Add
address" button and the "Your name" label, which has no placeholder.
2026-11-04 12:38:38 +01:00

244 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { toUnicodeEmail } from '@/lib/idn';
/**
* Utilities for multi-account support:
* - Account ID generation
* - Deterministic avatar colors
* - Account-scoped localStorage keys
*/
/** Generate a unique, deterministic account ID from username and server URL */
export function generateAccountId(username: string, serverUrl: string): string {
try {
const host = new URL(serverUrl).hostname;
return `${username}@${host}`;
} catch {
// Relative URL (e.g. /api/dev-jmap) – use current origin as base
const base = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
const host = new URL(serverUrl, base).hostname;
return `${username}@${host}`;
}
}
/** Deterministic avatar/accent color from an email string */
export function generateAvatarColor(email: string): string {
let hash = 0;
for (let i = 0; i < email.length; i++) {
hash = ((hash << 5) - hash + email.charCodeAt(i)) | 0;
}
// 12 distinct, accessible hues
const colors = [
'#2563eb', // blue
'#7c3aed', // violet
'#db2777', // pink
'#dc2626', // red
'#ea580c', // orange
'#d97706', // amber
'#65a30d', // lime
'#16a34a', // green
'#0d9488', // teal
'#0891b2', // cyan
'#6366f1', // indigo
'#9333ea', // purple
];
return colors[Math.abs(hash) % colors.length];
}
/**
* The `KEYWORD_PALETTE` key whose tint matches an account's avatar colour.
*
* Row tints are Tailwind class pairs (light + dark) rather than raw colours, so
* an account's hex cannot be used directly as a background. Mapping onto the
* shared tag palette keeps account tints in the same visual language as tag
* tints and gets dark mode for free.
*
* These pick the palette's `-dark` variants. A tag tint can afford to be faint
* because the tag also shows a coloured chip, but an account tint replaces the
* account dot and has to carry that information on its own - the fainter
* variants are close to invisible against the row background.
*
* The palette has no violet, so the two purple-ish avatar hues would collide.
* Purple keeps `purple-dark` and violet falls back to the plain `purple`, whose
* lighter wash still tells the two apart.
*/
export function accountTintKey(avatarColor: string | undefined): string {
switch ((avatarColor ?? '').toLowerCase()) {
case '#2563eb': return 'blue-dark';
case '#7c3aed': return 'purple';
case '#db2777': return 'pink-dark';
case '#dc2626': return 'red-dark';
case '#ea580c': return 'orange-dark';
case '#d97706': return 'amber-dark';
case '#65a30d': return 'lime-dark';
case '#16a34a': return 'green-dark';
case '#0d9488': return 'teal-dark';
case '#0891b2': return 'cyan-dark';
case '#6366f1': return 'indigo-dark';
case '#9333ea': return 'purple-dark';
default: return 'gray-dark';
}
}
/** Get initials for an avatar from a display name or email */
export function getInitials(name: string, email?: string): string {
if (name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return parts[0][0]?.toUpperCase() ?? '?';
}
if (email) {
return email[0]?.toUpperCase() ?? '?';
}
return '?';
}
/**
* Hard upper bound on cookie slots. Each slot can hold up to ~3 cookies
* (session, refresh token, server id, auth context), so 50 slots ≈ 125
* cookies on average - within Firefox's per-domain limit of 150.
*/
export const MAX_ACCOUNT_SLOTS = 50;
/**
* UX cap for browsers using HTTP/1.1. Each account holds one persistent
* SSE connection for JMAP push; HTTP/1.1 caps origins at 6 concurrent
* connections, so 5 accounts leave one connection free for normal traffic.
* On HTTP/2+ this cap doesn't apply because streams are multiplexed.
*/
export const MAX_ACCOUNTS_HTTP1 = 5;
/**
* Detect whether the page has observed any HTTP/2 or HTTP/3 traffic.
*
* We walk recent resource-timing entries and treat a single h2/h3 sighting
* as a positive signal. Cross-origin entries may report an empty
* `nextHopProtocol` without `Timing-Allow-Origin`, in which case we
* under-detect and fall back to the conservative cap - that's safe.
*/
export function isHttp2Available(): boolean {
if (typeof performance === 'undefined') return false;
// The document connection is already known before any subresources finish
// loading. Include it so a fresh login/restore doesn't incorrectly cap an
// HTTP/2 page at five accounts. Do not assume h2 when timing is unavailable.
const entries = [
...performance.getEntriesByType('navigation'),
...performance.getEntriesByType('resource'),
] as PerformanceResourceTiming[];
for (let i = entries.length - 1; i >= 0; i--) {
const proto = entries[i].nextHopProtocol;
if (proto === 'h2' || proto === 'h3') return true;
}
return false;
}
/**
* Effective per-browser account cap. Lifts to {@link MAX_ACCOUNT_SLOTS}
* once HTTP/2+ is observed, otherwise returns {@link MAX_ACCOUNTS_HTTP1}.
*/
export function getMaxAccounts(): number {
// The cap is for the push streams sharing one http/1.1 origin. The embedded
// server speaks http/1.1, but each account streams from its own mail server.
if (IS_DESKTOP_BUILD) return MAX_ACCOUNT_SLOTS;
return isHttp2Available() ? MAX_ACCOUNT_SLOTS : MAX_ACCOUNTS_HTTP1;
}
/** Minimal shape needed to name an account in a list (structural — avoids importing AccountEntry). */
export interface NamedAccount {
id: string;
displayName?: string;
label: string;
email?: string;
username: string;
}
/**
* The bold title and the line under it for an account in a list. Usually the
* name over the address. One person signed in to two mailboxes has the same
* name, and often the same picture, on both, so the rows only differed in the
* small address line: when another account shares the name, the address
* becomes the title and the name is left out. A name that is just the address
* again is left out too.
*/
export function accountRowText(
account: NamedAccount,
accounts: NamedAccount[],
): { title: string; subtitle: string | null } {
const key = (value: string) => value.trim().toLowerCase();
const nameOf = (a: NamedAccount) => a.displayName || a.label;
const name = nameOf(account);
const address = account.email || account.username;
const shared = accounts.some((a) => a.id !== account.id && key(nameOf(a)) === key(name));
if (!name || shared || key(name) === key(address)) return { title: address, subtitle: null };
return { title: name, subtitle: address };
}
/**
* Whether another signed-in account has the same address (one mailbox on two
* servers). Only then is the server worth a line under an account: in every
* other case it says nothing the person needs (settings-14, settings-p2-11).
*/
export function accountSharesAddress(
account: NamedAccount,
accounts: NamedAccount[],
): boolean {
const key = (a: NamedAccount) => toUnicodeEmail(a.email || a.username).trim().toLowerCase();
return accounts.some((other) => other.id !== account.id && key(other) === key(account));
}
/**
* The name an account's avatar takes its initials from. Usually the account's
* name. When another account has the same name, both would show the same two
* letters, so the initials come from the address instead: its local part split
* at dots, dashes, underscores and plus signs ("dso-ux-mail-b" gives "DB",
* "dso-ux-mail" gives "DM").
*/
export function accountAvatarName(account: NamedAccount, accounts: NamedAccount[]): string {
const name = account.displayName || account.label;
const { title, subtitle } = accountRowText(account, accounts);
if (subtitle !== null || title !== (account.email || account.username)) return name;
const local = title.split('@')[0] ?? title;
const words = local.split(/[.\-_+]+/).filter(Boolean);
return words.length > 0 ? words.join(' ') : name || title;
}
/** Minimal shape needed to order accounts (structural — avoids importing AccountEntry). */
export interface OrderableAccount {
id: string;
isDefault: boolean;
}
/**
* Display order for the account switcher: the default account first, then the
* remaining accounts in their stored order. Pure — does not mutate the input.
*/
export function sortDefaultFirst<T extends OrderableAccount>(accounts: T[]): T[] {
const defaults = accounts.filter((a) => a.isDefault);
const rest = accounts.filter((a) => !a.isDefault);
return [...defaults, ...rest];
}
/**
* Compute the new full account-id order after dragging `dragId` onto `overId`.
* Default account(s) stay pinned to the front; only non-default accounts are
* reordered (`dragId` is inserted at `overId`'s position among them).
* Returns null when the move is a no-op or invalid (e.g. a default is involved).
*/
export function reorderNonDefaultIds(
accounts: OrderableAccount[],
dragId: string,
overId: string,
): string[] | null {
if (dragId === overId) return null;
const defaults = accounts.filter((a) => a.isDefault).map((a) => a.id);
const nonDefault = accounts.filter((a) => !a.isDefault).map((a) => a.id);
const from = nonDefault.indexOf(dragId);
const to = nonDefault.indexOf(overId);
if (from < 0 || to < 0) return null;
const [moved] = nonDefault.splice(from, 1);
nonDefault.splice(to, 0, moved);
return [...defaults, ...nonDefault];
}