Files
webmail/lib/deep-links.ts
T
Linus Rath d9ffe8a76e fix: reach directly shared files and keep others' files on a failed copy
Fixes from the review of the Files work (1cf9944b..a9a25b94).

- A file shared on its own, or one below a folder its owner took back,
  could not be reached. Once Stalwart 1.0's placeholder folders were
  left out it became a root of "Shared with me", which listed folders
  only, and search opened it on the drive root. 0.16 lists such files
  as roots too. Each owner's files shared on their own are now listed
  on one page under "Shared with me" ("Files from <name>"), where they
  open, preview and download like any other file and nothing can be
  added; two of the same name get a number. Search opens a shared hit
  where it is listed, and links into a shared drive name the node by id
  (`/files?shared=<owner>:<id>`), so a shared folder or page also
  survives a reload. A Files page that is already open takes the link
  from search directly, since Next keeps it mounted when only the query
  changes.
- A copy that fails part-way destroys only the nodes it made, the
  deepest first and without `onDestroyRemoveChildren`, one call per
  level because 0.16 refuses a folder destroyed in the same call as
  its children. It used to destroy the new folder with everything
  below it, so whatever someone put there during a long copy was
  deleted for good. A folder holding such nodes now stays, and the
  error says part of the copy was left in place.
- The document editor opens read-only on 0.16 for a sharee without the
  rename right. 0.16.6 to 0.16.23 refuse any change a sharee makes
  without Modify, so every save of such a document failed with a 500.
  1.0, told apart by its `maxFileNodeDepth`, still asks for
  `mayModifyContent` alone.
- Ctrl+C with only symlinks selected empties the clipboard instead of
  keeping an earlier cut for the next paste.
- A paste reads each account's file tree once, not once per pasted
  item for the listing and 1.0's depth check.
- The Stalwart bundle's README says that on older 0.16 releases and
  the 1.0 pre-releases a failed update also drops the cached copy, and
  the next restart or reload of the Applications downloads it again,
  not only a later `UpdateApps`. The static README says a restart
  applies the CORS setting on the current 1.0 pre-releases, where
  `ReloadSettings` fails with the stock spam-filter rules.
2026-09-25 22:16:19 +02:00

477 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']);
export type MailDeepLink =
| { kind: 'folder'; ref: string; accountId?: string }
| { kind: 'message'; id: string; accountId?: string; fullscreen?: boolean }
| { kind: 'thread'; id: string; accountId?: string };
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;
// `?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, fullscreen };
if (kind === 'thread') return { kind: 'thread', id, accountId };
if (kind === 'folder') return { kind: 'folder', ref: id, accountId };
}
}
const legacyEmail = search?.get('email');
if (legacyEmail) return { kind: 'message', id: legacyEmail, accountId };
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
// ---------------------------------------------------------------------------
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 };