@rapidmx/web-client 0.33.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 (60) hide show
  1. package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
  2. package/apps/shared/calendar/calendarReminders.ts +16 -7
  3. package/apps/shared/calendar/useCalendarReminders.ts +66 -61
  4. package/apps/shared/components/calendar/CalendarAlarmDialog.tsx +75 -0
  5. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
  6. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  7. package/apps/shared/components/layout/AppShell.tsx +4 -2
  8. package/apps/shared/components/layout/UserMenu.tsx +29 -1
  9. package/apps/shared/components/mail/MessageDetailPane.tsx +56 -7
  10. package/apps/shared/components/mail/compose/quotedBody.ts +27 -12
  11. package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
  12. package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
  13. package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
  14. package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
  15. package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
  16. package/apps/shared/notifications/preferences.ts +27 -0
  17. package/apps/shared/notifications/sounds.ts +135 -0
  18. package/apps/shared/notifications/store.ts +9 -0
  19. package/apps/www/calendar/index.tsx +64 -32
  20. package/apps/www/contacts/index.tsx +58 -2
  21. package/apps/www/tasks/index.tsx +42 -0
  22. package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
  23. package/dist/apps/shared/calendar/calendarReminders.d.ts +6 -4
  24. package/dist/apps/shared/calendar/calendarReminders.js +14 -6
  25. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +24 -14
  26. package/dist/apps/shared/calendar/useCalendarReminders.js +49 -56
  27. package/dist/apps/shared/components/calendar/CalendarAlarmDialog.d.ts +13 -0
  28. package/dist/apps/shared/components/calendar/CalendarAlarmDialog.js +28 -0
  29. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
  30. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  31. package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
  32. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  33. package/dist/apps/shared/components/layout/AppShell.js +4 -3
  34. package/dist/apps/shared/components/layout/UserMenu.js +9 -3
  35. package/dist/apps/shared/components/mail/MessageDetailPane.js +40 -5
  36. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -2
  37. package/dist/apps/shared/components/mail/compose/quotedBody.js +24 -12
  38. package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
  39. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
  40. package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
  41. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
  42. package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
  43. package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
  44. package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
  45. package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
  46. package/dist/apps/shared/notifications/preferences.d.ts +8 -0
  47. package/dist/apps/shared/notifications/preferences.js +27 -0
  48. package/dist/apps/shared/notifications/sounds.d.ts +15 -0
  49. package/dist/apps/shared/notifications/sounds.js +101 -0
  50. package/dist/apps/shared/notifications/store.js +11 -0
  51. package/dist/apps/www/calendar/index.js +37 -8
  52. package/dist/apps/www/contacts/index.js +49 -3
  53. package/dist/apps/www/tasks/index.js +29 -1
  54. package/dist/lib/mail/pushClient.d.ts +10 -3
  55. package/dist/lib/mail/pushClient.js +18 -4
  56. package/dist/lib/tasks/tasksApi.d.ts +1 -0
  57. package/dist/lib/tasks/tasksApi.js +3 -0
  58. package/lib/mail/pushClient.ts +18 -4
  59. package/lib/tasks/tasksApi.ts +4 -0
  60. package/package.json +1 -1
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  import { useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
7
7
  import { createPortal } from "react-dom";
8
8
  import useIsMobile from "../../../../lib/util/useIsMobile.js";
9
- import { HiOutlineArrowRightOnRectangle, HiOutlineArrowUturnLeft, HiOutlineBell, HiOutlineBellAlert, HiOutlineClock, HiOutlineCog6Tooth, HiOutlineCommandLine, HiOutlineShieldCheck, HiOutlineUserCircle, } from "react-icons/hi2";
9
+ import { HiOutlineArrowRightOnRectangle, HiOutlineArrowUturnLeft, HiOutlineBell, HiOutlineBellAlert, HiOutlineSpeakerWave, HiOutlineClock, HiOutlineCog6Tooth, HiOutlineCommandLine, HiOutlineShieldCheck, HiOutlineUserCircle, } from "react-icons/hi2";
10
10
  import { formatProfileName, getMyProfile, getMyUsername, profileInitials } from "../../../../lib/auth/profileApi.js";
11
11
  import { listMailboxes } from "../../../../lib/mail/mailApi.js";
12
12
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
@@ -20,7 +20,7 @@ import { MailConnectionContext } from "../../mail/useMailConnection.js";
20
20
  import { ownMailboxes } from "../../mail/primaryMailbox.js";
21
21
  import ThemeSwitch from "./ThemeSwitch.js";
22
22
  import { desktopPermission, requestDesktopPermission, setDesktopOfferDismissed, } from "../../mail/newMailNotifications.js";
23
- import { getNotificationsEnabled, setNotificationsEnabled } from "../../notifications/preferences.js";
23
+ import { getNotificationSoundsEnabled, getNotificationsEnabled, setNotificationSoundsEnabled, setNotificationsEnabled, } from "../../notifications/preferences.js";
24
24
  import { dismissAll } from "../../notifications/store.js";
25
25
  function Avatar({ profile, initials, large }) {
26
26
  const sizeClass = large ? "w-10 h-10 text-base" : "w-8 h-8 text-sm";
@@ -53,6 +53,7 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
53
53
  const menuRef = useRef(null);
54
54
  const [detectedAdmin, setDetectedAdmin] = useState(false);
55
55
  const [popups, setPopups] = useState(true);
56
+ const [sounds, setSounds] = useState(true);
56
57
  const [permission, setPermission] = useState("unsupported");
57
58
  const [profile, setProfile] = useState(undefined);
58
59
  // Whether the profile lookup has answered, with a profile or without: the fallback names are only worth asking for after that.
@@ -140,9 +141,14 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
140
141
  useEffect(() => {
141
142
  if (open && showNotificationSettings) {
142
143
  setPopups(getNotificationsEnabled());
144
+ setSounds(getNotificationSoundsEnabled());
143
145
  setPermission(desktopPermission());
144
146
  }
145
147
  }, [open, showNotificationSettings]);
148
+ function toggleSounds() {
149
+ setNotificationSoundsEnabled(!sounds);
150
+ setSounds(!sounds);
151
+ }
146
152
  function togglePopups() {
147
153
  setNotificationsEnabled(!popups);
148
154
  setPopups(!popups);
@@ -206,7 +212,7 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
206
212
  const accountUrl = accountUrlOf(authServerUrl);
207
213
  return (_jsxs("div", { className: "relative", ref: containerRef, children: [_jsxs("button", { type: "button", onClick: () => setOpen((o) => !o), "aria-haspopup": "true", "aria-expanded": open, "aria-label": "Account menu", className: "relative flex items-center gap-1.5 rounded-pill py-1 pl-1 pr-2 hover:bg-surface-alt", children: [_jsx(Avatar, { profile: profile, initials: initials }), unseenErrors > 0 && (_jsx("span", { "data-testid": "unseen-errors-dot", "aria-hidden": "true", className: "absolute right-0.5 top-0.5 h-2.5 w-2.5 rounded-full border-2 border-surface bg-danger" })), _jsx("svg", { width: "14", height: "14", viewBox: "0 0 20 20", fill: "none", className: `text-text-muted transition-transform ${open ? "rotate-180" : ""}`, "aria-hidden": "true", children: _jsx("path", { d: "M5 7.5L10 12.5L15 7.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), open &&
208
214
  anchor &&
209
- createPortal(_jsxs("div", { ref: menuRef, role: "menu", style: { position: "fixed", right: anchor.right, top: anchor.top, bottom: anchor.bottom }, className: "w-60 max-w-[calc(100vw-1rem)] max-h-[calc(100vh-5rem)] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1.5 z-[70]", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), showMailLink && (_jsxs("a", { role: "menuitem", href: APP_HREFS.mail, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Back to mail"] })), _jsx(ThemeSwitch, {}), accountUrl && (_jsxs("a", { role: "menuitem", href: accountUrl, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineUserCircle, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Account"] })), showSettingsLink && (_jsxs("a", { role: "menuitem", href: SETTINGS_HREF, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineCog6Tooth, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Settings"] })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": popups, type: "button", onClick: togglePopups, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineBell, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Notifications"] }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: popups ? "On" : "Off" })] })), showNotificationSettings && permission === "default" && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => void enableDesktopNotifications(), className: "flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineBellAlert, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Turn on desktop notifications"] })), onShowNotifications && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => {
215
+ createPortal(_jsxs("div", { ref: menuRef, role: "menu", style: { position: "fixed", right: anchor.right, top: anchor.top, bottom: anchor.bottom }, className: "w-60 max-w-[calc(100vw-1rem)] max-h-[calc(100vh-5rem)] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1.5 z-[70]", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), showMailLink && (_jsxs("a", { role: "menuitem", href: APP_HREFS.mail, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Back to mail"] })), _jsx(ThemeSwitch, {}), accountUrl && (_jsxs("a", { role: "menuitem", href: accountUrl, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineUserCircle, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Account"] })), showSettingsLink && (_jsxs("a", { role: "menuitem", href: SETTINGS_HREF, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineCog6Tooth, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Settings"] })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": popups, type: "button", onClick: togglePopups, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineBell, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Notifications"] }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: popups ? "On" : "Off" })] })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": sounds, type: "button", onClick: toggleSounds, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineSpeakerWave, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Notification sounds"] }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: sounds ? "On" : "Off" })] })), showNotificationSettings && permission === "default" && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => void enableDesktopNotifications(), className: "flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineBellAlert, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Turn on desktop notifications"] })), onShowNotifications && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => {
210
216
  setOpen(false);
211
217
  onShowNotifications();
212
218
  }, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineClock, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Recent notifications"] }), unseenErrors > 0 && (_jsxs("span", { className: "rounded-pill bg-danger px-1.5 text-xs font-bold text-white", children: [_jsx("span", { "aria-hidden": "true", children: unseenErrors }), _jsxs("span", { className: "sr-only", children: [unseenErrors, " unseen ", unseenErrors === 1 ? "error" : "errors"] })] }))] })), onShowShortcuts && !isMobile && (_jsxs("button", { role: "menuitem", type: "button", "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.global.help, env), onClick: () => {
@@ -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>;
@@ -34,15 +34,16 @@ export async function olderConversationMessages(message, client) {
34
34
  }
35
35
  /**
36
36
  * 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
37
- * its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext.
37
+ * 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
38
+ * request goes through when given (see `useApiClient()`); without one they are the cookie requests they always were.
38
39
  */
39
- export async function printableOlderMessage(message) {
40
- const attachments = await listAttachments(message.folderUid, message.uid).catch(() => []);
40
+ export async function printableOlderMessage(message, client) {
41
+ const attachments = await listAttachments(message.folderUid, message.uid, client).catch(() => []);
41
42
  if (message.encrypted) {
42
43
  const unlocked = getUnlockedKeys(message.mailboxUid);
43
44
  if (unlocked) {
44
45
  const { evaluateMessageSecurity } = await import("../../../../../lib/crypto/messageSecurity.js");
45
- const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid), unlocked);
46
+ const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid, client), unlocked);
46
47
  const content = security.text !== undefined ? { kind: "text", text: security.text } : security.html !== undefined ? { kind: "html", html: security.html } : undefined;
47
48
  if (content) {
48
49
  return {
@@ -63,7 +64,8 @@ export async function printableOlderMessage(message) {
63
64
  return {
64
65
  subject: message.subject || "(no subject)",
65
66
  headers: printHeaders(message, formatMailAddress(message.from)),
66
- content: await fetchBodyContent(message.uid, message.version),
67
+ // Without a client this is exactly the cookie request it always was.
68
+ content: await (client ? fetchBodyContent(message.uid, message.version, undefined, client) : fetchBodyContent(message.uid, message.version)),
67
69
  attachments,
68
70
  };
69
71
  }
@@ -0,0 +1,19 @@
1
+ import { type PushEvent } from "../../../lib/mail/pushClient.js";
2
+ /** An item a push event says was created, changed or deleted: its uid, and whether it is gone. */
3
+ export interface FolderItemChange {
4
+ uid: string;
5
+ deleted: boolean;
6
+ }
7
+ /**
8
+ * The item a push event says was created, changed or deleted, when the event's `type` - the published model class name (`ContactMongo`,
9
+ * `TaskSQL`, ...) - matches `model`, or `undefined` for anything else the shared push connection delivers. Only the uid is taken: the payload
10
+ * is whatever every subscriber of the folder may see (a private calendar event's busy block), so a page fetches the item again by its uid.
11
+ */
12
+ export declare function changedItemOf(event: PushEvent, model: RegExp): FolderItemChange | undefined;
13
+ /**
14
+ * Keeps a page's folders live: while the page is mounted the tab's push connection is subscribed to `folderUids` (under the page's own
15
+ * `group` - see `PushClient.setChannels()`; Mail's connection subscribes to mail folders only, so nothing else asks for a calendar,
16
+ * contacts or tasks folder), and `onChange` is called with every item `parse` recognizes - created, changed or deleted anywhere: another
17
+ * tab, a phone over ActiveSync, the server itself. `onChange` may change between renders; the latest is called.
18
+ */
19
+ export declare function useFolderLiveUpdates(group: string, folderUids: readonly string[], parse: (event: PushEvent) => FolderItemChange | undefined, onChange: (change: FolderItemChange) => void): void;
@@ -0,0 +1,41 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useRef } from "react";
6
+ import { getPushClient } from "../../../lib/mail/pushClient.js";
7
+ /**
8
+ * The item a push event says was created, changed or deleted, when the event's `type` - the published model class name (`ContactMongo`,
9
+ * `TaskSQL`, ...) - matches `model`, or `undefined` for anything else the shared push connection delivers. Only the uid is taken: the payload
10
+ * is whatever every subscriber of the folder may see (a private calendar event's busy block), so a page fetches the item again by its uid.
11
+ */
12
+ export function changedItemOf(event, model) {
13
+ if (!model.test(event.type) || (event.action !== "create" && event.action !== "update" && event.action !== "delete")) {
14
+ return undefined;
15
+ }
16
+ const data = event.data;
17
+ return typeof data?.uid === "string" ? { uid: data.uid, deleted: event.action === "delete" } : undefined;
18
+ }
19
+ /**
20
+ * Keeps a page's folders live: while the page is mounted the tab's push connection is subscribed to `folderUids` (under the page's own
21
+ * `group` - see `PushClient.setChannels()`; Mail's connection subscribes to mail folders only, so nothing else asks for a calendar,
22
+ * contacts or tasks folder), and `onChange` is called with every item `parse` recognizes - created, changed or deleted anywhere: another
23
+ * tab, a phone over ActiveSync, the server itself. `onChange` may change between renders; the latest is called.
24
+ */
25
+ export function useFolderLiveUpdates(group, folderUids, parse, onChange) {
26
+ const key = [...new Set(folderUids)].sort().join(",");
27
+ useEffect(() => {
28
+ const client = getPushClient();
29
+ client.setChannels(key ? key.split(",") : [], group);
30
+ client.start();
31
+ return () => client.setChannels([], group);
32
+ }, [group, key]);
33
+ const latest = useRef({ parse, onChange });
34
+ latest.current = { parse, onChange };
35
+ useEffect(() => getPushClient().onEvent((event) => {
36
+ const change = latest.current.parse(event);
37
+ if (change) {
38
+ latest.current.onChange(change);
39
+ }
40
+ }), []);
41
+ }
@@ -57,9 +57,7 @@ async function openFailedDraft(event, client) {
57
57
  });
58
58
  return;
59
59
  }
60
- // loadOriginalMessage() (quotedBody.ts) does not yet accept an explicit ApiClient itself - out of scope here (that module is not one of
61
- // this task's files); it still falls back to the default global session even under an ApiClientContext.Provider.
62
- const original = await loadOriginalMessage(moved, null);
60
+ const original = await loadOriginalMessage(moved, null, {}, client);
63
61
  const attachments = await listAttachments(moved.folderUid, moved.uid, client).catch(() => []);
64
62
  const byType = (type) => moved.recipients.filter((recipient) => recipient.type === type).map(formatRecipient).join(", ");
65
63
  openComposeFromOutside({
@@ -11,3 +11,11 @@ export declare const NOTIFICATIONS_ENABLED_KEY = "rapidmx-new-mail-popups";
11
11
  /** Whether pop-up notifications are on (the default). */
12
12
  export declare function getNotificationsEnabled(): boolean;
13
13
  export declare function setNotificationsEnabled(enabled: boolean): void;
14
+ /**
15
+ * `localStorage` key of the switch for the sounds that go with notifications (a chime for new mail, a bell for a calendar alarm, a buzz for an
16
+ * error). Absent means on; `"off"` means the user turned them off. Independent of the pop-ups themselves.
17
+ */
18
+ export declare const NOTIFICATION_SOUNDS_KEY = "rapidmx-notification-sounds";
19
+ /** Whether notification sounds are on (the default). */
20
+ export declare function getNotificationSoundsEnabled(): boolean;
21
+ export declare function setNotificationSoundsEnabled(enabled: boolean): void;
@@ -34,3 +34,30 @@ export function setNotificationsEnabled(enabled) {
34
34
  // Storage blocked or full: the choice lasts until the page is reloaded, no longer.
35
35
  }
36
36
  }
37
+ /**
38
+ * `localStorage` key of the switch for the sounds that go with notifications (a chime for new mail, a bell for a calendar alarm, a buzz for an
39
+ * error). Absent means on; `"off"` means the user turned them off. Independent of the pop-ups themselves.
40
+ */
41
+ export const NOTIFICATION_SOUNDS_KEY = "rapidmx-notification-sounds";
42
+ /** Whether notification sounds are on (the default). */
43
+ export function getNotificationSoundsEnabled() {
44
+ try {
45
+ return localStorage.getItem(NOTIFICATION_SOUNDS_KEY) !== "off";
46
+ }
47
+ catch {
48
+ return true;
49
+ }
50
+ }
51
+ export function setNotificationSoundsEnabled(enabled) {
52
+ try {
53
+ if (enabled) {
54
+ localStorage.removeItem(NOTIFICATION_SOUNDS_KEY);
55
+ }
56
+ else {
57
+ localStorage.setItem(NOTIFICATION_SOUNDS_KEY, "off");
58
+ }
59
+ }
60
+ catch {
61
+ // Storage blocked or full: the choice lasts until the page is reloaded, no longer.
62
+ }
63
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The sounds that go with the app's notifications: a chime for new mail, a bell for a calendar alarm and a low double buzz for an error. They are
3
+ * made with the Web Audio API as they are played - no audio files to download, cache or ship - so they work offline and cost nothing until used.
4
+ *
5
+ * Browsers only let a page make sound after the person has interacted with it; until then the audio context stays suspended and a sound is silently
6
+ * skipped (it is resumed, and plays from then on, as soon as the page has had a click or a key press). A sound is never an error: whatever
7
+ * goes wrong with the audio is swallowed. The same sound is not played twice within `MIN_GAP_MS`, so a burst of notifications is one sound, not a clatter.
8
+ */
9
+ export type NotificationSound = "mail" | "calendar" | "error";
10
+ /** The shortest time between two plays of the same sound. */
11
+ export declare const MIN_GAP_MS = 750;
12
+ /** Plays `sound`, unless the person turned notification sounds off, the same sound just played, or the browser won't make sound yet. Never throws. */
13
+ export declare function playNotificationSound(sound: NotificationSound, now?: number): void;
14
+ /** Forgets the audio context and what was played when - for tests. */
15
+ export declare function resetNotificationSounds(): void;