/** * 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 = { 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 = 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/`. 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=` * 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/[/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 (":"). 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/`, or `/settings//
` 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/[/
]`. 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//
`, 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 };