@wtfalch/email 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ import type { MailClient } from './client.ts';
2
+ import type { Attachment } from './types.ts';
3
+ /**
4
+ * Fetching an attachment's bytes.
5
+ *
6
+ * **This module exists because a download URL is not a link.** JMAP's
7
+ * `downloadUrl` carries no credential of its own — RFC 8620 §6.2 expects the
8
+ * same authentication the API uses — so `<a href={downloadUrl} download>` and
9
+ * `<img src={cid}>` both fetch it without an `Authorization` header and get a
10
+ * 401. The bytes have to be fetched by code that holds the token, and the
11
+ * result handed to the browser as a blob it already has.
12
+ *
13
+ * That is the whole reason attachments were unreachable: `ThreadView` has
14
+ * taken an `onDownload` since it was written, and nothing could supply one
15
+ * without this.
16
+ *
17
+ * **`credentials: 'omit'`, for the reason `client.ts` gives at length.** A
18
+ * refused request makes Stalwart answer with `WWW-Authenticate: Basic` as
19
+ * well as `Bearer`, and a browser allowed to use credentials honours the
20
+ * second by opening its native password prompt — a modal that hides whatever
21
+ * the application was about to say. Declining that here keeps a 401 an
22
+ * ordinary response.
23
+ */
24
+ /** How much of a message this will pull into memory before refusing. A mail
25
+ * server will happily serve a 2GB blob and a browser tab will not survive
26
+ * holding one; Stalwart's own default message size limit is well under this.
27
+ * A caller that genuinely wants more passes its own `maxBytes`. */
28
+ export declare const DEFAULT_MAX_DOWNLOAD_BYTES: number;
29
+ export type DownloadOptions = {
30
+ /** Refuse a blob larger than this, rather than exhausting the tab. */
31
+ maxBytes?: number;
32
+ /** Abort the fetch — a person navigating away from a slow attachment. */
33
+ signal?: AbortSignal;
34
+ };
35
+ /**
36
+ * An attachment's bytes, as a `Blob`.
37
+ *
38
+ * The type comes from the message rather than from the response: a server
39
+ * that sends `application/octet-stream` for everything would otherwise turn
40
+ * every PDF into a file the operating system cannot open, and the part's
41
+ * declared type is the one the sender chose.
42
+ */
43
+ export declare function downloadAttachment(client: MailClient, attachment: Pick<Attachment, 'blobId' | 'name' | 'type' | 'size' | 'downloadUrl'>, options?: DownloadOptions): Promise<Blob>;
@@ -0,0 +1,68 @@
1
+ import { MailError, guard } from "./errors.js";
2
+ /**
3
+ * Fetching an attachment's bytes.
4
+ *
5
+ * **This module exists because a download URL is not a link.** JMAP's
6
+ * `downloadUrl` carries no credential of its own — RFC 8620 §6.2 expects the
7
+ * same authentication the API uses — so `<a href={downloadUrl} download>` and
8
+ * `<img src={cid}>` both fetch it without an `Authorization` header and get a
9
+ * 401. The bytes have to be fetched by code that holds the token, and the
10
+ * result handed to the browser as a blob it already has.
11
+ *
12
+ * That is the whole reason attachments were unreachable: `ThreadView` has
13
+ * taken an `onDownload` since it was written, and nothing could supply one
14
+ * without this.
15
+ *
16
+ * **`credentials: 'omit'`, for the reason `client.ts` gives at length.** A
17
+ * refused request makes Stalwart answer with `WWW-Authenticate: Basic` as
18
+ * well as `Bearer`, and a browser allowed to use credentials honours the
19
+ * second by opening its native password prompt — a modal that hides whatever
20
+ * the application was about to say. Declining that here keeps a 401 an
21
+ * ordinary response.
22
+ */
23
+ /** How much of a message this will pull into memory before refusing. A mail
24
+ * server will happily serve a 2GB blob and a browser tab will not survive
25
+ * holding one; Stalwart's own default message size limit is well under this.
26
+ * A caller that genuinely wants more passes its own `maxBytes`. */
27
+ export const DEFAULT_MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
28
+ /**
29
+ * An attachment's bytes, as a `Blob`.
30
+ *
31
+ * The type comes from the message rather than from the response: a server
32
+ * that sends `application/octet-stream` for everything would otherwise turn
33
+ * every PDF into a file the operating system cannot open, and the part's
34
+ * declared type is the one the sender chose.
35
+ */
36
+ export async function downloadAttachment(client, attachment, options = {}) {
37
+ const max = options.maxBytes ?? DEFAULT_MAX_DOWNLOAD_BYTES;
38
+ /* Checked before the request as well as after. The message says how big
39
+ the part is, and refusing on that alone saves pulling a gigabyte over
40
+ the wire to then refuse it. */
41
+ if (attachment.size > max) {
42
+ throw new MailError(`${attachment.name} is ${attachment.size} bytes, over the ${max}-byte limit for a download`, { type: 'tooLarge', operation: 'download' });
43
+ }
44
+ /* The URL on the attachment was expanded when the message was read. A
45
+ caller that built an `Attachment` by hand may not have one, so it is
46
+ recomputed rather than assumed. */
47
+ const url = attachment.downloadUrl || (await client.downloadUrl(attachment));
48
+ return guard('download', async () => {
49
+ const response = await fetch(url, {
50
+ headers: { Authorization: client.authorization },
51
+ credentials: 'omit',
52
+ ...(options.signal ? { signal: options.signal } : {}),
53
+ });
54
+ if (!response.ok) {
55
+ throw new MailError(`the server refused to send ${attachment.name}`, {
56
+ status: response.status,
57
+ operation: 'download',
58
+ });
59
+ }
60
+ const bytes = await response.arrayBuffer();
61
+ /* A server is not obliged to send `Content-Length`, and a chunked
62
+ response can be any size whatever the message claimed. */
63
+ if (bytes.byteLength > max) {
64
+ throw new MailError(`${attachment.name} arrived at ${bytes.byteLength} bytes, over the ${max}-byte limit`, { type: 'tooLarge', operation: 'download' });
65
+ }
66
+ return new Blob([bytes], { type: attachment.type || 'application/octet-stream' });
67
+ });
68
+ }
@@ -23,6 +23,8 @@ export { draftToEmail, send, sentPatch } from './submit.ts';
23
23
  export type { SendOptions } from './submit.ts';
24
24
  export { moveThread, moveToRole, removeFromMailbox, setFlagged, setRead, } from './mutate.ts';
25
25
  export type { MoveOptions } from './mutate.ts';
26
+ export { DEFAULT_MAX_DOWNLOAD_BYTES, downloadAttachment } from './download.ts';
27
+ export type { DownloadOptions } from './download.ts';
26
28
  export { push } from './push.ts';
27
29
  export type { PushHandle, PushOptions } from './push.ts';
28
30
  export { expandTemplate } from './uri.ts';
@@ -14,5 +14,6 @@ export { identities } from "./identities.js";
14
14
  export { attribution, formatAddress, forwardDraft, forwardIntroduction, forwardSubject, quoteText, replyDraft, replySubject, } from "./drafts.js";
15
15
  export { draftToEmail, send, sentPatch } from "./submit.js";
16
16
  export { moveThread, moveToRole, removeFromMailbox, setFlagged, setRead, } from "./mutate.js";
17
+ export { DEFAULT_MAX_DOWNLOAD_BYTES, downloadAttachment } from "./download.js";
17
18
  export { push } from "./push.js";
18
19
  export { expandTemplate } from "./uri.js";
@@ -925,6 +925,10 @@
925
925
  color: var(--muted);
926
926
  }
927
927
 
928
+ .attachment.is-busy {
929
+ cursor: progress;
930
+ }
931
+
928
932
  .attachment-size {
929
933
  color: var(--muted);
930
934
  font-variant-numeric: tabular-nums;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Button, Command, Icon, Modal, SplitPane } from '@wtfalch/design';
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { downloadAttachment } from "../download.js";
4
5
  import { forwardDraft, replyDraft } from "../drafts.js";
5
6
  import { findRole } from "../mailboxes.js";
6
7
  import { moveToRole, setFlagged, setRead } from "../mutate.js";
@@ -11,6 +12,7 @@ import { ThreadList } from "./ThreadList.js";
11
12
  import { ThreadView } from "./ThreadView.js";
12
13
  import { useIdentities, useMailboxes, usePush, useThread, useThreads } from "./hooks.js";
13
14
  import { usePanes } from "./layout.js";
15
+ import { saveBlob } from "./save.js";
14
16
  /**
15
17
  * The whole client: mailboxes, a list, a message, and a way to write one.
16
18
  *
@@ -73,6 +75,10 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
73
75
  const [helpOpen, setHelpOpen] = useState(false);
74
76
  const [acting, setActing] = useState(false);
75
77
  const [note, setNote] = useState(null);
78
+ /* By blob id rather than a single flag: four attachments on one message can
79
+ be fetched at once, and one spinner for all of them would say the wrong
80
+ thing about three of them. */
81
+ const [downloading, setDownloading] = useState(new Set());
76
82
  const searchField = useRef(null);
77
83
  const boxes = useMailboxes(client);
78
84
  const identities = useIdentities(client);
@@ -227,6 +233,35 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
227
233
  return;
228
234
  void act(() => setRead(client, threadId, false), 'Marked unread.');
229
235
  }, [act, client, threadId]);
236
+ /**
237
+ * An attachment, fetched and handed to the browser.
238
+ *
239
+ * `ThreadView` has taken an `onDownload` since it was written and nothing
240
+ * ever supplied one, so every attachment button in this client rendered
241
+ * disabled. It could not be a link: a JMAP download URL carries no
242
+ * credential, so an anchor fetches it without the token and gets a 401.
243
+ *
244
+ * The name is said in the failure because "that did not work" over a list
245
+ * of four files does not say which.
246
+ */
247
+ const download = useCallback(async (file) => {
248
+ setDownloading((busy) => new Set(busy).add(file.blobId));
249
+ try {
250
+ saveBlob(await downloadAttachment(client, file), file.name);
251
+ }
252
+ catch (error) {
253
+ setNote(error instanceof Error
254
+ ? `${file.name} did not download: ${error.message}`
255
+ : `${file.name} did not download.`);
256
+ }
257
+ finally {
258
+ setDownloading((busy) => {
259
+ const next = new Set(busy);
260
+ next.delete(file.blobId);
261
+ return next;
262
+ });
263
+ }
264
+ }, [client]);
230
265
  const flag = useCallback((next) => {
231
266
  if (!threadId)
232
267
  return;
@@ -403,7 +438,7 @@ export function Mail({ client, location, onNavigate, account, onError, now, clas
403
438
  setQuery('');
404
439
  }, "aria-label": "Clear the search", children: _jsx(Icon, { name: "close", size: 13 }) })) : (_jsx("span", { className: "mail-search-key", "aria-hidden": "true", children: "/" }))] }));
405
440
  const list = (_jsx(ThreadList, { page: threads.data, selectedId: threadId, onSelect: openThread, onPage: (threads.data?.total ?? 0) > PAGE ? setPosition : undefined, limit: PAGE, loading: threads.loading, now: now, title: query ? `Results for “${query}”` : current?.name, empty: query ? _jsxs("p", { className: "threads-quiet", children: ["Nothing matches \u201C", query, "\u201D."] }) : undefined }));
406
- const reader = (_jsx(ThreadView, { thread: thread.data, loading: thread.loading, onReply: startReply, onForward: startForward, onArchive: archiveBox ? archive : undefined, onTrash: trashBox ? trash : undefined, onMarkUnread: markUnread, onFlag: flag, flagged: summary?.isFlagged ?? false, busy: acting, onBack: panes === 'narrow' ? () => setThreadId(null) : undefined }));
441
+ const reader = (_jsx(ThreadView, { thread: thread.data, loading: thread.loading, onReply: startReply, onForward: startForward, onArchive: archiveBox ? archive : undefined, onTrash: trashBox ? trash : undefined, onMarkUnread: markUnread, onFlag: flag, onDownload: (file) => void download(file), downloading: downloading, flagged: summary?.isFlagged ?? false, busy: acting, onBack: panes === 'narrow' ? () => setThreadId(null) : undefined }));
407
442
  return (_jsxs("div", { className: `mail is-${panes}${className ? ` ${className}` : ''}`, children: [_jsxs("header", { className: "mail-bar", children: [_jsxs("div", { className: "mail-bar-start", children: [panes === 'narrow' && (_jsx(Button, { kind: "ghost", iconOnly: true, onClick: () => setDrawerOpen(true), "aria-label": "Mailboxes", children: _jsx(Icon, { name: "menu" }) })), _jsx("span", { className: "mail-wordmark", children: "Mail" })] }), searchBox, _jsxs("div", { className: "mail-bar-end", children: [_jsxs(Button, { kind: "primary", size: "sm", onClick: startNew, disabled: !identity, children: [_jsx(Icon, { name: "pen", size: 15 }), " ", _jsx("span", { className: "mail-compose-word", children: "Compose" })] }), account] })] }), panes === 'wide' && (_jsxs(SplitPane, { className: "mail-panes", label: "Mailbox list width", defaultSize: 15, min: 11, max: 28, storageKey: "mail-sidebar", children: [_jsx(MailboxTree, { mailboxes: boxes.data ?? [], selectedId: mailboxId, onSelect: go, loading: boxes.loading }), _jsxs(SplitPane, { label: "Conversation list width", defaultSize: 36, min: 26, max: 55, storageKey: "mail-list", children: [list, reader] })] })), panes === 'medium' && (_jsxs("div", { className: "mail-panes", children: [_jsx(MailboxTree, { mailboxes: boxes.data ?? [], selectedId: mailboxId, onSelect: go, loading: boxes.loading, rail: true }), _jsxs(SplitPane, { className: "mail-panes-split", label: "Conversation list width", defaultSize: 40, min: 28, max: 60, storageKey: "mail-list", children: [list, reader] })] })), panes === 'narrow' && _jsx("div", { className: "mail-panes", children: reading ? reader : list }), drawerOpen && (_jsx(Modal, { title: "Mailboxes", width: "20rem", onClose: () => setDrawerOpen(false), children: _jsx(MailboxTree, { mailboxes: boxes.data ?? [], selectedId: mailboxId, onSelect: go, loading: boxes.loading }) })), _jsx(Command, { open: paletteOpen, onOpenChange: setPaletteOpen, groups: paletteGroups }), helpOpen && (_jsx(Modal, { title: "Keyboard shortcuts", width: "26rem", onClose: () => setHelpOpen(false), children: _jsx(Shortcuts, {}) })), draft && (_jsx(Modal, { title: draft.subject ? draft.subject : 'New message', width: "46rem", onClose: sending ? undefined : () => setDraft(null), closeDisabled: sending, children: _jsx(Composer, { draft: draft, onChange: setDraft, onSend: doSend, onCancel: () => setDraft(null), identities: identities.data ?? [], sending: sending, error: sendError }) })), _jsx(Note, { said: note, onDone: () => setNote(null) })] }));
408
443
  }
409
444
  /**
@@ -49,10 +49,14 @@ export type ThreadViewProps = ThreadActions & {
49
49
  /** Fetches an attachment's bytes; the URL needs the same credential the API
50
50
  * does, so a bare link cannot do it. */
51
51
  onDownload?: (attachment: Attachment) => void;
52
+ /** Blob ids currently being fetched, so each row can say so for itself.
53
+ * A set rather than a boolean: four files on one message download
54
+ * independently and one spinner for all of them lies about three. */
55
+ downloading?: ReadonlySet<string>;
52
56
  /** Back to the list, on a window too narrow to show both. */
53
57
  onBack?: () => void;
54
58
  className?: string;
55
59
  };
56
- export declare function ThreadView({ thread, loading, onReply, onForward, onArchive, onTrash, onMarkUnread, onFlag, flagged, busy, onDownload, onBack, className, }: ThreadViewProps): import("react").JSX.Element;
60
+ export declare function ThreadView({ thread, loading, onReply, onForward, onArchive, onTrash, onMarkUnread, onFlag, flagged, busy, onDownload, downloading, onBack, className, }: ThreadViewProps): import("react").JSX.Element;
57
61
  /** Bytes, the way a mail client says them. */
58
62
  export declare function bytes(size: number): string;
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Button, Empty, Icon, Identity, Menu, ScrollArea, Skeleton } from '@wtfalch/design';
3
3
  import { useEffect, useState } from 'react';
4
- export function ThreadView({ thread, loading = false, onReply, onForward, onArchive, onTrash, onMarkUnread, onFlag, flagged = false, busy = false, onDownload, onBack, className, }) {
4
+ export function ThreadView({ thread, loading = false, onReply, onForward, onArchive, onTrash, onMarkUnread, onFlag, flagged = false, busy = false, onDownload, downloading, onBack, className, }) {
5
5
  if (loading && !thread) {
6
6
  return (_jsxs("div", { className: `thread${className ? ` ${className}` : ''}`, "aria-busy": "true", children: [_jsxs("header", { className: "thread-head", children: [_jsx(Skeleton, { width: "52%", height: 5 }), _jsx(Skeleton, { width: "28%", height: 2.5 })] }), _jsx("div", { className: "thread-body", children: _jsxs("div", { className: "message open", children: [_jsxs("div", { className: "message-head", children: [_jsx(Skeleton, { variant: "circular", width: "2rem", height: 8 }), _jsx(Skeleton, { width: "30%", height: 3 })] }), _jsx("div", { className: "message-body", children: _jsx(Skeleton, { lines: 6, height: 2.5 }) })] }) })] }));
7
7
  }
@@ -13,7 +13,7 @@ export function ThreadView({ thread, loading = false, onReply, onForward, onArch
13
13
  const people = [
14
14
  ...new Map(thread.messages.flatMap((message) => message.from.map((who) => [who.email, who.name?.trim() || who.email]))).values(),
15
15
  ];
16
- return (_jsxs("div", { className: `thread${className ? ` ${className}` : ''}`, children: [_jsxs("header", { className: "thread-head", children: [_jsxs("div", { className: "thread-headline", children: [onBack && (_jsx(Button, { kind: "ghost", iconOnly: true, onClick: onBack, "aria-label": "Back to the list", children: _jsx(Icon, { name: "back" }) })), _jsxs("div", { className: "thread-heading", children: [_jsx("h2", { className: "thread-title", children: thread.subject || '(no subject)' }), _jsxs("p", { className: "thread-meta", children: [people.join(', '), _jsx("span", { className: "thread-meta-sep", children: "\u00B7" }), thread.messages.length === 1 ? '1 message' : `${thread.messages.length} messages`] })] })] }), _jsx(Toolbar, { message: newest, onReply: onReply, onForward: onForward, onArchive: onArchive, onTrash: onTrash, onMarkUnread: onMarkUnread, onFlag: onFlag, flagged: flagged, busy: busy })] }), _jsxs(ScrollArea, { className: "thread-body", label: "Messages in this conversation", children: [_jsx("ol", { className: "messages", children: thread.messages.map((message, index) => (_jsx("li", { children: _jsx(MessageCard, { message: message, defaultOpen: index === last, onReply: onReply, onForward: onForward, onDownload: onDownload }) }, message.id))) }), onReply && newest && (_jsxs("div", { className: "thread-foot", children: [_jsxs(Button, { kind: "primary", onClick: () => onReply(newest, false), disabled: busy, children: [_jsx(Icon, { name: "reply" }), " Reply"] }), _jsx(Button, { kind: "ghost", onClick: () => onReply(newest, true), disabled: busy, children: "Reply all" }), onForward && (_jsx(Button, { kind: "ghost", onClick: () => onForward(newest), disabled: busy, children: "Forward" }))] }))] })] }));
16
+ return (_jsxs("div", { className: `thread${className ? ` ${className}` : ''}`, children: [_jsxs("header", { className: "thread-head", children: [_jsxs("div", { className: "thread-headline", children: [onBack && (_jsx(Button, { kind: "ghost", iconOnly: true, onClick: onBack, "aria-label": "Back to the list", children: _jsx(Icon, { name: "back" }) })), _jsxs("div", { className: "thread-heading", children: [_jsx("h2", { className: "thread-title", children: thread.subject || '(no subject)' }), _jsxs("p", { className: "thread-meta", children: [people.join(', '), _jsx("span", { className: "thread-meta-sep", children: "\u00B7" }), thread.messages.length === 1 ? '1 message' : `${thread.messages.length} messages`] })] })] }), _jsx(Toolbar, { message: newest, onReply: onReply, onForward: onForward, onArchive: onArchive, onTrash: onTrash, onMarkUnread: onMarkUnread, onFlag: onFlag, flagged: flagged, busy: busy })] }), _jsxs(ScrollArea, { className: "thread-body", label: "Messages in this conversation", children: [_jsx("ol", { className: "messages", children: thread.messages.map((message, index) => (_jsx("li", { children: _jsx(MessageCard, { message: message, defaultOpen: index === last, onReply: onReply, onForward: onForward, onDownload: onDownload, downloading: downloading }) }, message.id))) }), onReply && newest && (_jsxs("div", { className: "thread-foot", children: [_jsxs(Button, { kind: "primary", onClick: () => onReply(newest, false), disabled: busy, children: [_jsx(Icon, { name: "reply" }), " Reply"] }), _jsx(Button, { kind: "ghost", onClick: () => onReply(newest, true), disabled: busy, children: "Reply all" }), onForward && (_jsx(Button, { kind: "ghost", onClick: () => onForward(newest), disabled: busy, children: "Forward" }))] }))] })] }));
17
17
  }
18
18
  /** A hover hint on a control that has only a glyph. `aria-hidden` on the
19
19
  * wrapper is wrong -- the button inside it has to stay in the tree -- so
@@ -74,7 +74,7 @@ function Toolbar({ message, onReply, onForward, onArchive, onTrash, onMarkUnread
74
74
  ];
75
75
  return (_jsxs("div", { className: "thread-tools", children: [onReply && (_jsx(Hint, { says: "Reply \u2014 R", children: _jsx(Button, { kind: "ghost", iconOnly: true, disabled: busy, onClick: () => onReply(message, false), "aria-label": "Reply", children: _jsx(Icon, { name: "reply" }) }) })), onForward && (_jsx(Hint, { says: "Forward \u2014 F", children: _jsx(Button, { kind: "ghost", iconOnly: true, disabled: busy, onClick: () => onForward?.(message), "aria-label": "Forward", children: _jsx(Icon, { name: "forward" }) }) })), _jsx("span", { className: "thread-tools-rule", "aria-hidden": "true" }), onFlag && (_jsx(Hint, { says: flagged ? 'Unflag — S' : 'Flag — S', children: _jsx(Button, { kind: "ghost", iconOnly: true, disabled: busy, onClick: () => onFlag(!flagged), "aria-pressed": flagged, "aria-label": flagged ? 'Unflag' : 'Flag', className: flagged ? 'is-flagged' : undefined, children: _jsx(Icon, { name: flagged ? 'star-filled' : 'star' }) }) })), onArchive && (_jsx(Hint, { says: "Archive \u2014 E", children: _jsx(Button, { kind: "ghost", iconOnly: true, disabled: busy, onClick: onArchive, "aria-label": "Archive", children: _jsx(Icon, { name: "folder" }) }) })), onTrash && (_jsx(Hint, { says: "Delete \u2014 #", children: _jsx(Button, { kind: "ghost", iconOnly: true, disabled: busy, onClick: onTrash, "aria-label": "Delete", children: _jsx(Icon, { name: "trash" }) }) })), overflow.length > 0 && (_jsx(Menu, { label: "More actions", placement: "bottom end", trigger: _jsx(Button, { kind: "ghost", iconOnly: true, disabled: busy, "aria-label": "More actions", children: _jsx(Icon, { name: "more" }) }), items: overflow }))] }));
76
76
  }
77
- function MessageCard({ message, defaultOpen, onReply, onForward, onDownload, }) {
77
+ function MessageCard({ message, defaultOpen, onReply, onForward, onDownload, downloading, }) {
78
78
  const [open, setOpen] = useState(defaultOpen);
79
79
  /* The newest message changes when a reply arrives, and the card that was
80
80
  newest should not stay open while the new one opens under it. */
@@ -119,7 +119,10 @@ function MessageCard({ message, defaultOpen, onReply, onForward, onDownload, })
119
119
  reflowing it destroys both. */
120
120
  _jsx("pre", { className: "message-text", children: message.text })) : message.html !== null ? (_jsx(HtmlNotice, { html: message.html })) : (_jsx("p", { className: "message-quiet", children: "This message has no body." })), message.isTruncated && (_jsx("p", { className: "message-quiet", children: "This message was cut short at half a megabyte. The rest has not been fetched." })), message.attachments.length > 0 && (_jsx("ul", { className: "attachments", children: message.attachments
121
121
  .filter((file) => file.disposition !== 'inline')
122
- .map((file) => (_jsx("li", { children: _jsxs("button", { type: "button", className: "attachment", onClick: () => onDownload?.(file), disabled: !onDownload, children: [_jsx(Icon, { name: "paperclip", className: "attachment-icon" }), _jsx("span", { className: "attachment-name", children: file.name }), _jsx("span", { className: "attachment-size", children: bytes(file.size) })] }) }, file.blobId))) }))] }))] }));
122
+ .map((file) => {
123
+ const busy = downloading?.has(file.blobId) ?? false;
124
+ return (_jsx("li", { children: _jsxs("button", { type: "button", className: `attachment${busy ? ' is-busy' : ''}`, onClick: () => onDownload?.(file), disabled: !onDownload || busy, "aria-busy": busy || undefined, children: [_jsx(Icon, { name: busy ? 'spinner' : 'paperclip', className: `attachment-icon${busy ? ' spin' : ''}` }), _jsx("span", { className: "attachment-name", children: file.name }), _jsx("span", { className: "attachment-size", children: busy ? 'Fetching…' : bytes(file.size) })] }) }, file.blobId));
125
+ }) }))] }))] }));
123
126
  }
124
127
  function Recipients({ message }) {
125
128
  const line = (label, people) => people.length === 0 ? null : (_jsxs("p", { className: "message-line", children: [_jsx("span", { className: "message-line-label", children: label }), _jsx("span", { children: people.map((p) => p.name?.trim() || p.email).join(', ') })] }));
@@ -23,4 +23,5 @@ export type { ComposerProps } from './Composer.tsx';
23
23
  export { Mail } from './Mail.tsx';
24
24
  export type { MailLocation, MailProps } from './Mail.tsx';
25
25
  export { usePanes } from './layout.ts';
26
+ export { saveBlob } from './save.ts';
26
27
  export type { Panes } from './layout.ts';
@@ -17,3 +17,4 @@ export { ThreadView, bytes } from "./ThreadView.js";
17
17
  export { Composer, parseAddresses } from "./Composer.js";
18
18
  export { Mail } from "./Mail.js";
19
19
  export { usePanes } from "./layout.js";
20
+ export { saveBlob } from "./save.js";
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Handing a blob to the browser as a file.
3
+ *
4
+ * Its own module, and in the react folder rather than beside `download.ts`,
5
+ * because this half is pure DOM: `download.ts` fetches bytes and runs
6
+ * anywhere, this needs a `document`. Splitting them keeps the fetch testable
7
+ * in Node and keeps an application that wants to do its own saving — write to
8
+ * a directory handle, open in a viewer, upload somewhere else — able to take
9
+ * the bytes and skip this.
10
+ */
11
+ /**
12
+ * Save a blob under a filename.
13
+ *
14
+ * **The object URL is revoked on a timer, not immediately.** Revoking in the
15
+ * same tick as the click cancels the download in Chrome and Safari: the
16
+ * anchor's activation is queued, and by the time the browser reads the URL it
17
+ * has already been released. A tick is the usual fix and is still a race; a
18
+ * minute is not, and an object URL is a pointer to memory the page already
19
+ * holds, so holding it a little longer costs nothing.
20
+ *
21
+ * **The anchor is not appended in Firefox's case only.** A detached anchor's
22
+ * click is ignored there, so it goes into the document and comes straight
23
+ * back out, which is invisible and works everywhere.
24
+ */
25
+ export declare function saveBlob(blob: Blob, name: string): void;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Handing a blob to the browser as a file.
3
+ *
4
+ * Its own module, and in the react folder rather than beside `download.ts`,
5
+ * because this half is pure DOM: `download.ts` fetches bytes and runs
6
+ * anywhere, this needs a `document`. Splitting them keeps the fetch testable
7
+ * in Node and keeps an application that wants to do its own saving — write to
8
+ * a directory handle, open in a viewer, upload somewhere else — able to take
9
+ * the bytes and skip this.
10
+ */
11
+ /**
12
+ * Save a blob under a filename.
13
+ *
14
+ * **The object URL is revoked on a timer, not immediately.** Revoking in the
15
+ * same tick as the click cancels the download in Chrome and Safari: the
16
+ * anchor's activation is queued, and by the time the browser reads the URL it
17
+ * has already been released. A tick is the usual fix and is still a race; a
18
+ * minute is not, and an object URL is a pointer to memory the page already
19
+ * holds, so holding it a little longer costs nothing.
20
+ *
21
+ * **The anchor is not appended in Firefox's case only.** A detached anchor's
22
+ * click is ignored there, so it goes into the document and comes straight
23
+ * back out, which is invisible and works everywhere.
24
+ */
25
+ export function saveBlob(blob, name) {
26
+ if (typeof document === 'undefined') {
27
+ throw new Error('saveBlob needs a document; call it from the browser');
28
+ }
29
+ const url = URL.createObjectURL(blob);
30
+ const anchor = document.createElement('a');
31
+ anchor.href = url;
32
+ /* A filename with a path separator in it is a filename a server chose, and
33
+ browsers vary in how much of it they honour. Only the last segment is
34
+ ever a name. */
35
+ anchor.download = name.split(/[\\/]/).pop() || 'attachment';
36
+ anchor.rel = 'noopener';
37
+ anchor.style.display = 'none';
38
+ document.body.appendChild(anchor);
39
+ anchor.click();
40
+ anchor.remove();
41
+ window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/email",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "The wtfalch estate: reading a mailbox over JMAP, and administering the Stalwart server it lives on. Two entries with no code in common.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -61,7 +61,7 @@
61
61
  "@types/node": "^22",
62
62
  "@types/react": "^19",
63
63
  "@types/react-dom": "^19",
64
- "@wtfalch/design": "^0.5.0",
64
+ "@wtfalch/design": "^0.7.0",
65
65
  "jsdom": "^30.0.1",
66
66
  "react": "^19",
67
67
  "react-dom": "^19",