Files
webmail/lib/deep-links.ts
T
Linus Rath 4858eb0915 fix: open a push notification's message in the account it came from
Clicking a new-mail notification opened `/mail/message/<id>` in
whichever account was active. With several accounts signed in, that
looked the id up in the wrong account: nothing opened, or a different
message did and was marked read.

The preview API now returns the cookie slot of the login that owns the
notified account, the service worker adds it to the permalink (or links
that account's Inbox for a group notification), and the app switches to
the login on that slot before opening the message.
2026-09-26 16:12:20 +02:00

467 lines
18 KiB
TypeScript

/**
* Deep links (permalinks) for every app surface.
*
* The app is a single client-side shell per surface, so the URL is not what
* mounts a view - it is a serialisation of the view the user is looking at.
* This module owns that serialisation in both directions:
*
* build* - view state → app-relative path (`/mail/message/abc`)
* parse* - route segments → a typed "intent" the surface applies on mount
*
* Paths produced here are *app-relative*: no locale prefix and no mount
* prefix. Use `appPath()` (or `appUrl()` for something copyable) to turn one
* into a real URL, so subpath deploys (NEXT_PUBLIC_BASE_PATH) and every
* NEXT_PUBLIC_LOCALE_PREFIX mode are handled in exactly one place.
*
* Nothing here throws or 404s on bad input: an unparseable link resolves to
* `null` and the surface falls back to its default view. Old links degrade,
* they don't break.
*/
import { routing } from '@/i18n/routing';
import { getLocaleFromPath, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
import {
UNIFIED_MAILBOX_IDS,
UNIFIED_ROLE_BY_ID,
CROSS_VIEW_IDS,
CROSS_VIEW_BY_ID,
type Mailbox,
} from '@/lib/jmap/types';
import type { CalendarViewMode } from '@/stores/calendar-store';
/** Sentinel mailbox id for the virtual "Scheduled" view (see the mail page). */
export const SCHEDULED_MAILBOX_ID = '__scheduled__';
/**
* Sidebar-only id for a shared account's "Scheduled" row. The store keeps a
* single virtual scheduled mailbox (SCHEDULED_MAILBOX_ID); the account is
* carried alongside it as a view scope, so this suffixed form never reaches
* the store or a deep link.
*/
export const scopedScheduledMailboxId = (accountId: string) => `${SCHEDULED_MAILBOX_ID}:${accountId}`;
/** Splits a (possibly account-scoped) scheduled id into its parts. */
export function parseScheduledMailboxId(mailboxId: string): { isScheduled: boolean; accountId: string | null } {
if (mailboxId === SCHEDULED_MAILBOX_ID) return { isScheduled: true, accountId: null };
if (mailboxId.startsWith(`${SCHEDULED_MAILBOX_ID}:`)) {
return { isScheduled: true, accountId: mailboxId.slice(SCHEDULED_MAILBOX_ID.length + 1) || null };
}
return { isScheduled: false, accountId: null };
}
// ---------------------------------------------------------------------------
// URL assembly
// ---------------------------------------------------------------------------
/**
* Turns an app-relative path into one the browser can navigate to, applying
* the configured locale prefix and the deployment's mount prefix.
*
* appPath('/mail/message/abc') // prefix "never" → /mail/message/abc
* appPath('/mail/message/abc', 'de') // prefix "always" → /de/mail/message/abc
* appPath('/mail', 'en') // "as-needed", default locale → /mail
*
* `locale` defaults to the locale in the current URL, so callers in event
* handlers don't have to thread it through.
*/
export function appPath(path: string, locale?: string): string {
const normalized = path === '' ? '/' : path;
const resolved = locale ?? getLocaleFromPath();
let withLocale = normalized;
if (routing.localePrefix === 'always') {
withLocale = `/${resolved}${normalized === '/' ? '' : normalized}`;
} else if (routing.localePrefix === 'as-needed' && resolved !== routing.defaultLocale) {
withLocale = `/${resolved}${normalized === '/' ? '' : normalized}`;
}
return withBasePath(withLocale);
}
/**
* Absolute URL for an app-relative path - what "Copy link" puts on the
* clipboard. Falls back to the bare path during SSR, where there is no origin.
*/
export function appUrl(path: string, locale?: string): string {
const resolved = appPath(path, locale);
if (typeof window === 'undefined') return resolved;
return `${window.location.origin}${resolved}`;
}
/** Percent-encodes an opaque id for use as a single path segment. */
function encodeSegment(value: string): string {
return encodeURIComponent(value);
}
function decodeSegment(value: string): string {
try {
return decodeURIComponent(value);
} catch {
// A malformed %-sequence is a broken link, not a crash: use it verbatim
// and let the surface's "not found" path handle it.
return value;
}
}
// ---------------------------------------------------------------------------
// Mail
// ---------------------------------------------------------------------------
/**
* Readable aliases for folders that have no stable server-side id: the virtual
* unified / cross-account views and the client-side Scheduled view.
*/
const VIRTUAL_FOLDER_ALIASES: Record<string, string> = {
scheduled: SCHEDULED_MAILBOX_ID,
'unified-inbox': UNIFIED_MAILBOX_IDS.inbox,
'unified-sent': UNIFIED_MAILBOX_IDS.sent,
'unified-drafts': UNIFIED_MAILBOX_IDS.drafts,
'unified-trash': UNIFIED_MAILBOX_IDS.trash,
'unified-archive': UNIFIED_MAILBOX_IDS.archive,
'unified-junk': UNIFIED_MAILBOX_IDS.junk,
'cross-unread': CROSS_VIEW_IDS.unread,
'cross-starred': CROSS_VIEW_IDS.starred,
'cross-all': CROSS_VIEW_IDS.all,
};
const VIRTUAL_ALIAS_BY_ID: Record<string, string> = Object.fromEntries(
Object.entries(VIRTUAL_FOLDER_ALIASES).map(([alias, id]) => [id, alias]),
);
/** Mailbox roles that get a readable alias instead of their opaque JMAP id. */
const ALIASED_ROLES = new Set(['inbox', 'sent', 'drafts', 'trash', 'archive', 'junk']);
/**
* `accountId` is the local account (AccountEntry.id). `slot` names the
* login by its cookie slot instead - what a push notification knows.
*/
export type MailDeepLink =
| { kind: 'folder'; ref: string; accountId?: string; slot?: number }
| { kind: 'message'; id: string; accountId?: string; slot?: number; fullscreen?: boolean }
| { kind: 'thread'; id: string; accountId?: string; slot?: number };
export interface MailLinkState {
mailboxId: string | null;
emailId: string | null;
threadId: string | null;
}
/**
* The URL-facing reference for a mailbox: a virtual alias, a role name, or the
* raw JMAP id for custom folders (whose ids are the only thing that is stable).
*/
export function buildFolderRef(mailboxId: string, mailboxes: Mailbox[] = []): string {
const virtual = VIRTUAL_ALIAS_BY_ID[mailboxId];
if (virtual) return virtual;
const mailbox = mailboxes.find((m) => m.id === mailboxId);
// Shared folders carry another account's copy of a role (two "inbox" roles
// in one list), so only the account's own folders may claim the role alias.
if (mailbox?.role && ALIASED_ROLES.has(mailbox.role) && !mailbox.isShared) {
return mailbox.role;
}
return mailboxId;
}
/**
* Inverse of `buildFolderRef`. Returns null when the reference matches nothing
* in this account - a link to a folder that was deleted or belongs elsewhere.
*
* Exact ids are checked before aliases so a custom folder whose id happens to
* read like an alias still resolves to itself.
*/
export function resolveFolderRef(ref: string, mailboxes: Mailbox[] = []): string | null {
const decoded = decodeSegment(ref);
if (mailboxes.some((m) => m.id === decoded)) return decoded;
const virtual = VIRTUAL_FOLDER_ALIASES[decoded];
if (virtual) return virtual;
if (ALIASED_ROLES.has(decoded)) {
const byRole = mailboxes.find((m) => m.role === decoded && !m.isShared)
?? mailboxes.find((m) => m.role === decoded);
if (byRole) return byRole.id;
return null;
}
// Unknown id: the mailbox list may simply not be loaded yet, so hand it back
// and let the caller decide (it re-runs once mailboxes arrive).
return decoded || null;
}
/**
* The canonical path for the current mail view. Returns `/mail` for the bare
* list so the address bar stays clean when nothing is open.
*
* A thread wins over a single message: when the conversation view is open the
* thread is what the user is looking at.
*/
export function buildMailPath(state: MailLinkState, mailboxes: Mailbox[] = []): string {
if (state.threadId) return `/mail/thread/${encodeSegment(state.threadId)}`;
if (state.emailId) return `/mail/message/${encodeSegment(state.emailId)}`;
if (state.mailboxId) return `/mail/folder/${encodeSegment(buildFolderRef(state.mailboxId, mailboxes))}`;
return '/mail';
}
/**
* Parses the segments after `/mail`. Also accepts the legacy `?email=<id>`
* query the push service worker used to emit, so notifications from an older
* installed worker keep opening the right message.
*/
export function parseMailPath(
segments: string[] = [],
search?: URLSearchParams,
): MailDeepLink | null {
const accountId = search?.get('account') ?? undefined;
const rawSlot = search?.get('slot');
const slotNumber = rawSlot != null && rawSlot !== '' ? Number(rawSlot) : NaN;
const slot = Number.isInteger(slotNumber) && slotNumber >= 0 ? slotNumber : undefined;
// `?view=fullscreen` asks for the message alone, no sidebar or list - what
// a mail dragged out into a new browser tab opens as. The Pro shell always
// opens message links as fullscreen email tabs and ignores the flag.
const fullscreen = search?.get('view') === 'fullscreen' || undefined;
const [kind, value] = segments;
if (kind && value) {
const id = decodeSegment(value);
if (id) {
if (kind === 'message') return { kind: 'message', id, accountId, slot, fullscreen };
if (kind === 'thread') return { kind: 'thread', id, accountId, slot };
if (kind === 'folder') return { kind: 'folder', ref: id, accountId, slot };
}
}
const legacyEmail = search?.get('email');
if (legacyEmail) return { kind: 'message', id: legacyEmail, accountId, slot };
return null;
}
// ---------------------------------------------------------------------------
// Calendar
// ---------------------------------------------------------------------------
const CALENDAR_VIEWS: CalendarViewMode[] = ['month', 'week', 'day', 'agenda', 'tasks'];
export type CalendarDeepLink =
| { kind: 'event'; id: string; accountId?: string; login?: string }
| { kind: 'view'; view: CalendarViewMode; date: Date | null };
export interface CalendarLinkState {
view: CalendarViewMode;
date: Date | null;
eventId?: string | null;
/** JMAP account owning the event - needed for shared/secondary calendars. */
accountId?: string | null;
/** Connected-account id (login) reaching the event, when not the active one (global search). */
login?: string | null;
}
/** `YYYY-MM-DD` in local time - the date the user sees, not a UTC shift of it. */
export function formatLinkDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
/** Parses `YYYY-MM-DD` as a local-midnight Date. Returns null if malformed. */
export function parseLinkDate(value: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return null;
const [, y, m, d] = match;
const date = new Date(Number(y), Number(m) - 1, Number(d));
if (Number.isNaN(date.getTime())) return null;
// Rejects impossible dates that Date silently rolls over (2026-02-31).
if (date.getMonth() !== Number(m) - 1 || date.getDate() !== Number(d)) return null;
return date;
}
export function buildCalendarPath(state: CalendarLinkState): string {
if (state.eventId) {
const path = `/calendar/event/${encodeSegment(state.eventId)}`;
const params = new URLSearchParams();
if (state.accountId) params.set('account', state.accountId);
if (state.login) params.set('login', state.login);
const query = params.toString();
return query ? `${path}?${query}` : path;
}
if (!state.date) return `/calendar/${state.view}`;
return `/calendar/${state.view}/${formatLinkDate(state.date)}`;
}
export function parseCalendarPath(
segments: string[] = [],
search?: URLSearchParams,
): CalendarDeepLink | null {
const [first, second] = segments;
if (!first) return null;
if (first === 'event') {
const id = second ? decodeSegment(second) : '';
if (!id) return null;
return { kind: 'event', id, accountId: search?.get('account') ?? undefined, login: search?.get('login') ?? undefined };
}
if ((CALENDAR_VIEWS as string[]).includes(first)) {
return {
kind: 'view',
view: first as CalendarViewMode,
date: second ? parseLinkDate(second) : null,
};
}
// A bare date with no view (`/calendar/2026-08-06`) keeps the user's view.
const date = parseLinkDate(first);
if (date) return { kind: 'view', view: 'month', date };
return null;
}
// ---------------------------------------------------------------------------
// Contacts
// ---------------------------------------------------------------------------
export type ContactDeepLink =
| { kind: 'contact'; id: string; edit: boolean; fromEmail: boolean }
| { kind: 'create'; email?: string; name?: string; fromEmail: boolean };
export interface ContactLinkState {
contactId: string | null;
editing?: boolean;
}
export function buildContactsPath(state: ContactLinkState): string {
if (!state.contactId) return '/contacts';
const path = `/contacts/${encodeSegment(state.contactId)}`;
return state.editing ? `${path}/edit` : path;
}
/**
* Parses `/contacts/<id>[/edit]` plus the legacy query form the email viewer
* used to build (`?contactId=`, `?addEmail=`, `?addName=`, `?from=email`).
* Both are still emitted by plugins and bookmarks, so both are supported.
*/
export function parseContactsPath(
segments: string[] = [],
search?: URLSearchParams,
): ContactDeepLink | null {
const fromEmail = search?.get('from') === 'email';
const [first, second] = segments;
if (first === 'new') {
return {
kind: 'create',
email: search?.get('email') ?? search?.get('addEmail') ?? undefined,
name: search?.get('name') ?? search?.get('addName') ?? undefined,
fromEmail,
};
}
if (first) {
const id = decodeSegment(first);
if (id) return { kind: 'contact', id, edit: second === 'edit', fromEmail };
}
const legacyId = search?.get('contactId');
if (legacyId) {
return { kind: 'contact', id: legacyId, edit: search?.get('view') === 'edit', fromEmail };
}
const addEmail = search?.get('addEmail');
const addName = search?.get('addName');
if (addEmail || addName) {
return { kind: 'create', email: addEmail ?? undefined, name: addName ?? undefined, fromEmail };
}
return null;
}
// ---------------------------------------------------------------------------
// Files
// ---------------------------------------------------------------------------
export interface FilesDeepLink {
/** Absolute folder path inside the drive, always leading-slashed. */
path: string;
/** File name to preview once the folder has loaded, if the link named one. */
preview?: string;
}
export function buildFilesPath(currentPath: string, preview?: string | null): string {
const parts = currentPath.split('/').filter(Boolean).map(encodeSegment);
const base = parts.length > 0 ? `/files/${parts.join('/')}` : '/files';
return preview ? `${base}?preview=${encodeURIComponent(preview)}` : base;
}
export function parseFilesPath(
segments: string[] = [],
search?: URLSearchParams,
): FilesDeepLink | null {
const parts = segments.map(decodeSegment).filter(Boolean);
const preview = search?.get('preview') ?? undefined;
if (parts.length === 0 && !preview) return null;
return { path: `/${parts.join('/')}`, preview };
}
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------
export function buildSettingsPath(tabId: string | null | undefined): string {
return tabId ? `/settings/${encodeSegment(tabId)}` : '/settings';
}
/**
* The tab id from `/settings/<tab>`. Validation against the real tab registry
* is the settings page's job - it owns the list and the legacy-id migration.
*/
export function parseSettingsPath(segments: string[] = []): string | null {
const [tab] = segments;
return tab ? decodeSegment(tab) : null;
}
// ---------------------------------------------------------------------------
// Surface routing (shared by the Pro shell and the login round-trip)
// ---------------------------------------------------------------------------
export type AppSurface = 'mail' | 'calendar' | 'contacts' | 'files' | 'settings';
/**
* Which surface an app-relative path belongs to, plus the segments after it.
* `/` and `/mail` both mean mail; anything unrecognised returns null.
*/
export function matchSurface(
path: string,
): { surface: AppSurface; segments: string[] } | null {
let clean = path.split('?')[0].split('#')[0];
// Accepts both app-relative paths and raw `window.location.pathname`, which
// carries the mount prefix on subpath deployments.
const prefix = getPathPrefix();
if (prefix && (clean === prefix || clean.startsWith(`${prefix}/`))) {
clean = clean.slice(prefix.length) || '/';
}
const parts = clean.split('/').filter(Boolean);
// Drop a leading locale segment so this works under every prefix mode.
if (parts.length > 0 && (routing.locales as readonly string[]).includes(parts[0])) {
parts.shift();
}
if (parts.length === 0) return { surface: 'mail', segments: [] };
const [head, ...rest] = parts;
if (head === 'mail') return { surface: 'mail', segments: rest };
if (head === 'calendar') return { surface: 'calendar', segments: rest };
if (head === 'contacts') return { surface: 'contacts', segments: rest };
if (head === 'files') return { surface: 'files', segments: rest };
if (head === 'settings') return { surface: 'settings', segments: rest };
return null;
}
/** Re-exported for callers that need to spot a virtual folder alias. */
export { UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID };