@rapidmx/web-client 0.34.0 → 0.35.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.
Files changed (39) hide show
  1. package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
  2. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
  3. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  4. package/apps/shared/components/mail/MessageDetailPane.tsx +56 -7
  5. package/apps/shared/components/mail/compose/quotedBody.ts +27 -12
  6. package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
  7. package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
  8. package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
  9. package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
  10. package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
  11. package/apps/www/calendar/index.tsx +33 -31
  12. package/apps/www/contacts/index.tsx +58 -2
  13. package/apps/www/tasks/index.tsx +42 -0
  14. package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
  15. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
  16. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  17. package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
  18. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  19. package/dist/apps/shared/components/mail/MessageDetailPane.js +40 -5
  20. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -2
  21. package/dist/apps/shared/components/mail/compose/quotedBody.js +24 -12
  22. package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
  23. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
  24. package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
  25. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
  26. package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
  27. package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
  28. package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
  29. package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
  30. package/dist/apps/www/calendar/index.js +7 -7
  31. package/dist/apps/www/contacts/index.js +49 -3
  32. package/dist/apps/www/tasks/index.js +29 -1
  33. package/dist/lib/mail/pushClient.d.ts +10 -3
  34. package/dist/lib/mail/pushClient.js +18 -4
  35. package/dist/lib/tasks/tasksApi.d.ts +1 -0
  36. package/dist/lib/tasks/tasksApi.js +3 -0
  37. package/lib/mail/pushClient.ts +18 -4
  38. package/lib/tasks/tasksApi.ts +4 -0
  39. package/package.json +1 -1
@@ -10,6 +10,7 @@ import {
10
10
  Contact,
11
11
  createContact,
12
12
  deleteContact,
13
+ getContact,
13
14
  listContacts,
14
15
  ensureSuggestedContacts,
15
16
  listContactsInFolders,
@@ -42,6 +43,18 @@ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
42
43
  import { useShortcut } from "../../shared/keyboard/useShortcut.js";
43
44
  import { notifyApiError } from "../../shared/notifications/apiErrors.js";
44
45
  import { notify } from "../../shared/notifications/store.js";
46
+ import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
47
+
48
+ /** The published model class name of a contact on either database (`ContactMongo`, `ContactSQL`) - not a `ContactList`'s. */
49
+ const CONTACT_MODEL = /^Contact(Mongo|SQL)$/;
50
+
51
+ /** The push client channel group (`PushClient.setChannels()`) the page's contacts folders are subscribed under. */
52
+ const CONTACTS_CHANNEL_GROUP = "contacts";
53
+
54
+ /** `list` with `contact` in place of the one with its uid, or added when it has none. */
55
+ function upsertContact(list: Contact[], contact: Contact): Contact[] {
56
+ return list.some((existing) => existing.uid === contact.uid) ? list.map((existing) => (existing.uid === contact.uid ? contact : existing)) : [...list, contact];
57
+ }
45
58
 
46
59
  const INPUT_CLASS =
47
60
  "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
@@ -166,6 +179,45 @@ function ContactsContent({ userUid }: { userUid?: string }) {
166
179
  };
167
180
  }, [mailboxUid]);
168
181
 
182
+ // A contact created, changed or deleted anywhere - another tab, a phone over ActiveSync - is announced on its folder's channel. It is fetched again by
183
+ // its uid and filed in the list of the folder it is in now (the mailbox's contacts, or its suggested contacts), and taken out of the other; one that is
184
+ // gone (a delete, or a 404) leaves both. A failed fetch changes nothing: the next load shows it as it is.
185
+ useFolderLiveUpdates(
186
+ CONTACTS_CHANNEL_GROUP,
187
+ [folderUid, suggestedFolderUid].filter((uid): uid is string => !!uid),
188
+ (event) => changedItemOf(event, CONTACT_MODEL),
189
+ (change) => {
190
+ const remove = (uid: string) => {
191
+ setContacts((previous) => previous.filter((existing) => existing.uid !== uid));
192
+ setSuggestedContacts((previous) => previous.filter((existing) => existing.uid !== uid));
193
+ };
194
+ if (change.deleted) {
195
+ remove(change.uid);
196
+ return;
197
+ }
198
+ void getContact(change.uid, client).then(
199
+ (fresh) => {
200
+ if (fresh.deleted === true) {
201
+ remove(fresh.uid);
202
+ } else if (fresh.folderUid === folderUid) {
203
+ setContacts((previous) => upsertContact(previous, fresh));
204
+ setSuggestedContacts((previous) => previous.filter((existing) => existing.uid !== fresh.uid));
205
+ } else if (fresh.folderUid === suggestedFolderUid) {
206
+ setSuggestedContacts((previous) => upsertContact(previous, fresh));
207
+ setContacts((previous) => previous.filter((existing) => existing.uid !== fresh.uid));
208
+ } else {
209
+ remove(fresh.uid);
210
+ }
211
+ },
212
+ (err: unknown) => {
213
+ if (err instanceof ApiRequestError && err.status === 404) {
214
+ remove(change.uid);
215
+ }
216
+ },
217
+ );
218
+ },
219
+ );
220
+
169
221
  // restapi only honors `?deleted=true` for a caller with both delete and update rights on the contacts *folder*
170
222
  // (its ACL, which inherits the mailbox's but can carry its own records) - anyone else silently gets the *live*
171
223
  // contacts back, which the Deleted view would then present as deleted. There is no client API for the caller's
@@ -228,6 +280,9 @@ function ContactsContent({ userUid }: { userUid?: string }) {
228
280
  return contacts;
229
281
  case "favorites":
230
282
  return contacts.filter((c) => c.favorite);
283
+ case "everyone":
284
+ // The mailbox's own contacts and its suggested ones in one list (a person is in only one of them: a suggestion that is accepted is moved).
285
+ return [...contacts, ...suggestedContacts];
231
286
  case "suggested":
232
287
  return suggestedContacts;
233
288
  case "list":
@@ -428,7 +483,8 @@ function ContactsContent({ userUid }: { userUid?: string }) {
428
483
 
429
484
  // A suggested contact becomes one of the mailbox's own: a copy in its contacts folder, and the suggestion removed (the person is not suggested again).
430
485
  const handleAddToContacts = singleFlight(async () => {
431
- for (const contact of checkedContacts) {
486
+ // In the All contacts view the selection can hold contacts that already are the mailbox's own: only the suggested ones are added.
487
+ for (const contact of checkedContacts.filter((checked) => checked.folderUid === suggestedFolderUid)) {
432
488
  try {
433
489
  await createContact(
434
490
  {
@@ -540,7 +596,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
540
596
  onToggleFavorite={handleToggleFavorite}
541
597
  onAddCategory={handleAddCategory}
542
598
  onExportVCard={handleExportVCard}
543
- onAddToContacts={view.type === "suggested" ? handleAddToContacts : undefined}
599
+ onAddToContacts={view.type === "suggested" || view.type === "everyone" ? handleAddToContacts : undefined}
544
600
  onImportFile={handleImportFile}
545
601
  shortcuts
546
602
  hideNew={isMobile}
@@ -11,6 +11,7 @@ import {
11
11
  TaskPriority,
12
12
  createTask,
13
13
  deleteTask,
14
+ getTask,
14
15
  listTasks,
15
16
  setTaskCompleted,
16
17
  setTaskMyDay,
@@ -34,6 +35,13 @@ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
34
35
  import { useShortcut } from "../../shared/keyboard/useShortcut.js";
35
36
  import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
36
37
  import { notifyApiError } from "../../shared/notifications/apiErrors.js";
38
+ import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
39
+
40
+ /** The published model class name of a task on either database (`TaskMongo`, `TaskSQL`) - not a `TaskList`'s. */
41
+ const TASK_MODEL = /^Task(Mongo|SQL)$/;
42
+
43
+ /** The push client channel group (`PushClient.setChannels()`) the page's tasks folder is subscribed under. */
44
+ const TASKS_CHANNEL_GROUP = "tasks";
37
45
 
38
46
  const INPUT_CLASS =
39
47
  "text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
@@ -146,6 +154,40 @@ function TasksContent() {
146
154
  void reload();
147
155
  }, [folderUid]);
148
156
 
157
+ // A task created, changed or deleted anywhere - another tab, a phone over ActiveSync - is announced on its folder's channel. It is fetched again by its
158
+ // uid and replaces (or joins) the list while it is still in this folder; one that is gone (a delete, a 404, or moved to another folder) leaves it. A
159
+ // failed fetch changes nothing: the next load shows it as it is.
160
+ useFolderLiveUpdates(
161
+ TASKS_CHANNEL_GROUP,
162
+ folderUid ? [folderUid] : [],
163
+ (event) => changedItemOf(event, TASK_MODEL),
164
+ (change) => {
165
+ const remove = () => setTasks((previous) => previous.filter((existing) => existing.uid !== change.uid));
166
+ if (change.deleted) {
167
+ remove();
168
+ return;
169
+ }
170
+ void getTask(change.uid, client).then(
171
+ (fresh) => {
172
+ if (fresh.folderUid !== folderUid) {
173
+ remove();
174
+ return;
175
+ }
176
+ setTasks((previous) =>
177
+ previous.some((existing) => existing.uid === fresh.uid)
178
+ ? previous.map((existing) => (existing.uid === fresh.uid ? fresh : existing))
179
+ : [...previous, fresh],
180
+ );
181
+ },
182
+ (err: unknown) => {
183
+ if (err instanceof ApiRequestError && err.status === 404) {
184
+ remove();
185
+ }
186
+ },
187
+ );
188
+ },
189
+ );
190
+
149
191
  useEffect(() => {
150
192
  setTargetMailboxUid(undefined);
151
193
  setAddedElsewhere(null);
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  /** Pure helper behind the calendar page's live updates: which push events say "an event changed". */
6
+ import { changedItemOf } from "../live/useFolderLiveUpdates.js";
6
7
  /** The published model name of a calendar event on either database (`CalendarEventMongo`, `CalendarEventSQL`) - and not the reminder's `"CalendarEvent"`,
7
8
  * which has its own action. */
8
9
  const EVENT_MODEL = /^CalendarEvent(Mongo|SQL)$/;
@@ -16,9 +17,5 @@ const EVENT_MODEL = /^CalendarEvent(Mongo|SQL)$/;
16
17
  * changed event is handled that way, an ordinary one (an invitation arriving, an edit on another device) included.
17
18
  */
18
19
  export function changedEventOf(event) {
19
- if (!EVENT_MODEL.test(event.type) || (event.action !== "create" && event.action !== "update" && event.action !== "delete")) {
20
- return undefined;
21
- }
22
- const data = event.data;
23
- return typeof data?.uid === "string" ? { uid: data.uid, deleted: event.action === "delete" } : undefined;
20
+ return changedItemOf(event, EVENT_MODEL);
24
21
  }
@@ -2,6 +2,8 @@ import React from "react";
2
2
  import { Contact } from "../../../../lib/contacts/contactsApi.js";
3
3
  export type ContactsView = {
4
4
  type: "all";
5
+ } | {
6
+ type: "everyone";
5
7
  } | {
6
8
  type: "favorites";
7
9
  } | {
@@ -35,7 +37,7 @@ export interface ContactsSidebarProps {
35
37
  refreshToken?: number;
36
38
  }
37
39
  /**
38
- * Contacts' Outlook-style left sidebar: "Your contacts" (all, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
40
+ * Contacts' Outlook-style left sidebar: "All contacts" (the mailbox's own and its suggested ones together, when there are suggested ones), "Your contacts" (the mailbox's own, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
39
41
  * lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
40
42
  * distinct `categories` values across the loaded contacts — no separate category registry exists
41
43
  * server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
@@ -39,7 +39,7 @@ function NavItem({ label, count, active, onClick, swatch, }) {
39
39
  ].join(" "), children: [_jsxs("span", { className: "flex items-center gap-2 min-w-0", children: [swatch && _jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: swatch } }), _jsx("span", { className: "truncate", children: label })] }), count !== undefined && _jsx("span", { className: "text-xs text-text-muted shrink-0", children: count })] }));
40
40
  }
41
41
  /**
42
- * Contacts' Outlook-style left sidebar: "Your contacts" (all, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
42
+ * Contacts' Outlook-style left sidebar: "All contacts" (the mailbox's own and its suggested ones together, when there are suggested ones), "Your contacts" (the mailbox's own, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
43
43
  * lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
44
44
  * distinct `categories` values across the loaded contacts — no separate category registry exists
45
45
  * server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
@@ -104,6 +104,6 @@ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect
104
104
  // A function, not a plain JSX constant — rendered both in the desktop `<nav>` and the mobile
105
105
  // `Drawer` (see MailShell/ContactsShell for the identical pattern and the duplicate-id reasoning
106
106
  // behind the `idPrefix`).
107
- const navContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx(NavItem, { label: "Your contacts", count: contacts.length, active: active.type === "all", onClick: () => handleSelect({ type: "all" }) }), _jsx(NavItem, { label: "Favorites", count: favoriteCount, active: active.type === "favorites", onClick: () => handleSelect({ type: "favorites" }) }), suggestedCount !== undefined && (_jsx(NavItem, { label: "Suggested contacts", count: suggestedCount, active: active.type === "suggested", onClick: () => handleSelect({ type: "suggested" }) })), showDeleted && _jsx(NavItem, { label: "Deleted", active: active.type === "deleted", onClick: () => handleSelect({ type: "deleted" }) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-2.5 mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Your contact lists" }), _jsx("button", { type: "button", onClick: () => setAddingList(true), "aria-label": "New contact list", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), listsError && _jsx("p", { className: "px-2.5 text-xs text-danger", children: listsError }), _jsx("div", { className: "flex flex-col gap-0.5", children: lists.map((list) => (_jsx(NavItem, { label: list.name, count: listCounts.get(list.uid) ?? 0, active: active.type === "list" && active.uid === list.uid, onClick: () => handleSelect({ type: "list", uid: list.uid, name: list.name }) }, list.uid))) }), addingList && (_jsxs("form", { onSubmit: handleAddList, className: "flex items-center gap-1 px-2.5 mt-1", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New list name", id: `${idPrefix}-new-list-name`, value: newListName, onChange: (e) => setNewListName(e.target.value), className: "flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark", children: "Add" })] }))] }), categories.length > 0 && (_jsxs("div", { children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted px-2.5 mb-1", children: "Categories" }), _jsx("div", { className: "flex flex-col gap-0.5", children: categories.map((name) => (_jsx(NavItem, { label: name, swatch: categoryColor(name), active: active.type === "category" && active.name === name, onClick: () => handleSelect({ type: "category", name }) }, name))) })] }))] }));
107
+ const navContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [suggestedCount !== undefined && (_jsx(NavItem, { label: "All contacts", count: contacts.length + suggestedCount, active: active.type === "everyone", onClick: () => handleSelect({ type: "everyone" }) })), _jsx(NavItem, { label: "Your contacts", count: contacts.length, active: active.type === "all", onClick: () => handleSelect({ type: "all" }) }), _jsx(NavItem, { label: "Favorites", count: favoriteCount, active: active.type === "favorites", onClick: () => handleSelect({ type: "favorites" }) }), suggestedCount !== undefined && (_jsx(NavItem, { label: "Suggested contacts", count: suggestedCount, active: active.type === "suggested", onClick: () => handleSelect({ type: "suggested" }) })), showDeleted && _jsx(NavItem, { label: "Deleted", active: active.type === "deleted", onClick: () => handleSelect({ type: "deleted" }) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-2.5 mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Your contact lists" }), _jsx("button", { type: "button", onClick: () => setAddingList(true), "aria-label": "New contact list", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), listsError && _jsx("p", { className: "px-2.5 text-xs text-danger", children: listsError }), _jsx("div", { className: "flex flex-col gap-0.5", children: lists.map((list) => (_jsx(NavItem, { label: list.name, count: listCounts.get(list.uid) ?? 0, active: active.type === "list" && active.uid === list.uid, onClick: () => handleSelect({ type: "list", uid: list.uid, name: list.name }) }, list.uid))) }), addingList && (_jsxs("form", { onSubmit: handleAddList, className: "flex items-center gap-1 px-2.5 mt-1", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New list name", id: `${idPrefix}-new-list-name`, value: newListName, onChange: (e) => setNewListName(e.target.value), className: "flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark", children: "Add" })] }))] }), categories.length > 0 && (_jsxs("div", { children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted px-2.5 mb-1", children: "Categories" }), _jsx("div", { className: "flex flex-col gap-0.5", children: categories.map((name) => (_jsx(NavItem, { label: name, swatch: categoryColor(name), active: active.type === "category" && active.name === name, onClick: () => handleSelect({ type: "category", name }) }, name))) })] }))] }));
108
108
  return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "lg:hidden m-3 mb-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open contacts menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("nav", { "aria-label": "Contacts", className: "hidden lg:flex w-56 shrink-0 border-r border-border flex-col gap-4 p-3 overflow-y-auto", children: navContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Contacts", fullScreen: true, children: _jsx("div", { className: "flex flex-col gap-4", children: navContent("mobile") }) })] }));
109
109
  }
@@ -26,8 +26,8 @@ export declare function findContact(folders: readonly Folder[], address: string,
26
26
  export declare function loadRecentMessages(mailboxes: readonly Mailbox[], address: string, client?: ApiClient): Promise<Message[]>;
27
27
  /** The next `CARD_LIST_SIZE` events within `UPCOMING_DAYS` from `now` that `address` organizes or is invited to, soonest first. */
28
28
  export declare function loadUpcomingEvents(folders: readonly Folder[], address: string, now: Date, client?: ApiClient): Promise<CalendarOccurrence[]>;
29
- /** A vCard attachment's text. */
30
- export declare function fetchVCardText(attachmentUid: string): Promise<string>;
29
+ /** A vCard attachment's text - through `client` (its origin and token) when given, else the cookie request it always was. */
30
+ export declare function fetchVCardText(attachmentUid: string, client?: ApiClient): Promise<string>;
31
31
  /**
32
32
  * The vCard of the context message that describes `participant`: the first attachment holding a card with their address, else - only for the
33
33
  * message's sender, whose own vCard it presumably is - the first card there is. `undefined` when there is none, and for any failure to read one:
@@ -63,8 +63,11 @@ export async function loadUpcomingEvents(folders, address, now, client) {
63
63
  .sort((a, b) => Date.parse(a.startDate) - Date.parse(b.startDate))
64
64
  .slice(0, CARD_LIST_SIZE);
65
65
  }
66
- /** A vCard attachment's text. */
67
- export async function fetchVCardText(attachmentUid) {
66
+ /** A vCard attachment's text - through `client` (its origin and token) when given, else the cookie request it always was. */
67
+ export async function fetchVCardText(attachmentUid, client) {
68
+ if (client) {
69
+ return (await client.fetchBlob(`/mail/attachments/${encodeURIComponent(attachmentUid)}/content`)).text();
70
+ }
68
71
  const res = await fetch(attachmentContentUrl(attachmentUid), { credentials: "include" });
69
72
  if (!res.ok) {
70
73
  throw new ApiRequestError(res.statusText || "Could not load this contact card.", res.status);
@@ -87,7 +90,7 @@ export async function loadParticipantCard(participant, context, client) {
87
90
  const isSender = !!context.message && normalizeAddress(context.message.from.address) === normalizeAddress(participant.address);
88
91
  let fallback;
89
92
  for (const attachment of await vCardAttachments(context, client)) {
90
- const cards = parseVCards(await fetchVCardText(attachment.uid));
93
+ const cards = parseVCards(await fetchVCardText(attachment.uid, client));
91
94
  const match = cardForAddress(cards, participant.address, false);
92
95
  if (match) {
93
96
  return match;
@@ -143,6 +143,21 @@ function downloadFromUrl(url, filename) {
143
143
  a.click();
144
144
  a.remove();
145
145
  }
146
+ /** How long an attachment saved through an explicit client keeps its object URL: the download has started well before this. */
147
+ const DOWNLOAD_URL_LIFETIME_MS = 60000;
148
+ /**
149
+ * Saves the file at `path` through `client` (its origin and token, which a link's `href` cannot carry): its bytes become an object URL handed to
150
+ * `downloadFromUrl()`. The URL stays in `urls` until it is revoked - after `DOWNLOAD_URL_LIFETIME_MS`, or when the pane unmounts.
151
+ */
152
+ async function downloadThroughClient(client, path, filename, urls) {
153
+ const url = URL.createObjectURL(await client.fetchBlob(path));
154
+ urls.add(url);
155
+ downloadFromUrl(url, filename);
156
+ setTimeout(() => {
157
+ urls.delete(url);
158
+ URL.revokeObjectURL(url);
159
+ }, DOWNLOAD_URL_LIFETIME_MS);
160
+ }
146
161
  /** Saves one attachment recovered from inside a signed/encrypted entity. Always handed to the browser as an
147
162
  * opaque download (`application/octet-stream`), never rendered in this origin. */
148
163
  function downloadMimeAttachment(attachment) {
@@ -290,6 +305,17 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
290
305
  const { mailboxes, trackMessageChange } = useMailShell();
291
306
  const { requestUnlock } = useUnlockPrompt();
292
307
  const client = useApiClient();
308
+ // Object URLs of attachments saved through `client`, revoked when they have been left to lapse or when this pane unmounts.
309
+ const downloadUrls = useRef(new Set());
310
+ useEffect(() => {
311
+ const urls = downloadUrls.current;
312
+ return () => {
313
+ for (const url of urls) {
314
+ URL.revokeObjectURL(url);
315
+ }
316
+ urls.clear();
317
+ };
318
+ }, []);
293
319
  // Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
294
320
  // effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
295
321
  // state; see keySession.ts's own doc comment).
@@ -526,11 +552,20 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
526
552
  const mailbox = readerMailbox ?? (await getMailbox(message.mailboxUid, {}, client).catch(() => undefined));
527
553
  return mailbox ? [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])] : [];
528
554
  }
555
+ /** Saves a listed attachment: through the explicit client when there is one (failures are reported, the file is an object URL), else as the
556
+ * cookie link always did. */
557
+ function saveAttachment(attachment) {
558
+ if (client) {
559
+ downloadThroughClient(client, `/mail/attachments/${encodeURIComponent(attachment.uid)}/content`, attachment.filename, downloadUrls.current).catch((err) => notifyApiError(err, "Couldn't download this attachment"));
560
+ return;
561
+ }
562
+ downloadFromUrl(attachmentContentUrl(attachment.uid), attachment.filename);
563
+ }
529
564
  /** Starts fetching what Reply/Reply All/Forward will need (the compose window's code, the original's body) as the
530
565
  * pointer or keyboard reaches one of their buttons, so the click itself finds it already on its way or here. */
531
566
  function prefetchReply() {
532
567
  prefetchComposeWindow();
533
- prefetchOriginalMessage(message);
568
+ prefetchOriginalMessage(message, client);
534
569
  }
535
570
  /**
536
571
  * Opens the compose window at once from what is already in memory - the message's own subject and sender, and the
@@ -554,7 +589,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
554
589
  // Never rejects: loadOriginalMessage() and ownAddresses() don't, and the rest is pure.
555
590
  const late = (async () => {
556
591
  const [original, own] = await Promise.all([
557
- loadOriginalMessage(message, security, { recipients: kind === "replyAll" }),
592
+ loadOriginalMessage(message, security, { recipients: kind === "replyAll" }, client),
558
593
  kind === "forward" ? [] : (knownOwn ?? ownAddresses()),
559
594
  ]);
560
595
  if (kind === "forward") {
@@ -842,10 +877,10 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
842
877
  * sanitized body - in a frame of its own; see `printMessage.ts`. */
843
878
  async function handlePrint() {
844
879
  try {
845
- const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version));
880
+ const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version, undefined, client));
846
881
  const older = await olderConversationMessages(message, client).catch(() => []);
847
882
  // A few at a time: a long conversation is hundreds of messages, each of them several requests.
848
- const earlier = await mapWithConcurrency(older, PRINT_CONCURRENCY, (other) => printableOlderMessage(other).catch(() => ({
883
+ const earlier = await mapWithConcurrency(older, PRINT_CONCURRENCY, (other) => printableOlderMessage(other, client).catch(() => ({
849
884
  subject: other.subject || "(no subject)",
850
885
  headers: printHeaders(other, formatMailAddress(other.from)),
851
886
  content: { kind: "text", text: "This message could not be loaded, so it is not printed." },
@@ -955,6 +990,6 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
955
990
  .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
956
991
  .map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 py-2 px-3 rounded-sm bg-surface-alt text-sm", children: [_jsxs("span", { children: [senderLabel, " requested a ", type, " receipt for this message."] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: receiptBusy === type, disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "approve"), children: "Send receipt" }), _jsx(Button, { type: "button", variant: "text", disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "decline"), children: "Decline" })] }, type))), receiptError && _jsx(Alert, { children: receiptError }), mayHoldInvite && _jsx(InviteCard, { messageUid: message.uid, headingLevel: inThread ? 3 : 2 }), security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: "These attachments come from a message whose signature couldn\u2019t be verified. Open them only if you trust the sender." })), innerAttachments
957
992
  ? innerAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: innerAttachments.map((attachment, index) => (_jsx("li", { children: isVCardAttachment({ filename: attachment.filename, mimeType: attachment.contentType }) ? (_jsx(VCardAttachmentChip, { label: attachment.filename ?? `Unnamed ${attachment.contentType} attachment`, message: message, loadText: async () => new TextDecoder().decode(attachment.decode() ?? new Uint8Array(0)), onDownload: () => downloadMimeAttachment(attachment) })) : (_jsx("button", { type: "button", onClick: () => downloadMimeAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: attachment.filename ?? `Unnamed ${attachment.contentType} attachment` })) }, `${index}:${attachment.filename ?? ""}`))) }))
958
- : listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: isVCardAttachment(attachment) ? (_jsx(VCardAttachmentChip, { label: `${attachment.filename} (${formatBytes(attachment.sizeBytes)})`, message: message, loadText: () => fetchVCardText(attachment.uid), onDownload: () => downloadFromUrl(attachmentContentUrl(attachment.uid), attachment.filename) })) : (_jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] })) }, attachment.uid))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: message.encrypted && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply all to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll"), children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
993
+ : listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: isVCardAttachment(attachment) ? (_jsx(VCardAttachmentChip, { label: `${attachment.filename} (${formatBytes(attachment.sizeBytes)})`, message: message, loadText: () => fetchVCardText(attachment.uid, client), onDownload: () => saveAttachment(attachment) })) : client ? (_jsxs("button", { type: "button", onClick: () => saveAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] })) : (_jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] })) }, attachment.uid))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: message.encrypted && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply all to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll"), children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
959
994
  return (_jsxs("div", { className: inThread ? "min-w-0" : "flex-1 min-w-0 min-h-0 flex flex-col h-full", children: [inThread ? (card) : (_jsxs(_Fragment, { children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block px-4 pt-3", children: "\u2190 Back to messages" })), _jsx(SubjectCard, { subject: shownSubject }), _jsx("div", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: card })] })), _jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: message.mailboxUid, folders: folders ?? [], currentFolderUid: message.folderUid, onMove: handleMove, onFolderCreated: onFolderCreated }), messageActions.dialog, _jsx(MessageSourceDialog, { open: sourceView !== null, onClose: closeSource, mode: sourceView?.mode ?? "source", source: sourceView?.source ?? { status: "loading" }, encrypted: !!message.encrypted }), _jsxs(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Recall this message?", children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: "This asks every original recipient's mail system to delete their copy, but only if it's still unread there \u2014 there's no way to guarantee it, and no confirmation once it either succeeds or fails. Recipients who already read the message will keep it." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", loading: recalling, disabled: recalling, onClick: handleRecall, className: "!w-auto", children: "Recall message" }), _jsx(Button, { type: "button", variant: "secondary", disabled: recalling, onClick: () => setConfirming(false), className: "!w-auto", children: "Cancel" })] })] }), _jsx(Modal, { open: trustConfirmOpen && !!trustableCertificate, onClose: () => !trusting && setTrustConfirmOpen(false), title: "Trust this signer?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("p", { children: ["Mail from ", _jsx("span", { className: "font-medium", children: senderAddress }), " signed with this certificate will show as verified."] }), _jsxs("dl", { className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate issued to" }), _jsx("dd", { children: security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address" }), _jsx("dt", { className: "text-xs text-text-muted", children: "Fingerprint" }), _jsx("dd", { className: "font-mono text-xs break-all", children: security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown" })] }), _jsx("p", { className: "text-text-muted", children: "Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with the sender through another channel, such as a phone call." }), trustError && _jsx(Alert, { children: trustError }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: trusting, disabled: trusting, onClick: () => handleTrustSigner(senderAddress, trustableCertificate), children: "Trust" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => setTrustConfirmOpen(false), children: "Cancel" })] })] }) })] }));
960
995
  }
@@ -1,3 +1,4 @@
1
+ import { type ApiClient } from "../../../../../lib/util/api.js";
1
2
  import { Message, Recipient } from "../../../../../lib/mail/mailApi.js";
2
3
  import type { QuotedBody } from "../../../../../lib/mail/compose/composeQuoting.js";
3
4
  import type { MessageSecurityResult } from "../../../../../lib/crypto/messageSecurity.js";
@@ -19,7 +20,7 @@ export declare const QUOTE_CACHE_MS = 30000;
19
20
  * keyboard reaches a Reply/Reply All/Forward button - so the request is usually already answered when it is clicked.
20
21
  * Nothing to fetch for an encrypted message (`loadOriginalMessage()` never asks the server for one's body).
21
22
  */
22
- export declare function prefetchOriginalMessage(message: Message): void;
23
+ export declare function prefetchOriginalMessage(message: Message, client?: ApiClient): void;
23
24
  /** Forgets every remembered body (the sign-out and test seam). */
24
25
  export declare function clearOriginalMessageCache(): void;
25
26
  /**
@@ -50,4 +51,4 @@ export declare function clearOriginalMessageCache(): void;
50
51
  */
51
52
  export declare function loadOriginalMessage(message: Message, security: MessageSecurityResult | null, options?: {
52
53
  recipients?: boolean;
53
- }): Promise<OriginalMessage>;
54
+ }, client?: ApiClient): Promise<OriginalMessage>;
@@ -28,9 +28,9 @@ const contentCache = new Map();
28
28
  /**
29
29
  * The server's sanitized HTML body of a message (`GET /mail/messages/:id/content`), or `undefined` when it has none (a
30
30
  * `text/plain` message is answered with its preview instead) or the request failed or timed out. Never rejects. Remembered
31
- * for `QUOTE_CACHE_MS` - except a failure, which is asked again the next time.
31
+ * for `QUOTE_CACHE_MS` - except a failure, which is asked again the next time. Through `client` when given, else the cookie request.
32
32
  */
33
- function fetchContentHtml(uid) {
33
+ function fetchContentHtml(uid, client) {
34
34
  const cached = contentCache.get(uid);
35
35
  if (cached && Date.now() - cached.at < QUOTE_CACHE_MS) {
36
36
  return cached.promise;
@@ -39,6 +39,10 @@ function fetchContentHtml(uid) {
39
39
  const timer = setTimeout(() => controller.abort(), QUOTE_FETCH_TIMEOUT_MS);
40
40
  const promise = (async () => {
41
41
  try {
42
+ if (client) {
43
+ const blob = await client.fetchBlob(`/mail/messages/${encodeURIComponent(uid)}/content`, { signal: controller.signal });
44
+ return blob.type.toLowerCase().includes("text/html") ? await blob.text() : undefined;
45
+ }
42
46
  const res = await fetch(apiUrl(`/mail/messages/${encodeURIComponent(uid)}/content`), { credentials: "include", signal: controller.signal });
43
47
  return res.ok && (res.headers.get("content-type") ?? "").includes("text/html") ? await res.text() : undefined;
44
48
  }
@@ -76,9 +80,9 @@ function withTimeout(promise, ms) {
76
80
  * keyboard reaches a Reply/Reply All/Forward button - so the request is usually already answered when it is clicked.
77
81
  * Nothing to fetch for an encrypted message (`loadOriginalMessage()` never asks the server for one's body).
78
82
  */
79
- export function prefetchOriginalMessage(message) {
83
+ export function prefetchOriginalMessage(message, client) {
80
84
  if (!message.encrypted) {
81
- void fetchContentHtml(message.uid);
85
+ void fetchContentHtml(message.uid, client);
82
86
  }
83
87
  }
84
88
  /** Forgets every remembered body (the sign-out and test seam). */
@@ -97,18 +101,26 @@ function headerRecipients(value, type) {
97
101
  }
98
102
  /** The `data:` URI of the picture a quoted image of `message` stands for - one of the message's own attachments, fetched with the session -
99
103
  * or `undefined` when there is none, it is not a raster image, it is too large or it could not be had. Never rejects. */
100
- function serverImageResolver(message) {
104
+ function serverImageResolver(message, client) {
101
105
  let attachments;
102
106
  return async (reference, signal) => {
103
107
  try {
104
- attachments ?? (attachments = listAttachments(message.folderUid, message.uid));
108
+ attachments ?? (attachments = listAttachments(message.folderUid, message.uid, client));
105
109
  const attachment = findInlineAttachment(await attachments, reference);
106
110
  const type = attachment?.mimeType.split(";")[0].trim().toLowerCase();
107
111
  if (!attachment || !type || !EMBEDDED_IMAGE_TYPES.test(type) || attachment.sizeBytes > MAX_QUOTED_IMAGE_BYTES) {
108
112
  return undefined;
109
113
  }
110
- const res = await fetch(attachmentContentUrl(attachment.uid), { credentials: "include", signal });
111
- const bytes = res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
114
+ let bytes;
115
+ if (client) {
116
+ // A failed response rejects here, which the catch below turns into no picture - as an `!res.ok` answer always was.
117
+ const blob = await client.fetchBlob(`/mail/attachments/${encodeURIComponent(attachment.uid)}/content`, { signal });
118
+ bytes = new Uint8Array(await blob.arrayBuffer());
119
+ }
120
+ else {
121
+ const res = await fetch(attachmentContentUrl(attachment.uid), { credentials: "include", signal });
122
+ bytes = res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
123
+ }
112
124
  return bytes && bytes.length <= MAX_QUOTED_IMAGE_BYTES ? toDataUri(type, bytes) : undefined;
113
125
  }
114
126
  catch {
@@ -152,7 +164,7 @@ function partImageResolver(parts) {
152
164
  *
153
165
  * The quote builders sanitize whatever HTML this returns (`sanitizeQuotedHtml()`), whichever source it came from.
154
166
  */
155
- export async function loadOriginalMessage(message, security, options = {}) {
167
+ export async function loadOriginalMessage(message, security, options = {}, client) {
156
168
  const recovered = security?.text !== undefined ? { text: security.text } : security?.html !== undefined ? { html: security.html } : undefined;
157
169
  // Headers inside an unsigned (or badly signed) encrypted message are whatever its encrypter chose to write, so only a
158
170
  // verified signature makes them a source of recipients.
@@ -168,7 +180,7 @@ export async function loadOriginalMessage(message, security, options = {}) {
168
180
  if (!recovered) {
169
181
  // Never rejects; nothing (a plain-text message, a failure, a timeout) leaves the raw message below as the other way
170
182
  // to the body.
171
- const html = await fetchContentHtml(message.uid);
183
+ const html = await fetchContentHtml(message.uid, client);
172
184
  if (html !== undefined) {
173
185
  result.body = { html };
174
186
  }
@@ -178,7 +190,7 @@ export async function loadOriginalMessage(message, security, options = {}) {
178
190
  }
179
191
  try {
180
192
  if (needsRaw) {
181
- const entity = parseMimeEntity(await withTimeout(getMessageRawContent(message.uid), QUOTE_FETCH_TIMEOUT_MS));
193
+ const entity = parseMimeEntity(await withTimeout(getMessageRawContent(message.uid, client), QUOTE_FETCH_TIMEOUT_MS));
182
194
  if (!recovered && result.body.html === undefined) {
183
195
  const body = extractDisplayBody(entity);
184
196
  result.body = body.html !== undefined ? { html: body.html } : body.text !== undefined ? { text: body.text } : {};
@@ -194,7 +206,7 @@ export async function loadOriginalMessage(message, security, options = {}) {
194
206
  // message's own recipients.
195
207
  }
196
208
  if (result.body.html !== undefined) {
197
- const resolver = recovered ? partImageResolver(security?.attachments ?? []) : serverImageResolver(message);
209
+ const resolver = recovered ? partImageResolver(security?.attachments ?? []) : serverImageResolver(message, client);
198
210
  const html = result.body.html;
199
211
  // Bounded like the body's own request: a picture that has not arrived by then is cancelled and left out of the quote, not waited for -
200
212
  // the ones that have arrived stay.
@@ -8,6 +8,7 @@ import { ApiRequestError, apiUrl } from "../../../../../lib/util/api.js";
8
8
  import Alert from "../../../../../lib/components/feedback/Alert.js";
9
9
  import Button from "../../../../../lib/components/buttons/Button.js";
10
10
  import Skeleton from "../../../../../lib/components/feedback/Skeleton.js";
11
+ import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
11
12
  import { useResolvedTheme } from "../../../appearance/resolvedTheme.js";
12
13
  import { attachmentSource, cachedBodyContent, fetchBodyContent, makeCidResolver } from "./bodyContent.js";
13
14
  import { prepareBodyHtml } from "./bodyHtml.js";
@@ -43,6 +44,8 @@ export default function MessageBody({ messageUid, messageVersion, title, content
43
44
  });
44
45
  const [attempt, setAttempt] = useState(0);
45
46
  const supplied = content !== undefined;
47
+ // The explicit ApiClient this body is rendered under, if any - undefined for the cookie-based web app.
48
+ const client = useApiClient();
46
49
  useEffect(() => {
47
50
  if (supplied) {
48
51
  return;
@@ -59,7 +62,7 @@ export default function MessageBody({ messageUid, messageVersion, title, content
59
62
  if (!revalidating) {
60
63
  setRemote({ status: "loading" });
61
64
  }
62
- fetchBodyContent(messageUid, messageVersion, controller.signal).then((loaded) => setRemote({ status: "ready", content: loaded, uid: messageUid }), (err) => {
65
+ fetchBodyContent(messageUid, messageVersion, controller.signal, client).then((loaded) => setRemote({ status: "ready", content: loaded, uid: messageUid }), (err) => {
63
66
  if (!controller.signal.aborted && !revalidating) {
64
67
  setRemote({ status: "error", message: err instanceof ApiRequestError ? err.message : "Could not load this message." });
65
68
  }
@@ -1,3 +1,4 @@
1
+ import { type ApiClient } from "../../../../../lib/util/api.js";
1
2
  import { Attachment } from "../../../../../lib/mail/mailApi.js";
2
3
  import type { MimeAttachment } from "../../../../../lib/crypto/mime.js";
3
4
  /** What a message's body is: server-sanitized HTML, or plain text (a message with no HTML part, or the server's plain-text fallback). */
@@ -17,9 +18,10 @@ export declare function cachedBodyContent(uid: string, version: number): BodyCon
17
18
  /**
18
19
  * The message's body from the server's `GET /mail/messages/:id/content`: the HTML the server sanitized when it ingested the message
19
20
  * (`sanitizedHtmlBlobKey`), or - for a message with none - the plain-text preview. A request that fails throws an `ApiRequestError`.
20
- * Successful answers are remembered per uid and version.
21
+ * Successful answers are remembered per uid and version. With an explicit `client` the request goes through it (its origin and token) instead of
22
+ * the `jwt` cookie; without one it is the cookie request it always was.
21
23
  */
22
- export declare function fetchBodyContent(uid: string, version: number, signal?: AbortSignal): Promise<BodyContent>;
24
+ export declare function fetchBodyContent(uid: string, version: number, signal?: AbortSignal, client?: ApiClient): Promise<BodyContent>;
23
25
  /** The largest inline image embedded into a body from a signed or encrypted message. */
24
26
  export declare const MAX_INLINE_IMAGE_BYTES = 5000000;
25
27
  /** A server attachment record as it may really arrive: the model carries the part's `Content-ID`, though the client type doesn't list it. */
@@ -23,28 +23,39 @@ export function cachedBodyContent(uid, version) {
23
23
  /**
24
24
  * The message's body from the server's `GET /mail/messages/:id/content`: the HTML the server sanitized when it ingested the message
25
25
  * (`sanitizedHtmlBlobKey`), or - for a message with none - the plain-text preview. A request that fails throws an `ApiRequestError`.
26
- * Successful answers are remembered per uid and version.
26
+ * Successful answers are remembered per uid and version. With an explicit `client` the request goes through it (its origin and token) instead of
27
+ * the `jwt` cookie; without one it is the cookie request it always was.
27
28
  */
28
- export async function fetchBodyContent(uid, version, signal) {
29
+ export async function fetchBodyContent(uid, version, signal, client) {
29
30
  const cached = cachedBodyContent(uid, version);
30
31
  if (cached) {
31
32
  return cached;
32
33
  }
33
- // Says what it can show, as a document request would: the server answers with the sanitized HTML, or its plain-text fallback.
34
- const res = await fetch(apiUrl(`/mail/messages/${encodeURIComponent(uid)}/content`), {
35
- credentials: "include",
36
- headers: { Accept: ACCEPT },
37
- signal,
38
- });
39
- if (!res.ok) {
40
- const contentType = res.headers.get("content-type") ?? "";
41
- const body = contentType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
42
- throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Could not load this message.", res.status, body?.code, body);
34
+ const path = `/mail/messages/${encodeURIComponent(uid)}/content`;
35
+ let text;
36
+ let contentType;
37
+ if (client) {
38
+ // `fetchBlob()` rejects with the same `ApiRequestError` a failed response gives, and its blob carries the response's own type.
39
+ const blob = await client.fetchBlob(path, { headers: { Accept: ACCEPT }, signal });
40
+ text = await blob.text();
41
+ contentType = blob.type;
43
42
  }
44
- const text = await res.text();
45
- const content = (res.headers.get("content-type") ?? "").toLowerCase().includes("text/html")
46
- ? { kind: "html", html: text }
47
- : { kind: "text", text };
43
+ else {
44
+ // Says what it can show, as a document request would: the server answers with the sanitized HTML, or its plain-text fallback.
45
+ const res = await fetch(apiUrl(path), {
46
+ credentials: "include",
47
+ headers: { Accept: ACCEPT },
48
+ signal,
49
+ });
50
+ if (!res.ok) {
51
+ const resType = res.headers.get("content-type") ?? "";
52
+ const body = resType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
53
+ throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Could not load this message.", res.status, body?.code, body);
54
+ }
55
+ text = await res.text();
56
+ contentType = res.headers.get("content-type") ?? "";
57
+ }
58
+ const content = contentType.toLowerCase().includes("text/html") ? { kind: "html", html: text } : { kind: "text", text };
48
59
  if (cache.size >= CACHE_LIMIT) {
49
60
  cache.delete(cache.keys().next().value);
50
61
  }
@@ -10,6 +10,7 @@ export declare function printHeaders(message: Message, from: string): PrintableM
10
10
  export declare function olderConversationMessages(message: Message, client?: ApiClient): Promise<Message[]>;
11
11
  /**
12
12
  * One earlier message of a conversation as it prints: the body the reading pane would show. An encrypted one is decrypted here when this device has
13
- * its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext.
13
+ * its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext. `client` is the explicit `ApiClient` every
14
+ * request goes through when given (see `useApiClient()`); without one they are the cookie requests they always were.
14
15
  */
15
- export declare function printableOlderMessage(message: Message): Promise<PrintableMessage>;
16
+ export declare function printableOlderMessage(message: Message, client?: ApiClient): Promise<PrintableMessage>;