@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.
- package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
- package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +6 -3
- package/apps/shared/components/mail/MessageDetailPane.tsx +56 -7
- package/apps/shared/components/mail/compose/quotedBody.ts +27 -12
- package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
- package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
- package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
- package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
- package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
- package/apps/www/calendar/index.tsx +33 -31
- package/apps/www/contacts/index.tsx +58 -2
- package/apps/www/tasks/index.tsx +42 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
- package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.js +40 -5
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -2
- package/dist/apps/shared/components/mail/compose/quotedBody.js +24 -12
- package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
- package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
- package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
- package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
- package/dist/apps/www/calendar/index.js +7 -7
- package/dist/apps/www/contacts/index.js +49 -3
- package/dist/apps/www/tasks/index.js +29 -1
- package/dist/lib/mail/pushClient.d.ts +10 -3
- package/dist/lib/mail/pushClient.js +18 -4
- package/dist/lib/tasks/tasksApi.d.ts +1 -0
- package/dist/lib/tasks/tasksApi.js +3 -0
- package/lib/mail/pushClient.ts +18 -4
- package/lib/tasks/tasksApi.ts +4 -0
- 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
|
-
|
|
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}
|
package/apps/www/tasks/index.tsx
CHANGED
|
@@ -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
|
-
|
|
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" (
|
|
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" (
|
|
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: () =>
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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>;
|