@rapidmx/web-client 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
  2. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
  3. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  4. package/apps/shared/components/mail/MessageDetailPane.tsx +56 -7
  5. package/apps/shared/components/mail/compose/quotedBody.ts +27 -12
  6. package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
  7. package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
  8. package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
  9. package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
  10. package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
  11. package/apps/www/calendar/index.tsx +33 -31
  12. package/apps/www/contacts/index.tsx +58 -2
  13. package/apps/www/tasks/index.tsx +42 -0
  14. package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
  15. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
  16. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  17. package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
  18. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  19. package/dist/apps/shared/components/mail/MessageDetailPane.js +40 -5
  20. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -2
  21. package/dist/apps/shared/components/mail/compose/quotedBody.js +24 -12
  22. package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
  23. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
  24. package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
  25. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
  26. package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
  27. package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
  28. package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
  29. package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
  30. package/dist/apps/www/calendar/index.js +7 -7
  31. package/dist/apps/www/contacts/index.js +49 -3
  32. package/dist/apps/www/tasks/index.js +29 -1
  33. package/dist/lib/mail/pushClient.d.ts +10 -3
  34. package/dist/lib/mail/pushClient.js +18 -4
  35. package/dist/lib/tasks/tasksApi.d.ts +1 -0
  36. package/dist/lib/tasks/tasksApi.js +3 -0
  37. package/lib/mail/pushClient.ts +18 -4
  38. package/lib/tasks/tasksApi.ts +4 -0
  39. package/package.json +1 -1
@@ -5,6 +5,7 @@
5
5
  /** Pure helper behind the calendar page's live updates: which push events say "an event changed". */
6
6
 
7
7
  import type { PushEvent } from "../../../lib/mail/pushClient.js";
8
+ import { changedItemOf } from "../live/useFolderLiveUpdates.js";
8
9
 
9
10
  /** The published model name of a calendar event on either database (`CalendarEventMongo`, `CalendarEventSQL`) - and not the reminder's `"CalendarEvent"`,
10
11
  * which has its own action. */
@@ -20,9 +21,5 @@ const EVENT_MODEL = /^CalendarEvent(Mongo|SQL)$/;
20
21
  * changed event is handled that way, an ordinary one (an invitation arriving, an edit on another device) included.
21
22
  */
22
23
  export function changedEventOf(event: PushEvent): { uid: string; deleted: boolean } | undefined {
23
- if (!EVENT_MODEL.test(event.type) || (event.action !== "create" && event.action !== "update" && event.action !== "delete")) {
24
- return undefined;
25
- }
26
- const data = event.data as { uid?: unknown } | null | undefined;
27
- return typeof data?.uid === "string" ? { uid: data.uid, deleted: event.action === "delete" } : undefined;
24
+ return changedItemOf(event, EVENT_MODEL);
28
25
  }
@@ -12,6 +12,7 @@ import { notifyApiError } from "../../notifications/apiErrors.js";
12
12
 
13
13
  export type ContactsView =
14
14
  | { type: "all" }
15
+ | { type: "everyone" }
15
16
  | { type: "favorites" }
16
17
  | { type: "suggested" }
17
18
  | { type: "list"; uid: string; name: string }
@@ -94,7 +95,7 @@ function NavItem({
94
95
  }
95
96
 
96
97
  /**
97
- * Contacts' Outlook-style left sidebar: "Your contacts" (all, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
98
+ * 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
98
99
  * lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
99
100
  * distinct `categories` values across the loaded contacts — no separate category registry exists
100
101
  * server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
@@ -166,6 +167,9 @@ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect
166
167
  const navContent = (idPrefix: string) => (
167
168
  <>
168
169
  <div className="flex flex-col gap-0.5">
170
+ {suggestedCount !== undefined && (
171
+ <NavItem label="All contacts" count={contacts.length + suggestedCount} active={active.type === "everyone"} onClick={() => handleSelect({ type: "everyone" })} />
172
+ )}
169
173
  <NavItem label="Your contacts" count={contacts.length} active={active.type === "all"} onClick={() => handleSelect({ type: "all" })} />
170
174
  <NavItem label="Favorites" count={favoriteCount} active={active.type === "favorites"} onClick={() => handleSelect({ type: "favorites" })} />
171
175
  {suggestedCount !== undefined && (
@@ -104,8 +104,11 @@ export async function loadUpcomingEvents(folders: readonly Folder[], address: st
104
104
  .slice(0, CARD_LIST_SIZE);
105
105
  }
106
106
 
107
- /** A vCard attachment's text. */
108
- export async function fetchVCardText(attachmentUid: string): Promise<string> {
107
+ /** A vCard attachment's text - through `client` (its origin and token) when given, else the cookie request it always was. */
108
+ export async function fetchVCardText(attachmentUid: string, client?: ApiClient): Promise<string> {
109
+ if (client) {
110
+ return (await client.fetchBlob(`/mail/attachments/${encodeURIComponent(attachmentUid)}/content`)).text();
111
+ }
109
112
  const res = await fetch(attachmentContentUrl(attachmentUid), { credentials: "include" });
110
113
  if (!res.ok) {
111
114
  throw new ApiRequestError(res.statusText || "Could not load this contact card.", res.status);
@@ -130,7 +133,7 @@ export async function loadParticipantCard(participant: Participant, context: Con
130
133
  const isSender = !!context.message && normalizeAddress(context.message.from.address) === normalizeAddress(participant.address);
131
134
  let fallback: ParsedVCardContact | undefined;
132
135
  for (const attachment of await vCardAttachments(context, client)) {
133
- const cards = parseVCards(await fetchVCardText(attachment.uid));
136
+ const cards = parseVCards(await fetchVCardText(attachment.uid, client));
134
137
  const match = cardForAddress(cards, participant.address, false);
135
138
  if (match) {
136
139
  return match;
@@ -16,7 +16,7 @@ import {
16
16
  HiOutlineNoSymbol,
17
17
  HiOutlineSun,
18
18
  } from "react-icons/hi2";
19
- import { ApiRequestError } from "../../../../lib/util/api.js";
19
+ import { ApiRequestError, type ApiClient } from "../../../../lib/util/api.js";
20
20
  import {
21
21
  Attachment,
22
22
  Folder,
@@ -200,6 +200,23 @@ function downloadFromUrl(url: string, filename: string): void {
200
200
  a.remove();
201
201
  }
202
202
 
203
+ /** How long an attachment saved through an explicit client keeps its object URL: the download has started well before this. */
204
+ const DOWNLOAD_URL_LIFETIME_MS = 60_000;
205
+
206
+ /**
207
+ * 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
208
+ * `downloadFromUrl()`. The URL stays in `urls` until it is revoked - after `DOWNLOAD_URL_LIFETIME_MS`, or when the pane unmounts.
209
+ */
210
+ async function downloadThroughClient(client: ApiClient, path: string, filename: string, urls: Set<string>): Promise<void> {
211
+ const url = URL.createObjectURL(await client.fetchBlob(path));
212
+ urls.add(url);
213
+ downloadFromUrl(url, filename);
214
+ setTimeout(() => {
215
+ urls.delete(url);
216
+ URL.revokeObjectURL(url);
217
+ }, DOWNLOAD_URL_LIFETIME_MS);
218
+ }
219
+
203
220
  /** Saves one attachment recovered from inside a signed/encrypted entity. Always handed to the browser as an
204
221
  * opaque download (`application/octet-stream`), never rendered in this origin. */
205
222
  function downloadMimeAttachment(attachment: MimeAttachment): void {
@@ -529,6 +546,17 @@ function MessageDetailContent({
529
546
  const { mailboxes, trackMessageChange } = useMailShell();
530
547
  const { requestUnlock } = useUnlockPrompt();
531
548
  const client = useApiClient();
549
+ // Object URLs of attachments saved through `client`, revoked when they have been left to lapse or when this pane unmounts.
550
+ const downloadUrls = useRef(new Set<string>());
551
+ useEffect(() => {
552
+ const urls = downloadUrls.current;
553
+ return () => {
554
+ for (const url of urls) {
555
+ URL.revokeObjectURL(url);
556
+ }
557
+ urls.clear();
558
+ };
559
+ }, []);
532
560
  // Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
533
561
  // effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
534
562
  // state; see keySession.ts's own doc comment).
@@ -783,11 +811,23 @@ function MessageDetailContent({
783
811
  return mailbox ? [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])] : [];
784
812
  }
785
813
 
814
+ /** Saves a listed attachment: through the explicit client when there is one (failures are reported, the file is an object URL), else as the
815
+ * cookie link always did. */
816
+ function saveAttachment(attachment: Attachment) {
817
+ if (client) {
818
+ downloadThroughClient(client, `/mail/attachments/${encodeURIComponent(attachment.uid)}/content`, attachment.filename, downloadUrls.current).catch(
819
+ (err) => notifyApiError(err, "Couldn't download this attachment"),
820
+ );
821
+ return;
822
+ }
823
+ downloadFromUrl(attachmentContentUrl(attachment.uid), attachment.filename);
824
+ }
825
+
786
826
  /** Starts fetching what Reply/Reply All/Forward will need (the compose window's code, the original's body) as the
787
827
  * pointer or keyboard reaches one of their buttons, so the click itself finds it already on its way or here. */
788
828
  function prefetchReply() {
789
829
  prefetchComposeWindow();
790
- prefetchOriginalMessage(message);
830
+ prefetchOriginalMessage(message, client);
791
831
  }
792
832
 
793
833
  /**
@@ -812,7 +852,7 @@ function MessageDetailContent({
812
852
  // Never rejects: loadOriginalMessage() and ownAddresses() don't, and the rest is pure.
813
853
  const late = (async (): Promise<ComposeLateInput> => {
814
854
  const [original, own] = await Promise.all([
815
- loadOriginalMessage(message, security, { recipients: kind === "replyAll" }),
855
+ loadOriginalMessage(message, security, { recipients: kind === "replyAll" }, client),
816
856
  kind === "forward" ? [] : (knownOwn ?? ownAddresses()),
817
857
  ]);
818
858
  if (kind === "forward") {
@@ -1141,11 +1181,11 @@ function MessageDetailContent({
1141
1181
  * sanitized body - in a frame of its own; see `printMessage.ts`. */
1142
1182
  async function handlePrint() {
1143
1183
  try {
1144
- const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version));
1184
+ const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version, undefined, client));
1145
1185
  const older = await olderConversationMessages(message, client).catch(() => []);
1146
1186
  // A few at a time: a long conversation is hundreds of messages, each of them several requests.
1147
1187
  const earlier = await mapWithConcurrency(older, PRINT_CONCURRENCY, (other) =>
1148
- printableOlderMessage(other).catch(
1188
+ printableOlderMessage(other, client).catch(
1149
1189
  (): PrintableMessage => ({
1150
1190
  subject: other.subject || "(no subject)",
1151
1191
  headers: printHeaders(other, formatMailAddress(other.from)),
@@ -1643,9 +1683,18 @@ function MessageDetailContent({
1643
1683
  <VCardAttachmentChip
1644
1684
  label={`${attachment.filename} (${formatBytes(attachment.sizeBytes)})`}
1645
1685
  message={message}
1646
- loadText={() => fetchVCardText(attachment.uid)}
1647
- onDownload={() => downloadFromUrl(attachmentContentUrl(attachment.uid), attachment.filename)}
1686
+ loadText={() => fetchVCardText(attachment.uid, client)}
1687
+ onDownload={() => saveAttachment(attachment)}
1648
1688
  />
1689
+ ) : client ? (
1690
+ // Through the explicit client a link cannot carry the token, so the file is fetched and saved from a button.
1691
+ <button
1692
+ type="button"
1693
+ onClick={() => saveAttachment(attachment)}
1694
+ className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
1695
+ >
1696
+ {attachment.filename} ({formatBytes(attachment.sizeBytes)})
1697
+ </button>
1649
1698
  ) : (
1650
1699
  <a
1651
1700
  href={attachmentContentUrl(attachment.uid)}
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { apiUrl } from "../../../../../lib/util/api.js";
5
+ import { type ApiClient, apiUrl } from "../../../../../lib/util/api.js";
6
6
  import { Message, Recipient, attachmentContentUrl, getMessageRawContent, listAttachments } from "../../../../../lib/mail/mailApi.js";
7
7
  import type { QuotedBody } from "../../../../../lib/mail/compose/composeQuoting.js";
8
8
  import { MAX_QUOTED_IMAGE_BYTES, QuotedImageReference, embedQuotedImages } from "../../../../../lib/mail/compose/quotedImages.js";
@@ -44,9 +44,9 @@ const contentCache = new Map<string, { at: number; promise: Promise<string | und
44
44
  /**
45
45
  * The server's sanitized HTML body of a message (`GET /mail/messages/:id/content`), or `undefined` when it has none (a
46
46
  * `text/plain` message is answered with its preview instead) or the request failed or timed out. Never rejects. Remembered
47
- * for `QUOTE_CACHE_MS` - except a failure, which is asked again the next time.
47
+ * for `QUOTE_CACHE_MS` - except a failure, which is asked again the next time. Through `client` when given, else the cookie request.
48
48
  */
49
- function fetchContentHtml(uid: string): Promise<string | undefined> {
49
+ function fetchContentHtml(uid: string, client?: ApiClient): Promise<string | undefined> {
50
50
  const cached = contentCache.get(uid);
51
51
  if (cached && Date.now() - cached.at < QUOTE_CACHE_MS) {
52
52
  return cached.promise;
@@ -55,6 +55,10 @@ function fetchContentHtml(uid: string): Promise<string | undefined> {
55
55
  const timer = setTimeout(() => controller.abort(), QUOTE_FETCH_TIMEOUT_MS);
56
56
  const promise = (async (): Promise<string | undefined> => {
57
57
  try {
58
+ if (client) {
59
+ const blob = await client.fetchBlob(`/mail/messages/${encodeURIComponent(uid)}/content`, { signal: controller.signal });
60
+ return blob.type.toLowerCase().includes("text/html") ? await blob.text() : undefined;
61
+ }
58
62
  const res = await fetch(apiUrl(`/mail/messages/${encodeURIComponent(uid)}/content`), { credentials: "include", signal: controller.signal });
59
63
  return res.ok && (res.headers.get("content-type") ?? "").includes("text/html") ? await res.text() : undefined;
60
64
  } catch {
@@ -92,9 +96,9 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
92
96
  * keyboard reaches a Reply/Reply All/Forward button - so the request is usually already answered when it is clicked.
93
97
  * Nothing to fetch for an encrypted message (`loadOriginalMessage()` never asks the server for one's body).
94
98
  */
95
- export function prefetchOriginalMessage(message: Message): void {
99
+ export function prefetchOriginalMessage(message: Message, client?: ApiClient): void {
96
100
  if (!message.encrypted) {
97
- void fetchContentHtml(message.uid);
101
+ void fetchContentHtml(message.uid, client);
98
102
  }
99
103
  }
100
104
 
@@ -116,18 +120,28 @@ function headerRecipients(value: string | undefined, type: "to" | "cc"): Recipie
116
120
 
117
121
  /** The `data:` URI of the picture a quoted image of `message` stands for - one of the message's own attachments, fetched with the session -
118
122
  * or `undefined` when there is none, it is not a raster image, it is too large or it could not be had. Never rejects. */
119
- function serverImageResolver(message: Message): (reference: QuotedImageReference, signal?: AbortSignal) => Promise<string | undefined> {
123
+ function serverImageResolver(
124
+ message: Message,
125
+ client?: ApiClient,
126
+ ): (reference: QuotedImageReference, signal?: AbortSignal) => Promise<string | undefined> {
120
127
  let attachments: Promise<Awaited<ReturnType<typeof listAttachments>>> | undefined;
121
128
  return async (reference, signal) => {
122
129
  try {
123
- attachments ??= listAttachments(message.folderUid, message.uid);
130
+ attachments ??= listAttachments(message.folderUid, message.uid, client);
124
131
  const attachment = findInlineAttachment(await attachments, reference);
125
132
  const type = attachment?.mimeType.split(";")[0].trim().toLowerCase();
126
133
  if (!attachment || !type || !EMBEDDED_IMAGE_TYPES.test(type) || attachment.sizeBytes > MAX_QUOTED_IMAGE_BYTES) {
127
134
  return undefined;
128
135
  }
129
- const res = await fetch(attachmentContentUrl(attachment.uid), { credentials: "include", signal });
130
- const bytes = res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
136
+ let bytes: Uint8Array | undefined;
137
+ if (client) {
138
+ // A failed response rejects here, which the catch below turns into no picture - as an `!res.ok` answer always was.
139
+ const blob = await client.fetchBlob(`/mail/attachments/${encodeURIComponent(attachment.uid)}/content`, { signal });
140
+ bytes = new Uint8Array(await blob.arrayBuffer());
141
+ } else {
142
+ const res = await fetch(attachmentContentUrl(attachment.uid), { credentials: "include", signal });
143
+ bytes = res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
144
+ }
131
145
  return bytes && bytes.length <= MAX_QUOTED_IMAGE_BYTES ? toDataUri(type, bytes) : undefined;
132
146
  } catch {
133
147
  return undefined;
@@ -176,6 +190,7 @@ export async function loadOriginalMessage(
176
190
  message: Message,
177
191
  security: MessageSecurityResult | null,
178
192
  options: { recipients?: boolean } = {},
193
+ client?: ApiClient,
179
194
  ): Promise<OriginalMessage> {
180
195
  const recovered: QuotedBody | undefined =
181
196
  security?.text !== undefined ? { text: security.text } : security?.html !== undefined ? { html: security.html } : undefined;
@@ -193,7 +208,7 @@ export async function loadOriginalMessage(
193
208
  if (!recovered) {
194
209
  // Never rejects; nothing (a plain-text message, a failure, a timeout) leaves the raw message below as the other way
195
210
  // to the body.
196
- const html = await fetchContentHtml(message.uid);
211
+ const html = await fetchContentHtml(message.uid, client);
197
212
  if (html !== undefined) {
198
213
  result.body = { html };
199
214
  } else {
@@ -202,7 +217,7 @@ export async function loadOriginalMessage(
202
217
  }
203
218
  try {
204
219
  if (needsRaw) {
205
- const entity = parseMimeEntity(await withTimeout(getMessageRawContent(message.uid), QUOTE_FETCH_TIMEOUT_MS));
220
+ const entity = parseMimeEntity(await withTimeout(getMessageRawContent(message.uid, client), QUOTE_FETCH_TIMEOUT_MS));
206
221
  if (!recovered && result.body.html === undefined) {
207
222
  const body = extractDisplayBody(entity);
208
223
  result.body = body.html !== undefined ? { html: body.html } : body.text !== undefined ? { text: body.text } : {};
@@ -217,7 +232,7 @@ export async function loadOriginalMessage(
217
232
  // message's own recipients.
218
233
  }
219
234
  if (result.body.html !== undefined) {
220
- const resolver = recovered ? partImageResolver(security?.attachments ?? []) : serverImageResolver(message);
235
+ const resolver = recovered ? partImageResolver(security?.attachments ?? []) : serverImageResolver(message, client);
221
236
  const html = result.body.html;
222
237
  // 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 -
223
238
  // the ones that have arrived stay.
@@ -9,6 +9,7 @@ import type { MimeAttachment } from "../../../../../lib/crypto/mime.js";
9
9
  import Alert from "../../../../../lib/components/feedback/Alert.js";
10
10
  import Button from "../../../../../lib/components/buttons/Button.js";
11
11
  import Skeleton from "../../../../../lib/components/feedback/Skeleton.js";
12
+ import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
12
13
  import { useResolvedTheme } from "../../../appearance/resolvedTheme.js";
13
14
  import { BodyContent, attachmentSource, cachedBodyContent, fetchBodyContent, makeCidResolver } from "./bodyContent.js";
14
15
  import { prepareBodyHtml } from "./bodyHtml.js";
@@ -78,6 +79,8 @@ export default function MessageBody({ messageUid, messageVersion, title, content
78
79
  });
79
80
  const [attempt, setAttempt] = useState(0);
80
81
  const supplied = content !== undefined;
82
+ // The explicit ApiClient this body is rendered under, if any - undefined for the cookie-based web app.
83
+ const client = useApiClient();
81
84
 
82
85
  useEffect(() => {
83
86
  if (supplied) {
@@ -95,7 +98,7 @@ export default function MessageBody({ messageUid, messageVersion, title, content
95
98
  if (!revalidating) {
96
99
  setRemote({ status: "loading" });
97
100
  }
98
- fetchBodyContent(messageUid, messageVersion, controller.signal).then(
101
+ fetchBodyContent(messageUid, messageVersion, controller.signal, client).then(
99
102
  (loaded) => setRemote({ status: "ready", content: loaded, uid: messageUid }),
100
103
  (err) => {
101
104
  if (!controller.signal.aborted && !revalidating) {
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { ApiRequestError, apiUrl } from "../../../../../lib/util/api.js";
5
+ import { ApiRequestError, type ApiClient, apiUrl } from "../../../../../lib/util/api.js";
6
6
  import { Attachment, attachmentContentUrl } from "../../../../../lib/mail/mailApi.js";
7
7
  import type { MimeAttachment } from "../../../../../lib/crypto/mime.js";
8
8
  import { bytesToBase64, findInlineAttachment, toDataUri } from "../../../../../lib/mail/inlineImages.js";
@@ -31,28 +31,38 @@ export function cachedBodyContent(uid: string, version: number): BodyContent | u
31
31
  /**
32
32
  * The message's body from the server's `GET /mail/messages/:id/content`: the HTML the server sanitized when it ingested the message
33
33
  * (`sanitizedHtmlBlobKey`), or - for a message with none - the plain-text preview. A request that fails throws an `ApiRequestError`.
34
- * Successful answers are remembered per uid and version.
34
+ * Successful answers are remembered per uid and version. With an explicit `client` the request goes through it (its origin and token) instead of
35
+ * the `jwt` cookie; without one it is the cookie request it always was.
35
36
  */
36
- export async function fetchBodyContent(uid: string, version: number, signal?: AbortSignal): Promise<BodyContent> {
37
+ export async function fetchBodyContent(uid: string, version: number, signal?: AbortSignal, client?: ApiClient): Promise<BodyContent> {
37
38
  const cached = cachedBodyContent(uid, version);
38
39
  if (cached) {
39
40
  return cached;
40
41
  }
41
- // Says what it can show, as a document request would: the server answers with the sanitized HTML, or its plain-text fallback.
42
- const res = await fetch(apiUrl(`/mail/messages/${encodeURIComponent(uid)}/content`), {
43
- credentials: "include",
44
- headers: { Accept: ACCEPT },
45
- signal,
46
- });
47
- if (!res.ok) {
48
- const contentType = res.headers.get("content-type") ?? "";
49
- const body = contentType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
50
- throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Could not load this message.", res.status, body?.code, body);
42
+ const path = `/mail/messages/${encodeURIComponent(uid)}/content`;
43
+ let text: string;
44
+ let contentType: string;
45
+ if (client) {
46
+ // `fetchBlob()` rejects with the same `ApiRequestError` a failed response gives, and its blob carries the response's own type.
47
+ const blob = await client.fetchBlob(path, { headers: { Accept: ACCEPT }, signal });
48
+ text = await blob.text();
49
+ contentType = blob.type;
50
+ } else {
51
+ // Says what it can show, as a document request would: the server answers with the sanitized HTML, or its plain-text fallback.
52
+ const res = await fetch(apiUrl(path), {
53
+ credentials: "include",
54
+ headers: { Accept: ACCEPT },
55
+ signal,
56
+ });
57
+ if (!res.ok) {
58
+ const resType = res.headers.get("content-type") ?? "";
59
+ const body = resType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
60
+ throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Could not load this message.", res.status, body?.code, body);
61
+ }
62
+ text = await res.text();
63
+ contentType = res.headers.get("content-type") ?? "";
51
64
  }
52
- const text = await res.text();
53
- const content: BodyContent = (res.headers.get("content-type") ?? "").toLowerCase().includes("text/html")
54
- ? { kind: "html", html: text }
55
- : { kind: "text", text };
65
+ const content: BodyContent = contentType.toLowerCase().includes("text/html") ? { kind: "html", html: text } : { kind: "text", text };
56
66
  if (cache.size >= CACHE_LIMIT) {
57
67
  cache.delete(cache.keys().next().value as string);
58
68
  }
@@ -40,15 +40,16 @@ export async function olderConversationMessages(message: Message, client?: ApiCl
40
40
 
41
41
  /**
42
42
  * 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
43
- * its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext.
43
+ * 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
44
+ * request goes through when given (see `useApiClient()`); without one they are the cookie requests they always were.
44
45
  */
45
- export async function printableOlderMessage(message: Message): Promise<PrintableMessage> {
46
- const attachments = await listAttachments(message.folderUid, message.uid).catch(() => []);
46
+ export async function printableOlderMessage(message: Message, client?: ApiClient): Promise<PrintableMessage> {
47
+ const attachments = await listAttachments(message.folderUid, message.uid, client).catch(() => []);
47
48
  if (message.encrypted) {
48
49
  const unlocked = getUnlockedKeys(message.mailboxUid);
49
50
  if (unlocked) {
50
51
  const { evaluateMessageSecurity } = await import("../../../../../lib/crypto/messageSecurity.js");
51
- const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid), unlocked);
52
+ const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid, client), unlocked);
52
53
  const content =
53
54
  security.text !== undefined ? ({ kind: "text", text: security.text } as const) : security.html !== undefined ? ({ kind: "html", html: security.html } as const) : undefined;
54
55
  if (content) {
@@ -70,7 +71,8 @@ export async function printableOlderMessage(message: Message): Promise<Printable
70
71
  return {
71
72
  subject: message.subject || "(no subject)",
72
73
  headers: printHeaders(message, formatMailAddress(message.from)),
73
- content: await fetchBodyContent(message.uid, message.version),
74
+ // Without a client this is exactly the cookie request it always was.
75
+ content: await (client ? fetchBodyContent(message.uid, message.version, undefined, client) : fetchBodyContent(message.uid, message.version)),
74
76
  attachments,
75
77
  };
76
78
  }
@@ -0,0 +1,59 @@
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, type PushEvent } from "../../../lib/mail/pushClient.js";
7
+
8
+ /** An item a push event says was created, changed or deleted: its uid, and whether it is gone. */
9
+ export interface FolderItemChange {
10
+ uid: string;
11
+ deleted: boolean;
12
+ }
13
+
14
+ /**
15
+ * The item a push event says was created, changed or deleted, when the event's `type` - the published model class name (`ContactMongo`,
16
+ * `TaskSQL`, ...) - matches `model`, or `undefined` for anything else the shared push connection delivers. Only the uid is taken: the payload
17
+ * 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.
18
+ */
19
+ export function changedItemOf(event: PushEvent, model: RegExp): FolderItemChange | undefined {
20
+ if (!model.test(event.type) || (event.action !== "create" && event.action !== "update" && event.action !== "delete")) {
21
+ return undefined;
22
+ }
23
+ const data = event.data as { uid?: unknown } | null | undefined;
24
+ return typeof data?.uid === "string" ? { uid: data.uid, deleted: event.action === "delete" } : undefined;
25
+ }
26
+
27
+ /**
28
+ * 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
29
+ * `group` - see `PushClient.setChannels()`; Mail's connection subscribes to mail folders only, so nothing else asks for a calendar,
30
+ * contacts or tasks folder), and `onChange` is called with every item `parse` recognizes - created, changed or deleted anywhere: another
31
+ * tab, a phone over ActiveSync, the server itself. `onChange` may change between renders; the latest is called.
32
+ */
33
+ export function useFolderLiveUpdates(
34
+ group: string,
35
+ folderUids: readonly string[],
36
+ parse: (event: PushEvent) => FolderItemChange | undefined,
37
+ onChange: (change: FolderItemChange) => void,
38
+ ): void {
39
+ const key = [...new Set(folderUids)].sort().join(",");
40
+ useEffect(() => {
41
+ const client = getPushClient();
42
+ client.setChannels(key ? key.split(",") : [], group);
43
+ client.start();
44
+ return () => client.setChannels([], group);
45
+ }, [group, key]);
46
+
47
+ const latest = useRef({ parse, onChange });
48
+ latest.current = { parse, onChange };
49
+ useEffect(
50
+ () =>
51
+ getPushClient().onEvent((event) => {
52
+ const change = latest.current.parse(event);
53
+ if (change) {
54
+ latest.current.onChange(change);
55
+ }
56
+ }),
57
+ [],
58
+ );
59
+ }
@@ -64,9 +64,7 @@ async function openFailedDraft(event: SendEvent, client: ApiClient | undefined):
64
64
  });
65
65
  return;
66
66
  }
67
- // loadOriginalMessage() (quotedBody.ts) does not yet accept an explicit ApiClient itself - out of scope here (that module is not one of
68
- // this task's files); it still falls back to the default global session even under an ApiClientContext.Provider.
69
- const original = await loadOriginalMessage(moved, null);
67
+ const original = await loadOriginalMessage(moved, null, {}, client);
70
68
  const attachments = await listAttachments(moved.folderUid, moved.uid, client).catch(() => []);
71
69
  const byType = (type: "to" | "cc" | "bcc") => moved.recipients.filter((recipient) => recipient.type === type).map(formatRecipient).join(", ");
72
70
  openComposeFromOutside({
@@ -25,7 +25,7 @@ import { ApiRequestError } from "../../../lib/util/api.js";
25
25
  import { useApiClient } from "../../../lib/util/apiClientContext.js";
26
26
  import useIsMobile from "../../../lib/util/useIsMobile.js";
27
27
  import { CalendarEvent, getCalendarEvent, listCalendarEvents } from "../../../lib/calendar/calendarApi.js";
28
- import { getPushClient } from "../../../lib/mail/pushClient.js";
28
+ import { useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
29
29
  import { moveOccurrence, resizeOccurrenceEnd } from "../../../lib/calendar/calendarMutations.js";
30
30
  import { resolveDragAction } from "../../../lib/calendar/calendarDragIds.js";
31
31
  import { createFolder } from "../../../lib/mail/mailApi.js";
@@ -63,6 +63,9 @@ import { notifyApiError } from "../../shared/notifications/apiErrors.js";
63
63
  import { bookingSettingsHref } from "../../shared/calendar/bookingPlugin.js";
64
64
  import { changedEventOf } from "../../shared/calendar/calendarLiveUpdates.js";
65
65
 
66
+ /** The push client channel group (`PushClient.setChannels()`) the page's calendars are subscribed under. */
67
+ const CALENDAR_CHANNEL_GROUP = "calendar";
68
+
66
69
  /** The furthest instants a `Date` can hold: the List's "range" reaches both. */
67
70
  const MAX_DATE_MS = 8.64e15;
68
71
 
@@ -302,36 +305,35 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
302
305
  // fetch changes nothing: the next load shows the event as it is.
303
306
  const checkedFolderUidsRef = useRef(checkedFolderUids);
304
307
  checkedFolderUidsRef.current = checkedFolderUids;
305
- useEffect(
306
- () =>
307
- getPushClient().onEvent((event) => {
308
- const change = changedEventOf(event);
309
- if (!change) {
310
- return;
311
- }
312
- const { uid } = change;
313
- if (change.deleted) {
314
- setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
315
- return;
316
- }
317
- void getCalendarEvent(uid, client).then(
318
- (fresh) => {
319
- if (checkedFolderUidsRef.current.has(fresh.folderUid)) {
320
- setEvents((previous) =>
321
- previous.some((existing) => existing.uid === fresh.uid)
322
- ? previous.map((existing) => (existing.uid === fresh.uid ? fresh : existing))
323
- : [...previous, fresh],
324
- );
325
- }
326
- },
327
- (err: unknown) => {
328
- if (err instanceof ApiRequestError && err.status === 404) {
329
- setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
330
- }
331
- },
332
- );
333
- }),
334
- [client],
308
+ // Those events are published on the calendar's own channel, so the page subscribes to every calendar it lists - checked or not, so checking
309
+ // one needs no new subscription - while it is open.
310
+ useFolderLiveUpdates(
311
+ CALENDAR_CHANNEL_GROUP,
312
+ calendarFolders.map((f) => f.uid),
313
+ changedEventOf,
314
+ (change) => {
315
+ const { uid } = change;
316
+ if (change.deleted) {
317
+ setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
318
+ return;
319
+ }
320
+ void getCalendarEvent(uid, client).then(
321
+ (fresh) => {
322
+ if (checkedFolderUidsRef.current.has(fresh.folderUid)) {
323
+ setEvents((previous) =>
324
+ previous.some((existing) => existing.uid === fresh.uid)
325
+ ? previous.map((existing) => (existing.uid === fresh.uid ? fresh : existing))
326
+ : [...previous, fresh],
327
+ );
328
+ }
329
+ },
330
+ (err: unknown) => {
331
+ if (err instanceof ApiRequestError && err.status === 404) {
332
+ setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
333
+ }
334
+ },
335
+ );
336
+ },
335
337
  );
336
338
 
337
339
  // Search: `searchText` is the box, `query` what it held once typing paused (or Enter was pressed) and is what is searched for. A search covers every