Files
webmail/lib/deep-links.ts
T

550 lines
21 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; ownerAccountId?: string }
| { kind: 'thread'; id: string; accountId?: string; slot?: number; ownerAccountId?: string };
export interface MailLinkState {
mailboxId: string | null;
emailId: string | null;
threadId: string | null;
/**
* The JMAP account a linked message or conversation lives in, when it is not
* the login's own: email ids repeat across accounts (#1351).
*/
ownerAccountId?: string | null;
/** The login (AccountEntry.id) reaching it, when not the active one. */
loginId?: string | null;
/** Open a message link alone, without sidebar and list. */
fullscreen?: boolean;
}
/**
* 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 parts of the mail list's state that decide what a folder link shows. */
export interface OpenFolderView {
selectedMailbox: string;
isUnifiedView: boolean;
isScheduledView: boolean;
selectedKeyword: string | null;
hasSearch: boolean;
}
/**
* Whether a folder link names the plain folder the list already shows.
*
* The address bar carries the open folder (`buildMailPath`), so reloading the
* page hands that folder back as a link once the boot fetch has loaded it.
* Selecting it again would only fetch the same list a second time under the
* loading overlay. Virtual views are never "already open" at boot: the boot
* fetch does not load them.
*/
export function isFolderLinkOpen(mailboxId: string, view: OpenFolderView): boolean {
if (VIRTUAL_ALIAS_BY_ID[mailboxId]) return false;
return (
mailboxId === view.selectedMailbox &&
!view.isUnifiedView &&
!view.isScheduledView &&
!view.selectedKeyword &&
!view.hasSearch
);
}
/**
* 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 {
const query = (fullscreen: boolean) => {
const params = new URLSearchParams();
if (state.loginId) params.set('account', state.loginId);
if (state.ownerAccountId) params.set('owner', state.ownerAccountId);
if (fullscreen) params.set('view', 'fullscreen');
const qs = params.toString();
return qs ? `?${qs}` : '';
};
if (state.threadId) return `/mail/thread/${encodeSegment(state.threadId)}${query(false)}`;
if (state.emailId) return `/mail/message/${encodeSegment(state.emailId)}${query(!!state.fullscreen)}`;
if (state.mailboxId) return `/mail/folder/${encodeSegment(buildFolderRef(state.mailboxId, mailboxes))}`;
return '/mail';
}
/**
* Whether a real browser path (any mount or locale prefix) opens a folder
* link, `/mail/folder/<ref>`. Read before the shell renders, when only the
* address bar can tell a reload of a folder from a plain visit to the app.
*/
export function pathNamesMailFolder(pathname: string): boolean {
return /(?:^|\/)mail\/folder\/[^/]+/.test(pathname);
}
/**
* 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 ownerAccountId = search?.get('owner') || undefined;
const [kind, value] = segments;
if (kind && value) {
const id = decodeSegment(value);
if (id) {
if (kind === 'message') return { kind: 'message', id, accountId, slot, fullscreen, ownerAccountId };
if (kind === 'thread') return { kind: 'thread', id, accountId, slot, ownerAccountId };
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;
/**
* A node of a drive shared with the user, or a page of such a drive, by the
* id the Files store lists it under ("<owner>:<id>"). It has no path under
* the user's own root; the Files app opens where it is listed instead.
*/
shared?: 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;
}
/** The query of a link into a shared drive (see FilesDeepLink.shared), leading `?` included. */
export function sharedFilesSearch(id: string, preview?: string | null): string {
const query = `?shared=${encodeURIComponent(id)}`;
return preview ? `${query}&preview=${encodeURIComponent(preview)}` : query;
}
export function buildSharedFilesPath(id: string, preview?: string | null): string {
return `/files${sharedFilesSearch(id, preview)}`;
}
export function parseFilesPath(
segments: string[] = [],
search?: URLSearchParams,
): FilesDeepLink | null {
const parts = segments.map(decodeSegment).filter(Boolean);
const preview = search?.get('preview') ?? undefined;
const shared = search?.get('shared') || undefined;
if (parts.length === 0 && !preview && !shared) return null;
return { path: `/${parts.join('/')}`, preview, ...(shared ? { shared } : {}) };
}
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------
/** `/settings/<tab>`, or `/settings/<tab>/<section>` to land on one section of the page. */
export function buildSettingsPath(tabId: string | null | undefined, section?: string | null): string {
if (!tabId) return '/settings';
const base = `/settings/${encodeSegment(tabId)}`;
return section ? `${base}/${encodeSegment(section)}` : base;
}
/**
* The tab id from `/settings/<tab>[/<section>]`. 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;
}
/** The section id from `/settings/<tab>/<section>`, if the link names one. */
export function parseSettingsSection(segments: string[] = []): string | null {
const [, section] = segments;
return section ? decodeSegment(section) : 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 };