@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
@@ -0,0 +1,19 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import type { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
6
+
7
+ /** The name the deployment goes by in the browser tab: its branding title, else its company name, else RapidMX. */
8
+ export function brandName(branding?: Pick<Branding, "title" | "companyName"> | null): string {
9
+ return branding?.title || branding?.companyName || "RapidMX";
10
+ }
11
+
12
+ /**
13
+ * The `title` export of a webmail page: `export const title = pageTitle("Calendar")`. `@rapidrest/react` renders it into the
14
+ * document's `<title>` on the server - so the tab is right from the first byte, and a crawler sees it - and sends it with every
15
+ * client navigation, so the tab follows the page. `label` is the app's name (`Acme: Calendar`), the same for all of an app's pages.
16
+ */
17
+ export function pageTitle(label: string): (props: { branding?: Pick<Branding, "title" | "companyName"> | null }) => string {
18
+ return ({ branding }) => `${brandName(branding)}: ${label}`;
19
+ }
@@ -0,0 +1,82 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { PropsWithChildren, useCallback, useMemo, useState } from "react";
6
+ import { useNavigationEffects, useRouter } from "@rapidrest/react/client";
7
+ import { AppChrome, type AppShellActive, type AppShellProps } from "../shared/components/layout/AppShell.js";
8
+ import { AppFrameContext, type AppFrameContextValue } from "../shared/navigation/frameContext.js";
9
+ import { titleWithUnread } from "../shared/mail/useUnreadTitle.js";
10
+
11
+ /**
12
+ * The webmail's persistent app frame: `@rapidrest/react`'s app shell (`_shell.tsx`), rendered around every page of `apps/www` -
13
+ * on the server, in the same tree the browser hydrates - and kept mounted while the router swaps the pages inside it, so that
14
+ * moving between Mail, Calendar, Contacts, Tasks and Settings replaces only the page. It is what holds the state that must outlive
15
+ * a page: the compose windows in progress, the unlock prompt, the idle-key timer, the sign-out listener, the mail connection (one
16
+ * push socket for every app), the user menu and the notification pop-ups (all `AppChrome`'s).
17
+ *
18
+ * The props are the current page's - `WwwRoute.fetchProps()`'s, the same for every page: the user, the auth-server URL, the
19
+ * branding, the plugin navigation, the impersonation state and the appearance - and are fetched again on every navigation, so a
20
+ * change of any of them (a plugin installed, the branding edited) shows on the next page. A page still renders its own shell
21
+ * (`MailShell`, `CalendarShell`, ...) with `AppShell` inside it as it always did; inside this frame that renders only its children.
22
+ *
23
+ * What a page load did by itself and the router now does after each navigation - focus moves to the content, the window scrolls to
24
+ * the top, a screen reader is told the page's title - is configured here (`useNavigationEffects()`); the tab's title is each
25
+ * page's `title` export (`pageTitle()`). `busy` is the router's pending state: the content is `aria-busy` while the next page
26
+ * loads and the old one is still on screen.
27
+ */
28
+ export type WwwShellProps = PropsWithChildren<Omit<AppShellProps, "active">>;
29
+
30
+ /**
31
+ * The app-rail entry (or `"settings"`, which has none) that a route highlights: the first segment of its template names the
32
+ * app. Anything else - Mail's `/` and `/messages/:uid` - is Mail.
33
+ */
34
+ export function activeAppOf(route: string): AppShellActive {
35
+ const app = route.split("/")[1];
36
+ return app === "calendar" || app === "contacts" || app === "tasks" || app === "settings" ? app : "mail";
37
+ }
38
+
39
+ export default function WwwShell({
40
+ children,
41
+ userUid,
42
+ authServerUrl,
43
+ impersonating,
44
+ impersonationBaseUrl,
45
+ trusted,
46
+ trustedRoles,
47
+ pluginNav,
48
+ branding,
49
+ appearance,
50
+ }: WwwShellProps) {
51
+ const { route, pending } = useRouter();
52
+ const [takeovers, setTakeovers] = useState(0);
53
+ const enterTakeover = useCallback(() => {
54
+ setTakeovers((n) => n + 1);
55
+ return () => setTakeovers((n) => n - 1);
56
+ }, []);
57
+ const frame = useMemo<AppFrameContextValue>(() => ({ enterTakeover }), [enterTakeover]);
58
+ // The content region is what a page load would have started at: focus it (it is only focusable by script), start at the top, and say the
59
+ // page's title - without the unread count that the tab's title carries in front of it.
60
+ useNavigationEffects({ focus: "#app-content", scroll: "top", announce: ({ title }) => titleWithUnread(title, 0) });
61
+
62
+ return (
63
+ <AppFrameContext.Provider value={frame}>
64
+ <AppChrome
65
+ active={activeAppOf(route)}
66
+ busy={pending}
67
+ hideChrome={takeovers > 0}
68
+ userUid={userUid}
69
+ authServerUrl={authServerUrl}
70
+ impersonating={impersonating}
71
+ impersonationBaseUrl={impersonationBaseUrl}
72
+ trusted={trusted}
73
+ trustedRoles={trustedRoles}
74
+ pluginNav={pluginNav}
75
+ branding={branding}
76
+ appearance={appearance}
77
+ >
78
+ {children}
79
+ </AppChrome>
80
+ </AppFrameContext.Provider>
81
+ );
82
+ }
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { routedPage } from "../_routedPage.js";
5
+ import { pageTitle } from "../../shared/navigation/pageTitle.js";
6
6
  import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
7
7
  import { DndContext, DragEndEvent, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
8
8
  import { HiOutlineBars3, HiOutlinePlus } from "react-icons/hi2";
@@ -575,4 +575,7 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
575
575
  );
576
576
  }
577
577
 
578
- export default routedPage("/calendar", CalendarPage);
578
+ export default CalendarPage;
579
+
580
+ /** The tab's title: `Brand: Calendar` (see `pageTitle()`). */
581
+ export const title = pageTitle("Calendar");
@@ -2,8 +2,8 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { routedPage } from "../_routedPage.js";
6
- import { useNavigate } from "../../shared/navigation/AppRouter.js";
5
+ import { pageTitle } from "../../shared/navigation/pageTitle.js";
6
+ import { useNavigate } from "../../shared/navigation/index.js";
7
7
  import React, { useEffect, useState } from "react";
8
8
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
9
  import { Contact, deleteContact, getContact } from "@rapidmx/react-shared/contacts/contactsApi.js";
@@ -106,4 +106,7 @@ function ContactDetailContent({ uid }: { uid: string }) {
106
106
  );
107
107
  }
108
108
 
109
- export default routedPage("/contacts/:uid", ContactDetailPage);
109
+ export default ContactDetailPage;
110
+
111
+ /** The tab's title: `Brand: Contacts` (see `pageTitle()`). */
112
+ export const title = pageTitle("Contacts");
@@ -2,8 +2,8 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { routedPage } from "../_routedPage.js";
6
- import { useNavigate } from "../../shared/navigation/AppRouter.js";
5
+ import { pageTitle } from "../../shared/navigation/pageTitle.js";
6
+ import { useNavigate } from "../../shared/navigation/index.js";
7
7
  import React, { useEffect, useMemo, useRef, useState } from "react";
8
8
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
9
  import {
@@ -536,6 +536,8 @@ function ContactsContent({ userUid }: { userUid?: string }) {
536
536
  {mailboxes.find((mb) => mb.uid === savedElsewhere.mailboxUid)?.displayName ?? "another mailbox"}.{" "}
537
537
  <a
538
538
  href={`/contacts?mailboxUid=${encodeURIComponent(savedElsewhere.mailboxUid)}`}
539
+ data-router-shallow=""
540
+ data-router-prefetch="false"
539
541
  className="font-medium text-primary-dark hover:underline"
540
542
  >
541
543
  View that mailbox&rsquo;s contacts
@@ -590,4 +592,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
590
592
  );
591
593
  }
592
594
 
593
- export default routedPage("/contacts", ContactsPage);
595
+ export default ContactsPage;
596
+
597
+ /** The tab's title: `Brand: Contacts` (see `pageTitle()`). */
598
+ export const title = pageTitle("Contacts");
@@ -2,9 +2,9 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { routedPage } from "./_routedPage.js";
6
- import { useNavigate } from "../shared/navigation/AppRouter.js";
7
- import { whenIdle } from "../shared/navigation/idle.js";
5
+ import { pageTitle } from "../shared/navigation/pageTitle.js";
6
+ import { useNavigate } from "../shared/navigation/index.js";
7
+ import { whenIdle } from "@rapidrest/react/client";
8
8
  import { prefetchComposeWindow } from "../shared/components/mail/compose/ComposeContext.js";
9
9
  import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
10
10
  import { createPortal } from "react-dom";
@@ -76,6 +76,10 @@ import MoveToFolderDialog from "../shared/components/mail/MoveToFolderDialog.js"
76
76
  import { EncryptedPreview } from "../shared/components/mail/reading/EncryptedPreview.js";
77
77
  import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
78
78
  import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
79
+ import EmptyFolderBar from "../shared/components/mail/EmptyFolderBar.js";
80
+ import { EMPTIABLE_FOLDER_TYPES } from "../shared/mail/permanentDelete.js";
81
+ import { usePermanentDelete } from "../shared/mail/usePermanentDelete.js";
82
+ import { useMailboxUpdateAccess } from "../shared/mail/useMailboxUpdateAccess.js";
79
83
  import {
80
84
  CONVERSATION_SORT_NOTE,
81
85
  CONVERSATION_SORT_UNAVAILABLE,
@@ -538,7 +542,7 @@ function InboxPage(props: MailShellProps) {
538
542
  }
539
543
 
540
544
  function InboxContent({ userUid }: { userUid?: string }) {
541
- const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot } = useMailShell();
545
+ const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange, folderCountOf, mobileSearchSlot } = useMailShell();
542
546
  const isMobile = useIsMobile();
543
547
  const navigate = useNavigate();
544
548
  const { requestUnlock } = useUnlockPrompt();
@@ -581,6 +585,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
581
585
  // or it would act on a selection that is still arriving.
582
586
  const [resolvingSelection, setResolvingSelection] = useState(0);
583
587
  const [bulkBusy, setBulkBusy] = useState(false);
588
+ // Delete on what is already in Deleted Items, and Empty folder: confirmed, then done for good (see `usePermanentDelete()`).
589
+ const permanent = usePermanentDelete();
584
590
  // Bumped to force the list effect below to re-run - a bulk update is deliberately neither atomic nor
585
591
  // all-or-nothing (see `bulkUpdateMessages()`), so a rejection means refetching rather than guessing
586
592
  // which half of the selection actually landed.
@@ -640,6 +646,8 @@ function InboxContent({ userUid }: { userUid?: string }) {
640
646
  // Always set once this page renders: a caller with no mailbox at all gets `MailboxProvisioning` from the shell instead.
641
647
  const activeMailboxUid = (mailboxUid ?? primaryMailboxUid(mailboxes, userUid))!;
642
648
  const mailboxKeys = mailboxes.find((mb) => mb.uid === activeMailboxUid)?.keys ?? [];
649
+ // False for a mailbox shared with the reader view-only: Empty folder is not offered there (the server would refuse it).
650
+ const mailboxWritable = useMailboxUpdateAccess(mailboxes.find((mb) => mb.uid === activeMailboxUid));
643
651
  // The Sort/Filter menus' and "Show as conversations"' current settings, remembered per mailbox across
644
652
  // reloads (`listPreferences.ts`). Read during render, not in an effect, so the very first listing
645
653
  // already uses the remembered arrangement rather than fetching the default one and immediately
@@ -2014,6 +2022,50 @@ function InboxContent({ userUid }: { userUid?: string }) {
2014
2022
  return runBulkAction((moving) => moveToFolderOfType(moving, type, name), true, chosen);
2015
2023
  }
2016
2024
 
2025
+ /**
2026
+ * Permanently deletes `chosen` - messages that are already in Deleted Items - once the reader has confirmed it (`usePermanentDelete()`
2027
+ * asks, deletes, tells the badges and says what happened). The rows that went leave the list, and selection and advance behave as for
2028
+ * Archive; a conversation list is reloaded, as after every bulk action on it, and so is a flat one when the server refused some
2029
+ * (those stay listed, and the reload shows what is really there). Resolves whether anything was deleted.
2030
+ */
2031
+ async function purgeChosen(chosen: Message[]): Promise<boolean> {
2032
+ const outcome = await permanent.requestPermanentDelete(chosen);
2033
+ if (!outcome) {
2034
+ return false;
2035
+ }
2036
+ if (asConversations) {
2037
+ setSelectedConversationIds(new Set());
2038
+ setConversationMessagesById({});
2039
+ setRefreshKey((n) => n + 1);
2040
+ } else {
2041
+ removeListedMessages(new Set(outcome.deleted.map((m) => m.uid)));
2042
+ if (outcome.failed.length > 0) {
2043
+ setRefreshKey((n) => n + 1);
2044
+ }
2045
+ }
2046
+ setSelectedUids(new Set());
2047
+ return outcome.deleted.length > 0;
2048
+ }
2049
+
2050
+ /** Empty folder: permanently deletes everything in the folder being listed (Deleted Items or Junk Email) after the reader has confirmed. */
2051
+ async function emptyListedFolder(folder: Folder, count: number | undefined) {
2052
+ const outcome = await permanent.requestEmptyFolder({ uid: folder.uid, name: folder.name }, count);
2053
+ if (!outcome) {
2054
+ return;
2055
+ }
2056
+ if (outcome.emptied) {
2057
+ setMessages([]);
2058
+ setConversations([]);
2059
+ }
2060
+ setSelectedUid(null);
2061
+ setOpenThread(null);
2062
+ setSelectedUids(new Set());
2063
+ setSelectedConversationIds(new Set());
2064
+ setConversationMessagesById({});
2065
+ // What is really left - after a partial delete, or one that failed outright - is whatever the server lists.
2066
+ setRefreshKey((n) => n + 1);
2067
+ }
2068
+
2017
2069
  // ---- Swiping a row on a phone (see `SwipeRow`): right to left archives, left to right asks for a folder. Both go through the same
2018
2070
  // ---- bulk path as the selection bar's Archive and Move to, so the badges, the optimistic bookkeeping and the failure pop-up are one
2019
2071
  // ---- implementation. Not offered where the actions aren't (an aggregate view spans mailboxes, search results are a mixed bag, the
@@ -2250,6 +2302,11 @@ function InboxContent({ userUid }: { userUid?: string }) {
2250
2302
  return mailboxFolders.flatMap((mf) => mf.folders).find((f) => f.uid === folderUidOfMessage)?.type;
2251
2303
  }
2252
2304
 
2305
+ /** Whether a message is in its mailbox's Deleted Items - where Delete is the permanent one. */
2306
+ function inDeletedItems(message: Message): boolean {
2307
+ return folderTypeOf(message.folderUid) === "deleted_items";
2308
+ }
2309
+
2253
2310
  /** Moves the keyboard's focus to a row - and scrolls it into view - once the selection has moved to it. */
2254
2311
  function focusRow(uid: string) {
2255
2312
  // Only called from a key press, when the list is on screen.
@@ -2314,17 +2371,13 @@ function InboxContent({ userUid }: { userUid?: string }) {
2314
2371
  }
2315
2372
 
2316
2373
  /**
2317
- * Runs a bulk action on what the keyboard acts on - the ticked rows in select mode, else the open message or conversation - keeping only
2318
- * the messages `applies` accepts (Delete leaves out what is already in Deleted Items, Mark read what is already read: nothing to do
2319
- * then). Held while another bulk action is on the wire, like the bar's buttons. Closes the open thread after a move away from it.
2374
+ * What the keyboard acts on - the ticked rows in select mode, else the open message or conversation - keeping only the messages `applies`
2375
+ * accepts (Mark read leaves out what is already read: nothing to do then). Nothing (an empty list) while another bulk action or a
2376
+ * permanent delete is on the wire, like the bar's buttons, or when the open conversation could not be loaded.
2320
2377
  */
2321
- async function runOnKeyboardTargets(
2322
- action: (chosen: Message[]) => Promise<Message[]>,
2323
- removesRows: boolean,
2324
- applies: (message: Message) => boolean = () => true,
2325
- ) {
2326
- if (bulkBusy || resolvingSelection > 0) {
2327
- return;
2378
+ async function keyboardTargets(applies: (message: Message) => boolean = () => true): Promise<Message[]> {
2379
+ if (bulkBusy || resolvingSelection > 0 || permanent.busy) {
2380
+ return [];
2328
2381
  }
2329
2382
  let chosen: Message[];
2330
2383
  try {
@@ -2335,33 +2388,61 @@ function InboxContent({ userUid }: { userUid?: string }) {
2335
2388
  : [selected!];
2336
2389
  } catch (err) {
2337
2390
  notifyApiError(err, "Couldn't load the messages in that conversation");
2338
- return;
2391
+ return [];
2339
2392
  }
2340
- chosen = chosen.filter(applies);
2393
+ return chosen.filter(applies);
2394
+ }
2395
+
2396
+ /** Closes the open thread (and the selection in it) - what a delete of the whole conversation leaves nothing to show of. */
2397
+ function closeOpenThread() {
2398
+ setOpenThread(null);
2399
+ setSelectedUid(null);
2400
+ }
2401
+
2402
+ /** Runs a bulk action on `chosen` (from `keyboardTargets()`), closing the open thread after a move away from it. */
2403
+ async function actOnKeyboardTargets(chosen: Message[], action: (chosen: Message[]) => Promise<Message[]>, removesRows: boolean) {
2341
2404
  if (chosen.length === 0) {
2342
2405
  return;
2343
2406
  }
2344
2407
  const succeeded = await runBulkAction(action, removesRows, chosen);
2345
2408
  if (succeeded && removesRows && threadPane && !selectMode) {
2346
- setOpenThread(null);
2347
- setSelectedUid(null);
2409
+ closeOpenThread();
2410
+ }
2411
+ }
2412
+
2413
+ /** Runs a bulk action on what the keyboard acts on (`keyboardTargets()`). */
2414
+ async function runOnKeyboardTargets(
2415
+ action: (chosen: Message[]) => Promise<Message[]>,
2416
+ removesRows: boolean,
2417
+ applies: (message: Message) => boolean = () => true,
2418
+ ) {
2419
+ await actOnKeyboardTargets(await keyboardTargets(applies), action, removesRows);
2420
+ }
2421
+
2422
+ /**
2423
+ * Delete, from the keyboard: what is in Deleted Items is permanently deleted (after a confirmation), anything else moves to its mailbox's
2424
+ * Deleted Items - as the selection bar's Delete does. A mix (search results from several folders) moves only what is not in Deleted Items yet.
2425
+ */
2426
+ async function deleteFromKeyboard() {
2427
+ const targets = await keyboardTargets();
2428
+ if (targets.length > 0 && targets.every(inDeletedItems)) {
2429
+ if ((await purgeChosen(targets)) && threadPane && !selectMode) {
2430
+ closeOpenThread();
2431
+ }
2432
+ return;
2348
2433
  }
2434
+ await actOnKeyboardTargets(
2435
+ targets.filter((message) => !inDeletedItems(message)),
2436
+ (chosen) => moveToFolderOfType(chosen, "deleted_items", "Deleted Items"),
2437
+ true,
2438
+ );
2349
2439
  }
2350
2440
 
2351
2441
  useShortcut(SHORTCUTS.mail.next, () => selectNeighbour(1), { enabled: canMoveSelection });
2352
2442
  useShortcut(SHORTCUTS.mail.previous, () => selectNeighbour(-1), { enabled: canMoveSelection });
2353
2443
  useShortcut(SHORTCUTS.mail.nextUnread, () => selectNextUnread(1), { enabled: canMoveSelection });
2354
2444
  useShortcut(SHORTCUTS.mail.previousUnread, () => selectNextUnread(-1), { enabled: canMoveSelection });
2355
- useShortcut(
2356
- SHORTCUTS.mail.delete,
2357
- () =>
2358
- void runOnKeyboardTargets(
2359
- (chosen) => moveToFolderOfType(chosen, "deleted_items", "Deleted Items"),
2360
- true,
2361
- (message) => folderTypeOf(message.folderUid) !== "deleted_items",
2362
- ),
2363
- { enabled: keyboardActions && keyboardTargetExists },
2364
- );
2445
+ useShortcut(SHORTCUTS.mail.delete, () => void deleteFromKeyboard(), { enabled: keyboardActions && keyboardTargetExists });
2365
2446
  useShortcut(
2366
2447
  SHORTCUTS.mail.markRead,
2367
2448
  () =>
@@ -2432,6 +2513,20 @@ function InboxContent({ userUid }: { userUid?: string }) {
2432
2513
  crossMailbox && selectedMessages.some((m) => m.mailboxUid !== activeMailboxUid)
2433
2514
  ? "Labels can only be applied here to messages in the open mailbox"
2434
2515
  : undefined;
2516
+ // Delete is the permanent one for what is in Deleted Items: for the selection when there is one (search results can come from any folder),
2517
+ // else for the folder being viewed, so the button says so before anything is ticked.
2518
+ const deletesPermanently =
2519
+ selectedMessages.length > 0 ? selectedMessages.every(inDeletedItems) : currentFolders.find((f) => f.uid === folderUid)?.type === "deleted_items";
2520
+ // Empty folder is offered at the top of the list of a mailbox's own Deleted Items or Junk Email - not for search results, nor for the "All
2521
+ // mailboxes" views, which have no one folder to empty.
2522
+ const emptiableFolder = !isSearching && !aggregateFolderType ? currentFolders.find((f) => f.uid === folderUid && EMPTIABLE_FOLDER_TYPES.includes(f.type)) : undefined;
2523
+ // How many it holds as the sidebar's counts have it (kept right as messages leave), unless that says none while the list shows some.
2524
+ const emptiableCount = emptiableFolder && folderCountOf(emptiableFolder).total > 0 ? folderCountOf(emptiableFolder).total : undefined;
2525
+ const emptyDisabledReason = !mailboxWritable
2526
+ ? "This mailbox is shared with you view-only"
2527
+ : listedRowCount === 0 && emptiableCount === undefined
2528
+ ? "This folder is already empty"
2529
+ : undefined;
2435
2530
 
2436
2531
  if (!folderUid && !aggregateFolderType) {
2437
2532
  // `MailShell` never renders this component at all until a mailbox is resolved (see its own
@@ -2492,8 +2587,14 @@ function InboxContent({ userUid }: { userUid?: string }) {
2492
2587
  await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid), true);
2493
2588
  }}
2494
2589
  onReportJunk={() => void moveSelectionToType("junk", "Junk Email")}
2495
- onDelete={() => void moveSelectionToType("deleted_items", "Deleted Items")}
2496
- busy={bulkBusy || resolvingSelection > 0}
2590
+ onDelete={() =>
2591
+ void (deletesPermanently
2592
+ ? purgeChosen(selectedMessages)
2593
+ : // A mix (search results from several folders) moves only what is not in Deleted Items yet.
2594
+ moveSelectionToType("deleted_items", "Deleted Items", selectedMessages.filter((m) => !inDeletedItems(m))))
2595
+ }
2596
+ deletesPermanently={deletesPermanently}
2597
+ busy={bulkBusy || resolvingSelection > 0 || permanent.busy}
2497
2598
  />
2498
2599
  ) : (
2499
2600
  <MailListToolbar
@@ -2539,6 +2640,16 @@ function InboxContent({ userUid }: { userUid?: string }) {
2539
2640
  )}
2540
2641
  {!isMobile && <div className="p-2 border-b border-border">{searchField}</div>}
2541
2642
  {isMobile && mobileSearchSlot && createPortal(searchField, mobileSearchSlot)}
2643
+ {emptiableFolder && !selectMode && (
2644
+ <EmptyFolderBar
2645
+ folderName={emptiableFolder.name}
2646
+ count={emptiableCount}
2647
+ disabled={!!emptyDisabledReason || permanent.busy || bulkBusy}
2648
+ disabledReason={emptyDisabledReason}
2649
+ onEmpty={() => void emptyListedFolder(emptiableFolder, emptiableCount)}
2650
+ />
2651
+ )}
2652
+ {permanent.dialog}
2542
2653
  {/* Two tabs, as Outlook has: Focused and Other. There is no "All" tab - the whole Inbox is
2543
2654
  still one pick away, in the Filter menu, which is where every other named filter lives
2544
2655
  and the only place that can show which of them is really in force. A stored `all` (or
@@ -2822,6 +2933,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
2822
2933
  // from the list rather than patched in place.
2823
2934
  removeListedMessage(updated.uid);
2824
2935
  }}
2936
+ onChanged={patchListedMessage}
2825
2937
  labels={labels}
2826
2938
  onLabelsChanged={patchListedMessage}
2827
2939
  onLabelCreated={(label) =>
@@ -2844,4 +2956,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
2844
2956
  );
2845
2957
  }
2846
2958
 
2847
- export default routedPage("/", InboxPage);
2959
+ export default InboxPage;
2960
+
2961
+ /** The tab's title: `Brand: Mail` (see `pageTitle()`). */
2962
+ export const title = pageTitle("Mail");
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { routedPage } from "../_routedPage.js";
5
+ import { pageTitle } from "../../shared/navigation/pageTitle.js";
6
6
  import React, { useEffect, useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import { Message, getMessage } from "@rapidmx/react-shared/mail/mailApi.js";
@@ -11,7 +11,7 @@ import { useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHook
11
11
  import { useMarkMessageRead } from "../../shared/mail/useMarkMessageRead.js";
12
12
  import MailShell, { MailShellProps, useMailShell } from "../../shared/components/mail/layout/MailShell.js";
13
13
  import { LazyConversationThreadPane, LazyMessageDetailPane } from "../../shared/components/mail/LazyReadingPane.js";
14
- import { useLocationSearch } from "../../shared/navigation/AppRouter.js";
14
+ import { useSearchParams } from "@rapidrest/react/client";
15
15
  import { ReadingPaneSkeleton } from "../../shared/components/mail/reading/MessageCard.js";
16
16
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
17
17
 
@@ -35,7 +35,7 @@ function MessageDetailContent({ uid }: { uid: string }) {
35
35
  const [loading, setLoading] = useState(true);
36
36
  const [error, setError] = useState<string | null>(null);
37
37
  const [labels, setLabels] = useState<Label[]>([]);
38
- const conversationId: string | null = new URLSearchParams(useLocationSearch()).get("conversation");
38
+ const conversationId: string | null = useSearchParams()[0].get("conversation");
39
39
 
40
40
  useEffect(() => {
41
41
  setLoading(true);
@@ -125,6 +125,7 @@ function MessageDetailContent({ uid }: { uid: string }) {
125
125
  onFolderCreated={onFolderCreated}
126
126
  onScheduledSendCanceled={setMessage}
127
127
  onArchived={setMessage}
128
+ onChanged={setMessage}
128
129
  labels={labels}
129
130
  onLabelsChanged={setMessage}
130
131
  onLabelCreated={(label) => setLabels((prev) => [...prev, label])}
@@ -132,4 +133,7 @@ function MessageDetailContent({ uid }: { uid: string }) {
132
133
  );
133
134
  }
134
135
 
135
- export default routedPage("/messages/:uid", MessageDetailPage);
136
+ export default MessageDetailPage;
137
+
138
+ /** The tab's title: `Brand: Mail` (see `pageTitle()`). */
139
+ export const title = pageTitle("Mail");
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { routedPage } from "../../_routedPage.js";
5
+ import { pageTitle } from "../../../shared/navigation/pageTitle.js";
6
6
  import React from "react";
7
7
  import SettingsShell, { SettingsShellProps } from "../../../shared/components/settings/layout/SettingsShell.js";
8
8
  import AppearanceForm from "../../../shared/appearance/AppearanceForm.js";
@@ -18,4 +18,7 @@ function SettingsAppearancePage(props: SettingsAppearancePageProps) {
18
18
  );
19
19
  }
20
20
 
21
- export default routedPage("/settings/appearance", SettingsAppearancePage);
21
+ export default SettingsAppearancePage;
22
+
23
+ /** The tab's title: `Brand: Settings` (see `pageTitle()`). */
24
+ export const title = pageTitle("Settings");
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { routedPage } from "../../_routedPage.js";
5
+ import { pageTitle } from "../../../shared/navigation/pageTitle.js";
6
6
  import React, { FormEvent, useState } from "react";
7
7
  import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
8
8
  import { updateMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
@@ -130,4 +130,7 @@ function AutoReplyContent() {
130
130
  );
131
131
  }
132
132
 
133
- export default routedPage("/settings/auto-reply", SettingsAutoReplyPage);
133
+ export default SettingsAutoReplyPage;
134
+
135
+ /** The tab's title: `Brand: Settings` (see `pageTitle()`). */
136
+ export const title = pageTitle("Settings");