Files
webmail/lib/attachments/save-to-folder.ts
T
Linus Rath bb2393cf04 feat: open messages and drafts in their own windows and save all attachments to a folder
An installed app had no title bar of its own, no way to save attachments
except a zip, and "pop out" stayed inside the page. A new window was a
second copy of the app that had to restore the session itself, and on
start-up that evicts any login made without "Remember me" from the list
every window shares.

- Title bar: `display_override` with `window-controls-overlay`, one
  `--titlebar-inset` variable at the page roots and skeletons, and a drag
  strip with the window title. 0 without an overlay, so nothing moves.
- Save all to a folder: `showDirectoryPicker`, asking the device policy
  before the picker and before each file, with the zip as the fallback.
- Pop-out routes `/popout/message/<id>` and `/popout/compose`, on offer in
  an installed app or when the desktop bridge has windows. Hidden in Lite.
- A pop-out borrows the main window's session and never evicts or starts a
  token timer. Every renewal now takes a Web Lock, so two windows can't
  send the same refresh token, which Stalwart 1.0 may treat as a replay.
- A draft moves to its window under the same id. A closing window hands
  unsaved text to the main window to save, since its own request wouldn't
  survive the close.
- Read state, drafts, theme and language follow across windows, and
  `html[data-desktop]` gates the app-like behaviour in `app/desktop.css`.
2026-11-03 06:50:21 +01:00

171 lines
5.9 KiB
TypeScript

/**
* "Save all attachments to a folder", with the File System Access API.
*
* These writes don't pass saveBlobAs (lib/download-guard.ts), the backstop of
* the device policies, so the caller's `allowed` is asked before each file:
* a policy that turns on while the files are written stops the rest.
*
* The folder handle is an interface, not the DOM type, so the desktop bridge
* or a test can supply its own.
*/
export interface WritableFile {
write(data: Blob | BufferSource): Promise<void>;
close(): Promise<void>;
abort?(): Promise<void>;
}
export interface FolderFile {
createWritable(): Promise<WritableFile>;
}
export interface FolderHandle {
name: string;
getFileHandle(name: string, options?: { create?: boolean }): Promise<FolderFile>;
removeEntry?(name: string): Promise<void>;
}
type DirectoryPicker = (options?: { mode?: 'read' | 'readwrite'; id?: string; startIn?: string }) => Promise<FolderHandle>;
/** Whether this browser can ask for a folder. Without it the zip download is all there is. */
export function canPickFolder(): boolean {
return typeof window !== 'undefined' && typeof (window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker === 'function';
}
/**
* Asks where to save. Call it straight from the click, before any await: the
* browser only shows the picker for a user action. Rejects with an AbortError
* when the user closes the picker (see isPickerCancelled).
*/
export function pickFolder(): Promise<FolderHandle> {
const picker = (window as unknown as { showDirectoryPicker: DirectoryPicker }).showDirectoryPicker;
return picker.call(window, { mode: 'readwrite', id: 'bulwark-attachments', startIn: 'downloads' });
}
export function isPickerCancelled(error: unknown): boolean {
return (error as { name?: unknown } | null)?.name === 'AbortError';
}
/** The name's nth alternative: " (n)" before the extension, so duplicates stay recognisable. */
function numberedName(base: string, n: number): string {
if (n === 0) return base;
const dot = base.lastIndexOf('.');
const stem = dot > 0 ? base.slice(0, dot) : base;
const ext = dot > 0 ? base.slice(dot) : '';
return `${stem} (${n})${ext}`;
}
/** Names unique within one batch, for the zip's entries. */
export function createUniqueNamer(fallback = 'attachment'): (raw: string) => string {
const used = new Set<string>();
return (raw) => {
const base = raw || fallback;
let n = 0;
while (used.has(numberedName(base, n))) n++;
const name = numberedName(base, n);
used.add(name);
return name;
};
}
/** Characters no file system takes in a name, and path parts a sender could smuggle in. */
function safeFileName(raw: string, fallback: string): string {
const leaf = raw.split(/[\\/]/).pop() ?? '';
// eslint-disable-next-line no-control-regex
const cleaned = leaf.replace(/[<>:"|?*\x00-\x1f]/g, '_').replace(/[. ]+$/, '').trim();
return cleaned && cleaned !== '.' && cleaned !== '..' ? cleaned : fallback;
}
async function existsInFolder(folder: FolderHandle, name: string): Promise<boolean> {
try {
await folder.getFileHandle(name);
return true;
} catch (error) {
const kind = (error as { name?: unknown } | null)?.name;
if (kind === 'NotFoundError') return false;
// A sub-folder of that name: taken as well.
if (kind === 'TypeMismatchError') return true;
throw error;
}
}
/** The first of the name's alternatives neither this batch nor the folder has. */
async function freeFileName(folder: FolderHandle, base: string, claimed: ReadonlySet<string>): Promise<string> {
let n = 0;
while (claimed.has(numberedName(base, n)) || await existsInFolder(folder, numberedName(base, n))) n++;
return numberedName(base, n);
}
export interface FolderSaveItem {
name: string;
/** The file's content, or null when it has none to save. Fetched only when its turn comes. */
read: () => Promise<Blob | BufferSource | null>;
}
export interface FolderSaveResult {
folder: string;
total: number;
saved: number;
/** Files that couldn't be read from the server; the rest still went. */
skipped: number;
/** Why the run ended early, when it did. */
stopped: 'policy' | 'write' | null;
}
/**
* Writes the files one at a time. A file that can't be fetched is skipped,
* like in the zip. A write that fails stops the run, because the next one
* would fail the same way (disk full, permission gone), and the result says
* how many made it. A file already in the folder is never overwritten.
*/
export async function saveFilesToFolder(
folder: FolderHandle,
items: readonly FolderSaveItem[],
allowed: () => boolean,
): Promise<FolderSaveResult> {
const result: FolderSaveResult = { folder: folder.name, total: items.length, saved: 0, skipped: 0, stopped: null };
const claimed = new Set<string>();
for (const item of items) {
if (!allowed()) {
result.stopped = 'policy';
return result;
}
let data: Blob | BufferSource | null;
try {
data = await item.read();
} catch {
data = null;
}
if (data === null) {
result.skipped += 1;
continue;
}
// The policy may have changed during the fetch, and nothing is on disk yet.
if (!allowed()) {
result.stopped = 'policy';
return result;
}
let name: string | null = null;
let writable: WritableFile | null = null;
try {
name = await freeFileName(folder, safeFileName(item.name, 'attachment'), claimed);
claimed.add(name);
const file = await folder.getFileHandle(name, { create: true });
writable = await file.createWritable();
await writable.write(data);
await writable.close();
result.saved += 1;
} catch {
await writable?.abort?.().catch(() => {});
// getFileHandle created an empty file; don't leave it looking saved.
if (name) await folder.removeEntry?.(name).catch(() => {});
result.stopped = 'write';
return result;
}
}
return result;
}