@rapidmx/web-client 0.16.0 → 0.17.2

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 +2 -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 +36 -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 +3 -2
  30. package/apps/shared/components/tasks/layout/TasksShell.tsx +2 -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 +70 -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 +2 -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 +26 -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 +3 -2
  103. package/dist/apps/shared/components/tasks/layout/TasksShell.js +2 -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 +27 -0
  119. package/dist/apps/shared/navigation/index.js +58 -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,7 @@ 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, useNavigate } from "../../../navigation/index.js";
14
14
  import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
15
15
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
16
16
  import { prefetchComposeWindow, useCompose } from "../compose/ComposeContext.js";
@@ -101,6 +101,13 @@ export interface MailShellContextValue {
101
101
  * context value, which is only ever read outside a real shell.
102
102
  */
103
103
  trackMessageChange: (previous: Message, next: Message | null) => CountTracker;
104
+ /**
105
+ * 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
106
+ * message, such as emptying a folder. A no-op on the default context value, which is only ever read outside a real shell.
107
+ */
108
+ refreshFolderCounts: (delayMs?: number) => void;
109
+ /** 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. */
110
+ folderCountOf: (folder: Folder) => FolderCount;
104
111
  /**
105
112
  * The phone layout's header row beside the folders button, for the page to put its search box in (`createPortal()`): the shell
106
113
  * owns the row, the page owns the search state. `null` until the row has rendered, and on the default context value.
@@ -117,6 +124,8 @@ const MailShellContext = createContext<MailShellContextValue>({
117
124
  noteFolderUids: () => undefined,
118
125
  live: NO_LIVE_UPDATES,
119
126
  trackMessageChange: () => NO_TRACKER,
127
+ refreshFolderCounts: () => undefined,
128
+ folderCountOf: (folder) => ({ unread: folder.unreadCount, total: folder.totalCount }),
120
129
  mobileSearchSlot: null,
121
130
  });
122
131
 
@@ -214,12 +223,10 @@ function MailShortcuts({ mailboxUid }: { mailboxUid?: string }) {
214
223
  /**
215
224
  * Mail's own contextual sidebar (every accessible mailbox's own folder tree, plus a merged "All Mailboxes"
216
225
  * 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).
226
+ * impersonation banner — see that component). The selected mailbox/folder (or aggregate pseudo-folder) live
227
+ * in the URL's `?mailboxUid=`/`?folderUid=`/`?aggregate=` query params, read from the router's location
228
+ * (`@rapidrest/react`), which is the request's on the server and while hydrating and follows a shallow
229
+ * navigation (a folder link) without remounting the page.
223
230
  *
224
231
  * Every accessible mailbox's folder tree renders at once - there is no "switch mailbox" affordance
225
232
  * anymore (the mailbox `<select>` this shell used to have is gone) - so a single-mailbox user sees zero
@@ -228,8 +235,7 @@ function MailShortcuts({ mailboxUid }: { mailboxUid?: string }) {
228
235
  * of folders" requirement.
229
236
  *
230
237
  * 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
238
+ * 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
239
  * primary mailbox.
234
240
  */
235
241
  export default function MailShell({
@@ -251,23 +257,16 @@ export default function MailShell({
251
257
  const { status, error, mailboxes, mailboxFolders, foldersLoading, onFolderCreated, noteFolderUids, live, folderCounts, outbox: outboxStatuses } = hosted ?? own;
252
258
  // The messages this tab is still handing to the server: the Outbox pill counts them at once.
253
259
  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
260
  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
261
 
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(() => {
262
+ // The selection lives in the URL, and the router (`@rapidrest/react`) changes the URL without a page load when a folder link is
263
+ // clicked - a shallow navigation, which keeps this page and its state - so it is read from the router's location, which updates,
264
+ // rather than once from `window.location`. The location is the request's on the server and while hydrating (its `#fragment` is not
265
+ // 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.
266
+ const { search } = useLocation();
267
+ const { requestedMailboxUid, requestedFolderUid, requestedAggregateType } = useMemo(() => {
266
268
  const params = new URLSearchParams(search);
267
- setRequestedMailboxUid(params.get("mailboxUid"));
268
- setRequestedFolderUid(params.get("folderUid"));
269
- setRequestedAggregateType(params.get("aggregate"));
270
- setSelectionRead(true);
269
+ return { requestedMailboxUid: params.get("mailboxUid"), requestedFolderUid: params.get("folderUid"), requestedAggregateType: params.get("aggregate") };
271
270
  }, [search]);
272
271
  // 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
272
  // with the page - is closed here.
@@ -275,12 +274,10 @@ export default function MailShell({
275
274
  setDrawerOpen(false);
276
275
  }, [search]);
277
276
 
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;
277
+ // What the address asks for.
278
+ const requestedAggregate: AggregateFolderType | undefined = isAggregateFolderType(requestedAggregateType) ? requestedAggregateType : undefined;
282
279
  const requestedMailbox: string | undefined =
283
- selectionRead && requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined;
280
+ requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined;
284
281
 
285
282
  // 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
283
  // shortcut and everything else that needs a single mailbox use while an all-mailboxes view (which has none) is open.
@@ -288,10 +285,9 @@ export default function MailShell({
288
285
 
289
286
  // With several mailboxes the address that names nothing opens "All Mailboxes > Inbox", the merged Inbox; with one there is no such
290
287
  // 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;
288
+ const aggregateByDefault = !requestedAggregate && !requestedMailbox && requestedFolderUid === null && mailboxes.length > 1;
293
289
  const aggregateFolderType: AggregateFolderType | undefined = requestedAggregate ?? (aggregateByDefault ? "inbox" : undefined);
294
- const mailboxUid: string | undefined = aggregateFolderType || !selectionRead ? undefined : (requestedMailbox ?? defaultMailboxUid);
290
+ const mailboxUid: string | undefined = aggregateFolderType ? undefined : (requestedMailbox ?? defaultMailboxUid);
295
291
 
296
292
  const selectedMailboxFolders = mailboxFolders.find((mf) => mf.mailbox.uid === mailboxUid)?.folders ?? [];
297
293
  const folderUid: string | undefined = aggregateFolderType
@@ -317,14 +313,16 @@ export default function MailShell({
317
313
 
318
314
  const counts = folderCounts.counts;
319
315
  const trackMessageChange = folderCounts.track;
316
+ const refreshFolderCounts = folderCounts.refresh;
317
+ const folderCountOf = useCallback((folder: Folder) => countOfFolder(folder, counts), [counts]);
320
318
  // `(3) Acme: Mail` in the tab strip while there is unread mail in an Inbox - kept by the frame when there is one.
321
319
  useUnreadTitle(inboxUnreadTotal(mailboxFolders, counts), { enabled: !hosted });
322
320
 
323
321
  // 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
322
  const [mobileSearchSlot, setMobileSearchSlot] = useState<HTMLElement | null>(null);
325
323
  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],
324
+ () => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, refreshFolderCounts, folderCountOf, mobileSearchSlot }),
325
+ [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, refreshFolderCounts, folderCountOf, mobileSearchSlot],
328
326
  );
329
327
 
330
328
  // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else for a
@@ -384,6 +382,8 @@ export default function MailShell({
384
382
  <a
385
383
  key={type}
386
384
  href={`/?aggregate=${encodeURIComponent(type)}`}
385
+ data-router-shallow=""
386
+ data-router-prefetch="false"
387
387
  className={[
388
388
  "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
389
389
  aggregateFolderType === type
@@ -442,6 +442,8 @@ export default function MailShell({
442
442
  <a
443
443
  key={folder.uid}
444
444
  href={`/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`}
445
+ data-router-shallow=""
446
+ data-router-prefetch="false"
445
447
  className={[
446
448
  "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
447
449
  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
+ }