@rapidmx/web-client 0.16.0 → 0.17.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 (184) hide show
  1. package/README.md +36 -28
  2. package/apps/admin/distribution-lists/new/index.tsx +3 -1
  3. package/apps/admin/domains/[uid].tsx +3 -1
  4. package/apps/admin/domains/new/index.tsx +3 -1
  5. package/apps/admin/escrow-scopes/[uid].tsx +3 -1
  6. package/apps/admin/escrow-scopes/new/index.tsx +3 -1
  7. package/apps/admin/index.tsx +3 -1
  8. package/apps/admin/mailboxes/[uid].tsx +3 -1
  9. package/apps/admin/mailboxes/new/index.tsx +3 -1
  10. package/apps/admin/transport-rules/new/index.tsx +3 -1
  11. package/apps/escrow/matters/new/index.tsx +3 -1
  12. package/apps/shared/components/admin/layout/AdminShell.tsx +4 -1
  13. package/apps/shared/components/admin/setup/SetupWizard.tsx +3 -1
  14. package/apps/shared/components/calendar/layout/CalendarShell.tsx +2 -2
  15. package/apps/shared/components/contacts/layout/ContactsShell.tsx +3 -2
  16. package/apps/shared/components/layout/AppShell.tsx +24 -43
  17. package/apps/shared/components/mail/ConversationThreadPane.tsx +1 -0
  18. package/apps/shared/components/mail/EmptyFolderBar.tsx +41 -0
  19. package/apps/shared/components/mail/MailSelectionBar.tsx +19 -15
  20. package/apps/shared/components/mail/MenuButton.tsx +31 -11
  21. package/apps/shared/components/mail/MessageDetailPane.tsx +202 -4
  22. package/apps/shared/components/mail/PermanentDeleteDialog.tsx +47 -0
  23. package/apps/shared/components/mail/layout/MailShell.tsx +37 -34
  24. package/apps/shared/components/mail/reading/MessageMoreMenu.tsx +258 -0
  25. package/apps/shared/components/mail/reading/MessageSourceDialog.tsx +79 -0
  26. package/apps/shared/components/mail/reading/messageExport.ts +59 -0
  27. package/apps/shared/components/mail/reading/printMessage.ts +99 -0
  28. package/apps/shared/components/mail/reading/useMessageActions.ts +443 -0
  29. package/apps/shared/components/settings/layout/SettingsShell.tsx +4 -2
  30. package/apps/shared/components/tasks/layout/TasksShell.tsx +3 -2
  31. package/apps/shared/keyboard/GlobalShortcuts.tsx +1 -1
  32. package/apps/shared/mail/folderOfType.ts +49 -0
  33. package/apps/shared/mail/permanentDelete.ts +160 -0
  34. package/apps/shared/mail/reportNotices.ts +66 -0
  35. package/apps/shared/mail/senderBlocking.ts +141 -0
  36. package/apps/shared/mail/useMailboxUpdateAccess.ts +60 -0
  37. package/apps/shared/mail/usePermanentDelete.tsx +160 -0
  38. package/apps/shared/navigation/frameContext.tsx +1 -1
  39. package/apps/shared/navigation/index.ts +43 -0
  40. package/apps/shared/navigation/pageTitle.ts +19 -0
  41. package/apps/www/_shell.tsx +82 -0
  42. package/apps/www/calendar/index.tsx +5 -2
  43. package/apps/www/contacts/[uid].tsx +6 -3
  44. package/apps/www/contacts/index.tsx +8 -3
  45. package/apps/www/index.tsx +146 -31
  46. package/apps/www/messages/[uid].tsx +8 -4
  47. package/apps/www/settings/appearance/index.tsx +5 -2
  48. package/apps/www/settings/auto-reply/index.tsx +5 -2
  49. package/apps/www/settings/blocked-senders/index.tsx +303 -0
  50. package/apps/www/settings/encryption/index.tsx +5 -2
  51. package/apps/www/settings/filters/[uid].tsx +5 -2
  52. package/apps/www/settings/filters/index.tsx +5 -2
  53. package/apps/www/settings/filters/new/index.tsx +30 -5
  54. package/apps/www/settings/labels/index.tsx +5 -2
  55. package/apps/www/settings/privacy/index.tsx +5 -2
  56. package/apps/www/settings/profile/index.tsx +5 -2
  57. package/apps/www/settings/read-receipts/index.tsx +5 -2
  58. package/apps/www/settings/sharing/index.tsx +5 -2
  59. package/apps/www/settings/signatures/[uid].tsx +5 -2
  60. package/apps/www/settings/signatures/index.tsx +5 -2
  61. package/apps/www/settings/signatures/new/index.tsx +6 -3
  62. package/apps/www/tasks/index.tsx +7 -2
  63. package/dist/apps/admin/distribution-lists/new/index.js +3 -1
  64. package/dist/apps/admin/domains/[uid].js +3 -1
  65. package/dist/apps/admin/domains/new/index.js +3 -1
  66. package/dist/apps/admin/escrow-scopes/[uid].js +3 -1
  67. package/dist/apps/admin/escrow-scopes/new/index.js +3 -1
  68. package/dist/apps/admin/index.js +3 -1
  69. package/dist/apps/admin/mailboxes/[uid].js +3 -1
  70. package/dist/apps/admin/mailboxes/new/index.js +3 -1
  71. package/dist/apps/admin/transport-rules/new/index.js +3 -1
  72. package/dist/apps/escrow/matters/new/index.js +3 -1
  73. package/dist/apps/shared/components/admin/layout/AdminShell.js +4 -1
  74. package/dist/apps/shared/components/admin/setup/SetupWizard.js +3 -1
  75. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +2 -2
  76. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +3 -2
  77. package/dist/apps/shared/components/layout/AppShell.d.ts +8 -11
  78. package/dist/apps/shared/components/layout/AppShell.js +24 -33
  79. package/dist/apps/shared/components/mail/ConversationThreadPane.js +1 -1
  80. package/dist/apps/shared/components/mail/EmptyFolderBar.d.ts +18 -0
  81. package/dist/apps/shared/components/mail/EmptyFolderBar.js +10 -0
  82. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +9 -4
  83. package/dist/apps/shared/components/mail/MailSelectionBar.js +10 -12
  84. package/dist/apps/shared/components/mail/MenuButton.d.ts +9 -2
  85. package/dist/apps/shared/components/mail/MenuButton.js +15 -11
  86. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +5 -0
  87. package/dist/apps/shared/components/mail/MessageDetailPane.js +147 -7
  88. package/dist/apps/shared/components/mail/PermanentDeleteDialog.d.ts +19 -0
  89. package/dist/apps/shared/components/mail/PermanentDeleteDialog.js +11 -0
  90. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +13 -9
  91. package/dist/apps/shared/components/mail/layout/MailShell.js +27 -33
  92. package/dist/apps/shared/components/mail/reading/MessageMoreMenu.d.ts +56 -0
  93. package/dist/apps/shared/components/mail/reading/MessageMoreMenu.js +153 -0
  94. package/dist/apps/shared/components/mail/reading/MessageSourceDialog.d.ts +27 -0
  95. package/dist/apps/shared/components/mail/reading/MessageSourceDialog.js +27 -0
  96. package/dist/apps/shared/components/mail/reading/messageExport.d.ts +19 -0
  97. package/dist/apps/shared/components/mail/reading/messageExport.js +51 -0
  98. package/dist/apps/shared/components/mail/reading/printMessage.d.ts +30 -0
  99. package/dist/apps/shared/components/mail/reading/printMessage.js +70 -0
  100. package/dist/apps/shared/components/mail/reading/useMessageActions.d.ts +57 -0
  101. package/dist/apps/shared/components/mail/reading/useMessageActions.js +366 -0
  102. package/dist/apps/shared/components/settings/layout/SettingsShell.js +4 -2
  103. package/dist/apps/shared/components/tasks/layout/TasksShell.js +3 -2
  104. package/dist/apps/shared/keyboard/GlobalShortcuts.js +1 -1
  105. package/dist/apps/shared/mail/folderOfType.d.ts +13 -0
  106. package/dist/apps/shared/mail/folderOfType.js +40 -0
  107. package/dist/apps/shared/mail/permanentDelete.d.ts +62 -0
  108. package/dist/apps/shared/mail/permanentDelete.js +127 -0
  109. package/dist/apps/shared/mail/reportNotices.d.ts +21 -0
  110. package/dist/apps/shared/mail/reportNotices.js +47 -0
  111. package/dist/apps/shared/mail/senderBlocking.d.ts +26 -0
  112. package/dist/apps/shared/mail/senderBlocking.js +92 -0
  113. package/dist/apps/shared/mail/useMailboxUpdateAccess.d.ts +11 -0
  114. package/dist/apps/shared/mail/useMailboxUpdateAccess.js +52 -0
  115. package/dist/apps/shared/mail/usePermanentDelete.d.ts +35 -0
  116. package/dist/apps/shared/mail/usePermanentDelete.js +96 -0
  117. package/dist/apps/shared/navigation/frameContext.d.ts +1 -1
  118. package/dist/apps/shared/navigation/index.d.ts +16 -0
  119. package/dist/apps/shared/navigation/index.js +34 -0
  120. package/dist/apps/shared/navigation/pageTitle.d.ts +11 -0
  121. package/dist/apps/shared/navigation/pageTitle.js +12 -0
  122. package/dist/apps/www/_shell.d.ts +26 -0
  123. package/dist/apps/www/_shell.js +31 -0
  124. package/dist/apps/www/calendar/index.d.ts +7 -2
  125. package/dist/apps/www/calendar/index.js +4 -2
  126. package/dist/apps/www/contacts/[uid].d.ts +14 -3
  127. package/dist/apps/www/contacts/[uid].js +5 -3
  128. package/dist/apps/www/contacts/index.d.ts +7 -2
  129. package/dist/apps/www/contacts/index.js +6 -4
  130. package/dist/apps/www/index.d.ts +7 -2
  131. package/dist/apps/www/index.js +117 -19
  132. package/dist/apps/www/messages/[uid].d.ts +14 -3
  133. package/dist/apps/www/messages/[uid].js +7 -5
  134. package/dist/apps/www/settings/appearance/index.d.ts +8 -2
  135. package/dist/apps/www/settings/appearance/index.js +4 -2
  136. package/dist/apps/www/settings/auto-reply/index.d.ts +7 -2
  137. package/dist/apps/www/settings/auto-reply/index.js +4 -2
  138. package/dist/apps/www/settings/blocked-senders/index.d.ts +9 -0
  139. package/dist/apps/www/settings/blocked-senders/index.js +134 -0
  140. package/dist/apps/www/settings/encryption/index.d.ts +7 -2
  141. package/dist/apps/www/settings/encryption/index.js +4 -2
  142. package/dist/apps/www/settings/filters/[uid].d.ts +7 -2
  143. package/dist/apps/www/settings/filters/[uid].js +4 -2
  144. package/dist/apps/www/settings/filters/index.d.ts +7 -2
  145. package/dist/apps/www/settings/filters/index.js +4 -2
  146. package/dist/apps/www/settings/filters/new/index.d.ts +7 -2
  147. package/dist/apps/www/settings/filters/new/index.js +28 -5
  148. package/dist/apps/www/settings/labels/index.d.ts +7 -2
  149. package/dist/apps/www/settings/labels/index.js +4 -2
  150. package/dist/apps/www/settings/privacy/index.d.ts +7 -2
  151. package/dist/apps/www/settings/privacy/index.js +4 -2
  152. package/dist/apps/www/settings/profile/index.d.ts +7 -2
  153. package/dist/apps/www/settings/profile/index.js +4 -2
  154. package/dist/apps/www/settings/read-receipts/index.d.ts +7 -2
  155. package/dist/apps/www/settings/read-receipts/index.js +4 -2
  156. package/dist/apps/www/settings/sharing/index.d.ts +7 -2
  157. package/dist/apps/www/settings/sharing/index.js +4 -2
  158. package/dist/apps/www/settings/signatures/[uid].d.ts +7 -2
  159. package/dist/apps/www/settings/signatures/[uid].js +4 -2
  160. package/dist/apps/www/settings/signatures/index.d.ts +7 -2
  161. package/dist/apps/www/settings/signatures/index.js +4 -2
  162. package/dist/apps/www/settings/signatures/new/index.d.ts +7 -2
  163. package/dist/apps/www/settings/signatures/new/index.js +5 -3
  164. package/dist/apps/www/tasks/index.d.ts +7 -2
  165. package/dist/apps/www/tasks/index.js +5 -3
  166. package/package.json +4 -2
  167. package/apps/shared/navigation/AppRouter.tsx +0 -300
  168. package/apps/shared/navigation/idle.ts +0 -45
  169. package/apps/shared/navigation/routerContext.tsx +0 -83
  170. package/apps/shared/navigation/routes.ts +0 -72
  171. package/apps/www/_routedPage.tsx +0 -24
  172. package/apps/www/_routes.ts +0 -37
  173. package/dist/apps/shared/navigation/AppRouter.d.ts +0 -52
  174. package/dist/apps/shared/navigation/AppRouter.js +0 -242
  175. package/dist/apps/shared/navigation/idle.d.ts +0 -14
  176. package/dist/apps/shared/navigation/idle.js +0 -43
  177. package/dist/apps/shared/navigation/routerContext.d.ts +0 -37
  178. package/dist/apps/shared/navigation/routerContext.js +0 -56
  179. package/dist/apps/shared/navigation/routes.d.ts +0 -32
  180. package/dist/apps/shared/navigation/routes.js +0 -37
  181. package/dist/apps/www/_routedPage.d.ts +0 -12
  182. package/dist/apps/www/_routedPage.js +0 -19
  183. package/dist/apps/www/_routes.d.ts +0 -11
  184. package/dist/apps/www/_routes.js +0 -31
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from "react";
5
+ import React, { createContext, PropsWithChildren, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
6
6
  import { HiOutlineBars3, HiOutlinePencilSquare } from "react-icons/hi2";
7
7
  import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
8
8
  import { Folder, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
@@ -10,7 +10,8 @@ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
10
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
11
11
  import AppShell, { AppShellProps } from "../../layout/AppShell.js";
12
12
  import FloatingActionButton from "../../layout/FloatingActionButton.js";
13
- import { useLocationSearch, useNavigate } from "../../../navigation/AppRouter.js";
13
+ import { useLocation } from "@rapidrest/react/client";
14
+ import { useNavigate } from "../../../navigation/index.js";
14
15
  import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
15
16
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
16
17
  import { prefetchComposeWindow, useCompose } from "../compose/ComposeContext.js";
@@ -101,6 +102,13 @@ export interface MailShellContextValue {
101
102
  * context value, which is only ever read outside a real shell.
102
103
  */
103
104
  trackMessageChange: (previous: Message, next: Message | null) => CountTracker;
105
+ /**
106
+ * Reads every mailbox's real folder counts back and shows them (after `delayMs`, 600 ms by default) - for a change too big to track message by
107
+ * message, such as emptying a folder. A no-op on the default context value, which is only ever read outside a real shell.
108
+ */
109
+ refreshFolderCounts: (delayMs?: number) => void;
110
+ /** A folder's counts as the sidebar shows them: what the shell has worked out or been told since it loaded, else what the folder was listed with. */
111
+ folderCountOf: (folder: Folder) => FolderCount;
104
112
  /**
105
113
  * The phone layout's header row beside the folders button, for the page to put its search box in (`createPortal()`): the shell
106
114
  * owns the row, the page owns the search state. `null` until the row has rendered, and on the default context value.
@@ -117,6 +125,8 @@ const MailShellContext = createContext<MailShellContextValue>({
117
125
  noteFolderUids: () => undefined,
118
126
  live: NO_LIVE_UPDATES,
119
127
  trackMessageChange: () => NO_TRACKER,
128
+ refreshFolderCounts: () => undefined,
129
+ folderCountOf: (folder) => ({ unread: folder.unreadCount, total: folder.totalCount }),
120
130
  mobileSearchSlot: null,
121
131
  });
122
132
 
@@ -214,12 +224,10 @@ function MailShortcuts({ mailboxUid }: { mailboxUid?: string }) {
214
224
  /**
215
225
  * Mail's own contextual sidebar (every accessible mailbox's own folder tree, plus a merged "All Mailboxes"
216
226
  * aggregate section) + content area, rendered inside the shared `AppShell` chrome (icon rail, header,
217
- * impersonation banner — see that component). There is no client-side router in this framework (see
218
- * `ReactRoute`'s file-convention resolver) — the selected mailbox/folder (or aggregate pseudo-folder) live
219
- * in the URL's `?mailboxUid=`/`?folderUid=`/`?aggregate=` query params, read once on mount (never during
220
- * the initial render itself, matching every other query-param reader in this codebase — e.g.
221
- * `apps/admin/quarantine`'s `readMailboxUid()` — so the server-rendered and just-hydrated client markup
222
- * match).
227
+ * impersonation banner — see that component). The selected mailbox/folder (or aggregate pseudo-folder) live
228
+ * in the URL's `?mailboxUid=`/`?folderUid=`/`?aggregate=` query params, read from the router's location
229
+ * (`@rapidrest/react`), which is the request's on the server and while hydrating and follows a shallow
230
+ * navigation (a folder link) without remounting the page.
223
231
  *
224
232
  * Every accessible mailbox's folder tree renders at once - there is no "switch mailbox" affordance
225
233
  * anymore (the mailbox `<select>` this shell used to have is gone) - so a single-mailbox user sees zero
@@ -228,8 +236,7 @@ function MailShortcuts({ mailboxUid }: { mailboxUid?: string }) {
228
236
  * of folders" requirement.
229
237
  *
230
238
  * An address that names nothing opens "All Mailboxes > Inbox" when there is more than one mailbox (that is when the section exists), and
231
- * the one mailbox's own Inbox otherwise; a `?mailboxUid=`, `?folderUid=` or `?aggregate=` always wins. Nothing is selected until the address
232
- * has been read. What needs a single mailbox while an all-mailboxes view is open (Compose, the new-message shortcut, key unlocking) uses the
239
+ * the one mailbox's own Inbox otherwise; a `?mailboxUid=`, `?folderUid=` or `?aggregate=` always wins. What needs a single mailbox while an all-mailboxes view is open (Compose, the new-message shortcut, key unlocking) uses the
233
240
  * primary mailbox.
234
241
  */
235
242
  export default function MailShell({
@@ -251,23 +258,16 @@ export default function MailShell({
251
258
  const { status, error, mailboxes, mailboxFolders, foldersLoading, onFolderCreated, noteFolderUids, live, folderCounts, outbox: outboxStatuses } = hosted ?? own;
252
259
  // The messages this tab is still handing to the server: the Outbox pill counts them at once.
253
260
  const pendingSends = usePendingSends();
254
- const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
255
- const [requestedFolderUid, setRequestedFolderUid] = useState<string | null>(null);
256
- const [requestedAggregateType, setRequestedAggregateType] = useState<string | null>(null);
257
261
  const [drawerOpen, setDrawerOpen] = useState(false);
258
- // Whether the URL has been read yet (see below): until then the selection is not known, and the sidebar sections have nothing to open for.
259
- const [selectionRead, setSelectionRead] = useState(false);
260
262
 
261
- // The selection lives in the URL, and the router (`AppRouter`) changes the URL without a page load when a folder link is
262
- // clicked - so it is read from the router's location, which updates, rather than once from `window.location`. Still read
263
- // in an effect, never during the first render, so the server render and the hydrating render agree.
264
- const search = useLocationSearch();
265
- useEffect(() => {
263
+ // The selection lives in the URL, and the router (`@rapidrest/react`) changes the URL without a page load when a folder link is
264
+ // clicked - a shallow navigation, which keeps this page and its state - so it is read from the router's location, which updates,
265
+ // rather than once from `window.location`. The location is the request's on the server and while hydrating (its `#fragment` is not
266
+ // sent, and no one here reads it), so the server render and the hydrating render agree and the selection is known from the first render.
267
+ const { search } = useLocation();
268
+ const { requestedMailboxUid, requestedFolderUid, requestedAggregateType } = useMemo(() => {
266
269
  const params = new URLSearchParams(search);
267
- setRequestedMailboxUid(params.get("mailboxUid"));
268
- setRequestedFolderUid(params.get("folderUid"));
269
- setRequestedAggregateType(params.get("aggregate"));
270
- setSelectionRead(true);
270
+ return { requestedMailboxUid: params.get("mailboxUid"), requestedFolderUid: params.get("folderUid"), requestedAggregateType: params.get("aggregate") };
271
271
  }, [search]);
272
272
  // Choosing a folder (or mailbox) changes the URL without a page load now, so the drawer that held the choice - which used to go
273
273
  // with the page - is closed here.
@@ -275,12 +275,10 @@ export default function MailShell({
275
275
  setDrawerOpen(false);
276
276
  }, [search]);
277
277
 
278
- // What the address asks for. Nothing is selected until it has been read (the first render, before the effect above): choosing a
279
- // default then would list a folder - the most expensive being every mailbox's - only to throw it away for the one the address names.
280
- const requestedAggregate: AggregateFolderType | undefined =
281
- selectionRead && isAggregateFolderType(requestedAggregateType) ? requestedAggregateType : undefined;
278
+ // What the address asks for.
279
+ const requestedAggregate: AggregateFolderType | undefined = isAggregateFolderType(requestedAggregateType) ? requestedAggregateType : undefined;
282
280
  const requestedMailbox: string | undefined =
283
- selectionRead && requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined;
281
+ requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined;
284
282
 
285
283
  // The mailbox the caller has not chosen: their own, never a shared one that merely sorts first. It is what compose, the new-message
286
284
  // shortcut and everything else that needs a single mailbox use while an all-mailboxes view (which has none) is open.
@@ -288,10 +286,9 @@ export default function MailShell({
288
286
 
289
287
  // With several mailboxes the address that names nothing opens "All Mailboxes > Inbox", the merged Inbox; with one there is no such
290
288
  // section, so it opens that mailbox's own Inbox. A mailbox, a folder or an all-mailboxes view in the address wins over either.
291
- const aggregateByDefault =
292
- selectionRead && !requestedAggregate && !requestedMailbox && requestedFolderUid === null && mailboxes.length > 1;
289
+ const aggregateByDefault = !requestedAggregate && !requestedMailbox && requestedFolderUid === null && mailboxes.length > 1;
293
290
  const aggregateFolderType: AggregateFolderType | undefined = requestedAggregate ?? (aggregateByDefault ? "inbox" : undefined);
294
- const mailboxUid: string | undefined = aggregateFolderType || !selectionRead ? undefined : (requestedMailbox ?? defaultMailboxUid);
291
+ const mailboxUid: string | undefined = aggregateFolderType ? undefined : (requestedMailbox ?? defaultMailboxUid);
295
292
 
296
293
  const selectedMailboxFolders = mailboxFolders.find((mf) => mf.mailbox.uid === mailboxUid)?.folders ?? [];
297
294
  const folderUid: string | undefined = aggregateFolderType
@@ -317,14 +314,16 @@ export default function MailShell({
317
314
 
318
315
  const counts = folderCounts.counts;
319
316
  const trackMessageChange = folderCounts.track;
317
+ const refreshFolderCounts = folderCounts.refresh;
318
+ const folderCountOf = useCallback((folder: Folder) => countOfFolder(folder, counts), [counts]);
320
319
  // `(3) Acme: Mail` in the tab strip while there is unread mail in an Inbox - kept by the frame when there is one.
321
320
  useUnreadTitle(inboxUnreadTotal(mailboxFolders, counts), { enabled: !hosted });
322
321
 
323
322
  // A state rather than a ref: the page reads it during its render, so it has to re-render once the row it names is in the document.
324
323
  const [mobileSearchSlot, setMobileSearchSlot] = useState<HTMLElement | null>(null);
325
324
  const contextValue = useMemo<MailShellContextValue>(
326
- () => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot }),
327
- [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot],
325
+ () => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, refreshFolderCounts, folderCountOf, mobileSearchSlot }),
326
+ [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, refreshFolderCounts, folderCountOf, mobileSearchSlot],
328
327
  );
329
328
 
330
329
  // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else for a
@@ -384,6 +383,8 @@ export default function MailShell({
384
383
  <a
385
384
  key={type}
386
385
  href={`/?aggregate=${encodeURIComponent(type)}`}
386
+ data-router-shallow=""
387
+ data-router-prefetch="false"
387
388
  className={[
388
389
  "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
389
390
  aggregateFolderType === type
@@ -442,6 +443,8 @@ export default function MailShell({
442
443
  <a
443
444
  key={folder.uid}
444
445
  href={`/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`}
446
+ data-router-shallow=""
447
+ data-router-prefetch="false"
445
448
  className={[
446
449
  "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
447
450
  folder.uid === folderUid
@@ -0,0 +1,258 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import {
7
+ HiOutlineArrowDownTray,
8
+ HiOutlineArrowUturnLeft,
9
+ HiOutlineArrowUturnRight,
10
+ HiOutlineCodeBracket,
11
+ HiOutlineCog6Tooth,
12
+ HiOutlineDocumentText,
13
+ HiOutlineEllipsisHorizontal,
14
+ HiOutlineEnvelope,
15
+ HiOutlineEnvelopeOpen,
16
+ HiOutlineEye,
17
+ HiOutlineFlag,
18
+ HiOutlineFunnel,
19
+ HiOutlineHandRaised,
20
+ HiOutlineInboxArrowDown,
21
+ HiOutlineInformationCircle,
22
+ HiOutlineNoSymbol,
23
+ HiOutlinePrinter,
24
+ HiOutlineShieldCheck,
25
+ HiOutlineShieldExclamation,
26
+ HiOutlineTrash,
27
+ } from "react-icons/hi2";
28
+ import MenuButton, { MenuItemSpec, MenuSectionSpec } from "../MenuButton.js";
29
+
30
+ /** What each row of the menu does - the reading pane's own handlers. */
31
+ export interface MessageMenuActions {
32
+ replyAll: () => void;
33
+ forward: () => void;
34
+ deleteMessage: () => void;
35
+ toggleRead: () => void;
36
+ toggleFlag: () => void;
37
+ reportJunk: () => void;
38
+ reportPhishing: () => void;
39
+ notJunk: () => void;
40
+ notJunkAndTrust: () => void;
41
+ blockSender: () => void;
42
+ neverBlockSender: () => void;
43
+ print: () => void;
44
+ viewSource: () => void;
45
+ viewDetails: () => void;
46
+ saveAsEml: () => void;
47
+ createRule: () => void;
48
+ }
49
+
50
+ export interface MessageMoreMenuProps {
51
+ actions: MessageMenuActions;
52
+ /** The sender's address the Block rows name. */
53
+ senderAddress: string;
54
+ read: boolean;
55
+ flagged: boolean;
56
+ /** The reader may change this mailbox's messages and filters (it is not a view-only share). */
57
+ writable: boolean;
58
+ /** Something is already being done to the message: its rows wait. */
59
+ busy: boolean;
60
+ inJunk: boolean;
61
+ /** The message is in Deleted Items: the Delete row reads "Delete permanently". */
62
+ inDeletedItems: boolean;
63
+ /** Mail the reader sent: it is not junk, and its sender is the reader. */
64
+ sent: boolean;
65
+ /** The sender is one of the reader's own addresses, which is not blocked. */
66
+ ownSender: boolean;
67
+ /** The reader may add a sender to this mailbox's Safe Senders: they have full access, or the server has not said which access they have (it then refuses, with its reason). */
68
+ canTrustSender: boolean;
69
+ /** Why the message cannot be printed right now (an encrypted one that is locked or still opening), or `undefined`. */
70
+ printReason: string | undefined;
71
+ /** The compose window is opening for a reply or forward: Reply all and Forward wait for it. */
72
+ composing: boolean;
73
+ /** The classes of the round "..." button - the reading pane's own icon-button look. */
74
+ triggerClassName: string;
75
+ }
76
+
77
+ const VIEW_ONLY = "View-only mailbox";
78
+
79
+ /** A row, disabled with `reason` (shown under the label, where a keyboard or phone user sees it as well) when there is one. */
80
+ function row(key: string, label: string, icon: React.ReactNode, onSelect: () => void, reason?: string, extra: Partial<MenuItemSpec> = {}): MenuItemSpec {
81
+ return { key, label, icon, onSelect, disabled: reason !== undefined, description: reason, ...extra } as MenuItemSpec;
82
+ }
83
+
84
+ /** The sections of the menu - Outlook's "More actions", cut to what this product really does. */
85
+ export function buildMessageMenu(props: MessageMoreMenuProps): MenuSectionSpec[] {
86
+ const { actions, read, flagged, writable, busy, inJunk, inDeletedItems, sent, ownSender, printReason, composing } = props;
87
+ // The reason a row that changes the message is unavailable, if it is: the first that applies wins.
88
+ const changeReason = !writable ? VIEW_ONLY : undefined;
89
+ const junkReason = changeReason ?? (sent ? "Not available for mail you sent" : inJunk ? "Already in Junk Email" : undefined);
90
+ const blockReason = changeReason ?? (sent ? "Not available for mail you sent" : ownSender ? "This is your own address" : undefined);
91
+ const waiting = (reason: string | undefined) => reason ?? (busy ? "Working on this message" : undefined);
92
+ const trustLabel = `Not junk, and always trust ${props.senderAddress}`;
93
+ const blockLabel = `Block ${props.senderAddress}`;
94
+ const neverBlockLabel = `Never block ${props.senderAddress}`;
95
+ return [
96
+ {
97
+ key: "message",
98
+ items: [
99
+ {
100
+ key: "reply-actions",
101
+ label: "Other reply actions",
102
+ icon: <HiOutlineArrowUturnLeft size={16} />,
103
+ submenu: [
104
+ {
105
+ key: "reply-actions-items",
106
+ items: [
107
+ row("reply-all", "Reply all", <HiOutlineArrowUturnLeft size={16} />, actions.replyAll, composing ? "Opening the compose window" : undefined),
108
+ row("forward", "Forward", <HiOutlineArrowUturnRight size={16} />, actions.forward, composing ? "Opening the compose window" : undefined),
109
+ ],
110
+ },
111
+ ],
112
+ },
113
+ row(
114
+ "delete",
115
+ // In Deleted Items there is nowhere further to move it to: Delete is the permanent one, and asks first.
116
+ inDeletedItems ? "Delete permanently" : "Delete",
117
+ <HiOutlineTrash size={16} />,
118
+ actions.deleteMessage,
119
+ waiting(changeReason),
120
+ ),
121
+ row(
122
+ "toggle-read",
123
+ read ? "Mark as unread" : "Mark as read",
124
+ read ? <HiOutlineEnvelope size={16} /> : <HiOutlineEnvelopeOpen size={16} />,
125
+ actions.toggleRead,
126
+ waiting(changeReason),
127
+ ),
128
+ row("toggle-flag", flagged ? "Unflag" : "Flag", <HiOutlineFlag size={16} />, actions.toggleFlag, waiting(changeReason)),
129
+ ],
130
+ },
131
+ {
132
+ key: "sender",
133
+ items: [
134
+ {
135
+ key: "report",
136
+ label: "Report",
137
+ icon: <HiOutlineNoSymbol size={16} />,
138
+ submenu: [
139
+ {
140
+ key: "report-items",
141
+ items: [
142
+ row("report-junk", "Report junk", <HiOutlineNoSymbol size={16} />, actions.reportJunk, waiting(junkReason)),
143
+ row("report-phishing", "Report phishing", <HiOutlineShieldExclamation size={16} />, actions.reportPhishing, waiting(junkReason)),
144
+ // In Junk Email the way back: to the Inbox, telling the spam filter it was wrong - and, when the reader may, trusting the sender.
145
+ ...(inJunk
146
+ ? [
147
+ row("not-junk", "Not junk", <HiOutlineInboxArrowDown size={16} />, actions.notJunk, waiting(changeReason)),
148
+ ...(props.canTrustSender && !ownSender
149
+ ? [
150
+ row("not-junk-trust", trustLabel, <HiOutlineShieldCheck size={16} />, actions.notJunkAndTrust, waiting(changeReason), {
151
+ title: trustLabel,
152
+ }),
153
+ ]
154
+ : []),
155
+ ]
156
+ : []),
157
+ ],
158
+ },
159
+ ],
160
+ },
161
+ {
162
+ key: "block",
163
+ label: "Block",
164
+ icon: <HiOutlineHandRaised size={16} />,
165
+ submenu: [
166
+ {
167
+ key: "block-items",
168
+ items: [
169
+ row("block-sender", blockLabel, <HiOutlineHandRaised size={16} />, actions.blockSender, waiting(blockReason), { title: blockLabel }),
170
+ row(
171
+ "never-block-sender",
172
+ neverBlockLabel,
173
+ <HiOutlineShieldCheck size={16} />,
174
+ actions.neverBlockSender,
175
+ waiting(blockReason),
176
+ { title: neverBlockLabel },
177
+ ),
178
+ ],
179
+ },
180
+ ],
181
+ },
182
+ ],
183
+ },
184
+ {
185
+ key: "output",
186
+ items: [
187
+ row("print", "Print", <HiOutlinePrinter size={16} />, actions.print, printReason),
188
+ {
189
+ key: "view",
190
+ label: "View",
191
+ icon: <HiOutlineEye size={16} />,
192
+ submenu: [
193
+ {
194
+ key: "view-items",
195
+ items: [
196
+ row("view-source", "View message source", <HiOutlineCodeBracket size={16} />, actions.viewSource),
197
+ row("view-details", "Message details", <HiOutlineInformationCircle size={16} />, actions.viewDetails),
198
+ ],
199
+ },
200
+ ],
201
+ },
202
+ {
203
+ key: "save-as",
204
+ label: "Save as",
205
+ icon: <HiOutlineArrowDownTray size={16} />,
206
+ submenu: [
207
+ {
208
+ key: "save-as-items",
209
+ items: [
210
+ row("save-eml", "Save as .eml", <HiOutlineEnvelope size={16} />, actions.saveAsEml),
211
+ row("save-pdf", "Save as PDF", <HiOutlineDocumentText size={16} />, actions.print, printReason, {
212
+ description: printReason ?? "Opens the print dialog: choose Save as PDF",
213
+ }),
214
+ ],
215
+ },
216
+ ],
217
+ },
218
+ ],
219
+ },
220
+ {
221
+ key: "advanced",
222
+ items: [
223
+ {
224
+ key: "advanced-actions",
225
+ label: "Advanced actions",
226
+ icon: <HiOutlineCog6Tooth size={16} />,
227
+ submenu: [
228
+ {
229
+ key: "advanced-items",
230
+ items: [row("create-rule", "Create rule", <HiOutlineFunnel size={16} />, actions.createRule, changeReason)],
231
+ },
232
+ ],
233
+ },
234
+ ],
235
+ },
236
+ ];
237
+ }
238
+
239
+ /**
240
+ * The "..." at the end of a message card's icon row: Outlook's More actions menu, holding the actions there is no room for beside the
241
+ * message and the ones that are less often wanted. An ARIA menu (`MenuButton`): arrows, Home and End move, Right or Enter opens a submenu, Left or
242
+ * Escape leaves it (Escape at the top closes the menu and the focus goes back to this button), and a click outside closes it. A row that cannot be used right now
243
+ * is disabled and says why under its label.
244
+ */
245
+ export default function MessageMoreMenu(props: MessageMoreMenuProps) {
246
+ return (
247
+ <MenuButton
248
+ iconOnly
249
+ label="More actions"
250
+ aria-label="More actions"
251
+ title="More actions"
252
+ icon={<HiOutlineEllipsisHorizontal size={16} aria-hidden="true" />}
253
+ className={props.triggerClassName}
254
+ sections={buildMessageMenu(props)}
255
+ width={288}
256
+ />
257
+ );
258
+ }
@@ -0,0 +1,79 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useMemo } from "react";
6
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
7
+ import CopyButton from "@rapidmx/react-shared/components/buttons/CopyButton.js";
8
+ import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
9
+ import { MAX_SOURCE_DISPLAY_CHARS, headerText, sourceText } from "./messageExport.js";
10
+
11
+ /** What the dialog is showing a message's source for: the whole of it, or only its header lines ("Message details"). */
12
+ export type SourceMode = "source" | "headers";
13
+
14
+ /** What has been fetched of the raw message: on its way, here, or refused. */
15
+ export type SourceState = { status: "loading" } | { status: "ready"; raw: string } | { status: "error"; message: string };
16
+
17
+ export interface MessageSourceDialogProps {
18
+ open: boolean;
19
+ onClose: () => void;
20
+ mode: SourceMode;
21
+ source: SourceState;
22
+ /** The message is end-to-end encrypted: what is shown is the ciphertext the server holds, and the dialog says so. */
23
+ encrypted: boolean;
24
+ }
25
+
26
+ const TITLES: Record<SourceMode, string> = { source: "Message source", headers: "Message details" };
27
+
28
+ /**
29
+ * A message's raw source (or just its headers) in a full-window dialog, in a monospace block that can be scrolled and selected, with a Copy
30
+ * button. It is the RFC 5322 text exactly as the server stores it - for an encrypted message that is the encrypted message, never a
31
+ * decrypted one, and the dialog says so. A very long source is cut for display (`MAX_SOURCE_DISPLAY_CHARS`); Copy has all of it.
32
+ */
33
+ export default function MessageSourceDialog({ open, onClose, mode, source, encrypted }: MessageSourceDialogProps) {
34
+ const text = useMemo(() => {
35
+ if (source.status !== "ready") {
36
+ return "";
37
+ }
38
+ return mode === "headers" ? headerText(source.raw) : sourceText(source.raw);
39
+ }, [source, mode]);
40
+ const cut = text.length > MAX_SOURCE_DISPLAY_CHARS;
41
+ const title = TITLES[mode];
42
+ return (
43
+ <Drawer open={open} onClose={onClose} title={title} fullScreen>
44
+ <div className="flex flex-col gap-3 text-sm">
45
+ {encrypted && (
46
+ <p role="note" className="py-2 px-3 rounded-sm bg-surface-alt text-text">
47
+ This message is encrypted. This is the encrypted message exactly as the server stores it; it is not decrypted here.
48
+ </p>
49
+ )}
50
+ {source.status === "loading" && (
51
+ <p role="status" className="text-text-muted">
52
+ Loading the message&hellip;
53
+ </p>
54
+ )}
55
+ {source.status === "error" && <Alert>{source.message}</Alert>}
56
+ {source.status === "ready" && (
57
+ <>
58
+ <div className="flex flex-wrap items-center gap-3">
59
+ <CopyButton value={text} label={`Copy the ${mode === "headers" ? "message headers" : "message source"}`} />
60
+ {cut && (
61
+ <span className="text-xs text-text-muted">
62
+ Showing the first {MAX_SOURCE_DISPLAY_CHARS.toLocaleString()} of {text.length.toLocaleString()} characters. Copy and Save as
63
+ .eml keep all of it.
64
+ </span>
65
+ )}
66
+ </div>
67
+ <pre
68
+ tabIndex={0}
69
+ aria-label={title}
70
+ className="m-0 max-h-[70vh] overflow-auto rounded-sm border border-border bg-surface-alt p-3 font-mono text-xs whitespace-pre-wrap break-all"
71
+ >
72
+ {cut ? text.slice(0, MAX_SOURCE_DISPLAY_CHARS) : text}
73
+ </pre>
74
+ </>
75
+ )}
76
+ </div>
77
+ </Drawer>
78
+ );
79
+ }
@@ -0,0 +1,59 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { binaryStringToBytes, extractAddresses, parseMimeEntity } from "@rapidmx/react-shared/crypto/mime.js";
6
+
7
+ /**
8
+ * What the reading pane does with a message's raw RFC 5322 source (`getMessageRawContent()`, a binary string): show it, save it as a
9
+ * file, and read the From header out of it. Nothing here interprets the message's content beyond its header block.
10
+ */
11
+
12
+ /** How much of a message's source the "View message source" dialog draws: a message with attachments is mostly base64, and a
13
+ * megabyte of it in a `<pre>` helps nobody. Saving as .eml and Copy always have all of it. */
14
+ export const MAX_SOURCE_DISPLAY_CHARS = 200_000;
15
+
16
+ /** The file name "Save as .eml" gives a message: its subject without what a file system refuses, or `message`. */
17
+ export function emlFileName(subject: string | undefined): string {
18
+ const cleaned = (subject ?? "")
19
+ .replace(/[\p{Cc}\\/:*?"<>|]+/gu, " ")
20
+ .replace(/\s+/g, " ")
21
+ .trim()
22
+ .slice(0, 100)
23
+ .replace(/^\.+/, "")
24
+ .replace(/[. ]+$/, "");
25
+ return `${cleaned || "message"}.eml`;
26
+ }
27
+
28
+ /** Hands `bytes` to the browser as a download called `fileName`. */
29
+ export function downloadBytes(bytes: Uint8Array, fileName: string, type: string): void {
30
+ const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type }));
31
+ const a = document.createElement("a");
32
+ a.href = url;
33
+ a.download = fileName;
34
+ document.body.appendChild(a);
35
+ a.click();
36
+ a.remove();
37
+ // Some browsers are still reading the blob after `click()` returns.
38
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
39
+ }
40
+
41
+ /** Saves a raw message (a binary string, exactly as the server has it) as a `.eml` file, byte for byte. */
42
+ export function saveAsEml(raw: string, subject: string | undefined): void {
43
+ downloadBytes(binaryStringToBytes(raw), emlFileName(subject), "message/rfc822");
44
+ }
45
+
46
+ /** A raw message as text to read: its bytes decoded as UTF-8, an invalid byte shown as U+FFFD. */
47
+ export function sourceText(raw: string): string {
48
+ return new TextDecoder("utf-8").decode(binaryStringToBytes(raw));
49
+ }
50
+
51
+ /** The header block of a raw message, as text to read. */
52
+ export function headerText(raw: string): string {
53
+ return sourceText(parseMimeEntity(raw).rawHeaderBlock);
54
+ }
55
+
56
+ /** The address in a raw message's own From header (which is not always the envelope sender the server files the message under), lowercased. */
57
+ export function headerFromAddress(raw: string): string | undefined {
58
+ return extractAddresses(parseMimeEntity(raw).headers.from)[0];
59
+ }