@rapidmx/web-client 0.9.0 → 0.10.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 (226) hide show
  1. package/README.md +192 -88
  2. package/apps/shared/auth/accountUrl.ts +9 -0
  3. package/apps/shared/auth/adminAccess.ts +99 -0
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +2 -1
  5. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  6. package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
  7. package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
  8. package/apps/shared/components/layout/AppShell.tsx +411 -288
  9. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  10. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
  11. package/apps/shared/components/layout/UserMenu.tsx +303 -185
  12. package/apps/shared/components/mail/ConversationList.tsx +269 -262
  13. package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
  14. package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
  15. package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
  16. package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1387
  17. package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
  18. package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
  19. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  20. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1736
  21. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  22. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  23. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  24. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  25. package/apps/shared/components/mail/layout/MailShell.tsx +476 -472
  26. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  27. package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
  28. package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
  29. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  30. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  31. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  32. package/apps/shared/keyboard/dispatch.ts +124 -0
  33. package/apps/shared/keyboard/format.ts +89 -0
  34. package/apps/shared/keyboard/keymap.ts +114 -0
  35. package/apps/shared/keyboard/match.ts +44 -0
  36. package/apps/shared/keyboard/parse.ts +136 -0
  37. package/apps/shared/keyboard/platform.ts +34 -0
  38. package/apps/shared/keyboard/registry.ts +65 -0
  39. package/apps/shared/keyboard/targets.ts +79 -0
  40. package/apps/shared/keyboard/useShortcut.ts +50 -0
  41. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  42. package/apps/shared/mail/folderCounts.ts +302 -0
  43. package/apps/shared/mail/listSnapshots.ts +87 -0
  44. package/apps/shared/mail/messageReadState.ts +85 -0
  45. package/apps/shared/mail/newMailNotifications.ts +183 -0
  46. package/apps/shared/mail/useMailConnection.ts +150 -0
  47. package/apps/shared/mail/useMailLiveUpdates.ts +232 -224
  48. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  49. package/apps/shared/mail/useNewMailNotifications.ts +163 -0
  50. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  51. package/apps/shared/navigation/AppRouter.tsx +300 -0
  52. package/apps/shared/navigation/appHrefs.ts +23 -0
  53. package/apps/shared/navigation/frameContext.tsx +35 -0
  54. package/apps/shared/navigation/idle.ts +45 -0
  55. package/apps/shared/navigation/routerContext.tsx +83 -0
  56. package/apps/shared/navigation/routes.ts +72 -0
  57. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  58. package/apps/shared/styles/app.css +28 -10
  59. package/apps/www/_routedPage.tsx +24 -0
  60. package/apps/www/_routes.ts +35 -0
  61. package/apps/www/calendar/index.tsx +55 -19
  62. package/apps/www/contacts/[uid].tsx +112 -107
  63. package/apps/www/contacts/index.tsx +584 -567
  64. package/apps/www/index.tsx +2280 -1917
  65. package/apps/www/messages/[uid].tsx +106 -101
  66. package/apps/www/settings/auto-reply/index.tsx +4 -1
  67. package/apps/www/settings/encryption/index.tsx +1252 -1249
  68. package/apps/www/settings/filters/[uid].tsx +4 -1
  69. package/apps/www/settings/filters/index.tsx +102 -99
  70. package/apps/www/settings/filters/new/index.tsx +138 -133
  71. package/apps/www/settings/labels/index.tsx +204 -201
  72. package/apps/www/settings/privacy/index.tsx +4 -1
  73. package/apps/www/settings/read-receipts/index.tsx +4 -1
  74. package/apps/www/settings/sharing/index.tsx +277 -274
  75. package/apps/www/settings/signatures/[uid].tsx +170 -167
  76. package/apps/www/settings/signatures/index.tsx +88 -85
  77. package/apps/www/settings/signatures/new/index.tsx +134 -129
  78. package/apps/www/tasks/index.tsx +18 -2
  79. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  80. package/dist/apps/shared/auth/accountUrl.js +8 -0
  81. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  82. package/dist/apps/shared/auth/adminAccess.js +89 -0
  83. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +1 -1
  84. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  85. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  86. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  87. package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
  88. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  89. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
  90. package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
  91. package/dist/apps/shared/components/layout/AppShell.js +73 -14
  92. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  93. package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
  94. package/dist/apps/shared/components/layout/UserMenu.d.ts +19 -3
  95. package/dist/apps/shared/components/layout/UserMenu.js +52 -5
  96. package/dist/apps/shared/components/mail/ConversationList.js +4 -11
  97. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  98. package/dist/apps/shared/components/mail/ConversationThreadPane.js +52 -27
  99. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  100. package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
  101. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
  102. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
  103. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
  104. package/dist/apps/shared/components/mail/MessageDetailPane.js +83 -34
  105. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
  106. package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
  107. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
  108. package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
  109. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  110. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
  111. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +98 -22
  112. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  113. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  114. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  115. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  116. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  117. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  118. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  119. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  120. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +12 -4
  121. package/dist/apps/shared/components/mail/layout/MailShell.js +76 -96
  122. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  123. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  124. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  125. package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
  126. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  127. package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
  128. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  129. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  130. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  131. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  132. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  133. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  134. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  135. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  136. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  137. package/dist/apps/shared/keyboard/format.js +74 -0
  138. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  139. package/dist/apps/shared/keyboard/keymap.js +84 -0
  140. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  141. package/dist/apps/shared/keyboard/match.js +31 -0
  142. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  143. package/dist/apps/shared/keyboard/parse.js +107 -0
  144. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  145. package/dist/apps/shared/keyboard/platform.js +22 -0
  146. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  147. package/dist/apps/shared/keyboard/registry.js +33 -0
  148. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  149. package/dist/apps/shared/keyboard/targets.js +67 -0
  150. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  151. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  152. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  153. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  154. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  155. package/dist/apps/shared/mail/folderCounts.js +212 -0
  156. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  157. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  158. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  159. package/dist/apps/shared/mail/messageReadState.js +63 -0
  160. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  161. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  162. package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
  163. package/dist/apps/shared/mail/useMailConnection.js +96 -0
  164. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +13 -6
  165. package/dist/apps/shared/mail/useMailLiveUpdates.js +28 -26
  166. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  167. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  168. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
  169. package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
  170. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  171. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  172. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  173. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  174. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  175. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  176. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  177. package/dist/apps/shared/navigation/frameContext.js +24 -0
  178. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  179. package/dist/apps/shared/navigation/idle.js +43 -0
  180. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  181. package/dist/apps/shared/navigation/routerContext.js +56 -0
  182. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  183. package/dist/apps/shared/navigation/routes.js +37 -0
  184. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  185. package/dist/apps/shared/styles/app.css +28 -10
  186. package/dist/apps/www/_routedPage.d.ts +12 -0
  187. package/dist/apps/www/_routedPage.js +19 -0
  188. package/dist/apps/www/_routes.d.ts +11 -0
  189. package/dist/apps/www/_routes.js +29 -0
  190. package/dist/apps/www/calendar/index.d.ts +2 -2
  191. package/dist/apps/www/calendar/index.js +39 -8
  192. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  193. package/dist/apps/www/contacts/[uid].js +6 -2
  194. package/dist/apps/www/contacts/index.d.ts +2 -2
  195. package/dist/apps/www/contacts/index.js +17 -4
  196. package/dist/apps/www/index.d.ts +2 -2
  197. package/dist/apps/www/index.js +301 -35
  198. package/dist/apps/www/messages/[uid].d.ts +3 -7
  199. package/dist/apps/www/messages/[uid].js +7 -4
  200. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  201. package/dist/apps/www/settings/auto-reply/index.js +3 -1
  202. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  203. package/dist/apps/www/settings/encryption/index.js +3 -1
  204. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  205. package/dist/apps/www/settings/filters/[uid].js +3 -1
  206. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  207. package/dist/apps/www/settings/filters/index.js +3 -1
  208. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  209. package/dist/apps/www/settings/filters/new/index.js +6 -2
  210. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  211. package/dist/apps/www/settings/labels/index.js +3 -1
  212. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  213. package/dist/apps/www/settings/privacy/index.js +3 -1
  214. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  215. package/dist/apps/www/settings/read-receipts/index.js +3 -1
  216. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  217. package/dist/apps/www/settings/sharing/index.js +3 -1
  218. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  219. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  220. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  221. package/dist/apps/www/settings/signatures/index.js +3 -1
  222. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  223. package/dist/apps/www/settings/signatures/new/index.js +6 -2
  224. package/dist/apps/www/tasks/index.d.ts +2 -2
  225. package/dist/apps/www/tasks/index.js +15 -3
  226. package/package.json +2 -2
@@ -1,472 +1,476 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { createContext, PropsWithChildren, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
6
- import { HiOutlineBars3 } from "react-icons/hi2";
7
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
9
- import { Folder, Mailbox, listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
10
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
- import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
12
- import AppShell, { AppShellProps } from "../../layout/AppShell.js";
13
- import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
14
- import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
15
- import { useCompose } from "../compose/ComposeContext.js";
16
- import LocalIndexLifecycle from "../../../search/LocalIndexLifecycle.js";
17
- import { LiveUpdates, NO_LIVE_UPDATES, useMailLiveUpdates } from "../../../mail/useMailLiveUpdates.js";
18
-
19
- export type MailShellProps = Omit<AppShellProps, "active">;
20
-
21
- /** One mailbox's own mail folders (already filtered to `MAIL_FOLDER_TYPES` and unsorted) - one entry per
22
- * mailbox in `mailboxes`, fetched in parallel so every accessible mailbox's folder tree can render
23
- * simultaneously (see `MailShell`'s own doc comment on why this replaced the old single-mailbox `folders`
24
- * field). `error` is set (and `folders` left empty) for a mailbox whose own `listFolders()` call failed -
25
- * one mailbox's fetch failure must never blank out every other mailbox's section. */
26
- export interface MailboxFolders {
27
- mailbox: Mailbox;
28
- folders: Folder[];
29
- error?: string;
30
- }
31
-
32
- /** The well-known mail folder types an "aggregate" pseudo-folder can merge across every accessible
33
- * mailbox - a fixed, small set: `outbox` is deliberately excluded (transient per-mailbox send-queue
34
- * state, not a "merge across mailboxes" concept), and there's no aggregate concept for `calendar`/
35
- * `contacts`/`tasks`/`notes`/`user` folders (Mail's own sidebar already excludes those - see
36
- * `MAIL_FOLDER_TYPES`). */
37
- export const AGGREGATE_FOLDER_TYPES = ["inbox", "sent_items", "drafts", "deleted_items", "junk"] as const;
38
- export type AggregateFolderType = (typeof AGGREGATE_FOLDER_TYPES)[number];
39
-
40
- function isAggregateFolderType(value: string | null): value is AggregateFolderType {
41
- return !!value && (AGGREGATE_FOLDER_TYPES as readonly string[]).includes(value);
42
- }
43
-
44
- export interface MailShellContextValue {
45
- /** The mailbox currently selected (`?mailboxUid=`) - `undefined` while an aggregate pseudo-folder is
46
- * active instead (`aggregateFolderType` set), since there's no single mailbox to speak of then. */
47
- mailboxUid?: string;
48
- /** The folder currently selected (`?folderUid=`, or the selected mailbox's Inbox) - `undefined` in
49
- * aggregate mode, for the same reason. */
50
- folderUid?: string;
51
- /** Set only when an aggregate pseudo-folder (`?aggregate=inbox` etc.) is selected instead of a real,
52
- * single mailbox+folder - mutually exclusive with `mailboxUid`/`folderUid` above. */
53
- aggregateFolderType?: AggregateFolderType;
54
- mailboxes: Mailbox[];
55
- /** Every accessible mailbox's own mail folders - replaces the old single-mailbox `folders: Folder[]`
56
- * now that every mailbox's tree renders at once (see `MailboxFolders`'s own doc comment). */
57
- mailboxFolders: MailboxFolders[];
58
- /**
59
- * Adds a folder a page has just created (the Move to prompt's "New folder") to the tree this shell
60
- * already fetched, so it appears in the sidebar and in every folder picker without a reload - this
61
- * framework has no client-side router, so a reload is a whole page load.
62
- *
63
- * A no-op on the default context value, which is only ever read outside a real shell.
64
- */
65
- onFolderCreated: (folder: Folder) => void;
66
- /**
67
- * Bumped whenever new mail (or another change to a message) may have arrived - a push event, a reconnect or the safety-net
68
- * poll - so the list on screen can quietly refetch its first page. See `useMailLiveUpdates()`. Never changes on the default
69
- * context value, which is only ever read outside a real shell.
70
- */
71
- live: LiveUpdates;
72
- }
73
-
74
- const MailShellContext = createContext<MailShellContextValue>({
75
- mailboxes: [],
76
- mailboxFolders: [],
77
- onFolderCreated: () => undefined,
78
- live: NO_LIVE_UPDATES,
79
- });
80
-
81
- /** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
82
- export function useMailShell(): MailShellContextValue {
83
- return useContext(MailShellContext);
84
- }
85
-
86
- const FOLDER_LABELS: Record<string, string> = {
87
- inbox: "Inbox",
88
- sent_items: "Sent Items",
89
- drafts: "Drafts",
90
- outbox: "Outbox",
91
- junk: "Junk Email",
92
- archive: "Archive",
93
- deleted_items: "Deleted Items",
94
- };
95
-
96
- /** Well-known folders sort first, in Gmail/Outlook's conventional order; anything else (incl. `user`) sorts after, alphabetically. */
97
- const FOLDER_ORDER = ["inbox", "drafts", "outbox", "sent_items", "junk", "archive", "deleted_items"];
98
-
99
- /**
100
- * A mailbox's `calendar`/`contacts`/`tasks`/`notes` folders back their own dedicated apps (see
101
- * `CalendarShell`/`ContactsShell`/`TasksShell`), not Mail — `listFolders()` returns every well-known
102
- * folder for the mailbox regardless of which app owns it, so Mail's own folder tree must filter down
103
- * to just the mail ones itself, or those other apps' folders leak into this sidebar.
104
- */
105
- const MAIL_FOLDER_TYPES = new Set([...FOLDER_ORDER, "user"]);
106
-
107
- function folderSortKey(folder: Folder): number {
108
- const idx = FOLDER_ORDER.indexOf(folder.type);
109
- return idx === -1 ? FOLDER_ORDER.length : idx;
110
- }
111
-
112
- function sortedFoldersOf(folders: Folder[]): Folder[] {
113
- return [...folders].sort((a, b) => folderSortKey(a) - folderSortKey(b) || a.name.localeCompare(b.name));
114
- }
115
-
116
- /** A folder's unread count: the refreshed one when live updates have read it since load, else the one it was listed with. */
117
- function unreadOf(folder: Folder, unreadCounts: Record<string, number>): number {
118
- return unreadCounts[folder.uid] ?? folder.unreadCount;
119
- }
120
-
121
- function aggregateUnreadCount(mailboxFolders: MailboxFolders[], type: AggregateFolderType, unreadCounts: Record<string, number>): number {
122
- return mailboxFolders.reduce((total, mf) => {
123
- const folder = mf.folders.find((f) => f.type === type);
124
- return total + (folder ? unreadOf(folder, unreadCounts) : 0);
125
- }, 0);
126
- }
127
-
128
- type Status = "checking" | "error" | "ready";
129
-
130
- /** Page size of the one `listMailboxes()` call this shell makes. */
131
- export const MAILBOX_LIST_LIMIT = 100;
132
-
133
- /**
134
- * A separate component (not inlined into `MailShell`'s own render) so `useCompose()` resolves against
135
- * `ComposeProvider` correctly: that provider is rendered *inside* the `AppShell` that `MailShell` itself
136
- * returns, i.e. a descendant of `MailShell`, not an ancestor — a hook call made directly in `MailShell`'s
137
- * own function body would see only whatever context exists *above* `MailShell`, never a provider one of
138
- * its own descendants creates. This button, rendered as part of `AppShell`'s `children`, sits correctly
139
- * inside that subtree.
140
- *
141
- * Opens with no mailbox: a fresh message defaults to the caller's own mailbox, and the compose window's
142
- * own From field is where the sender is chosen.
143
- */
144
- function ComposeButton() {
145
- const { openCompose } = useCompose();
146
- return (
147
- <button
148
- type="button"
149
- onClick={() => openCompose({})}
150
- className="block text-center w-full py-2.5 px-4 rounded-sm font-semibold text-sm bg-primary text-white hover:bg-primary-dark"
151
- >
152
- Compose
153
- </button>
154
- );
155
- }
156
-
157
- /**
158
- * Mail's own contextual sidebar (every accessible mailbox's own folder tree, plus a merged "All Mailboxes"
159
- * aggregate section) + content area, rendered inside the shared `AppShell` chrome (icon rail, header,
160
- * impersonation banner — see that component). There is no client-side router in this framework (see
161
- * `ReactRoute`'s file-convention resolver) — the selected mailbox/folder (or aggregate pseudo-folder) live
162
- * in the URL's `?mailboxUid=`/`?folderUid=`/`?aggregate=` query params, read once on mount (never during
163
- * the initial render itself, matching every other query-param reader in this codebase — e.g.
164
- * `apps/admin/quarantine`'s `readMailboxUid()` — so the server-rendered and just-hydrated client markup
165
- * match).
166
- *
167
- * Every accessible mailbox's folder tree renders at once - there is no "switch mailbox" affordance
168
- * anymore (the mailbox `<select>` this shell used to have is gone) - so a single-mailbox user sees zero
169
- * behavior change from before this, and a multi-mailbox user sees every mailbox's folders (and the new
170
- * aggregate section) simultaneously, matching the shared-mailbox feature's own "display as a separate set
171
- * of folders" requirement.
172
- */
173
- export default function MailShell({
174
- userUid,
175
- authServerUrl,
176
- impersonating,
177
- impersonationBaseUrl,
178
- trusted,
179
- pluginNav,
180
- children,
181
- }: PropsWithChildren<MailShellProps>) {
182
- const [status, setStatus] = useState<Status>("checking");
183
- const [error, setError] = useState<string | null>(null);
184
- const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
185
- const [mailboxFolders, setMailboxFolders] = useState<MailboxFolders[]>([]);
186
- const [foldersLoading, setFoldersLoading] = useState(true);
187
- const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
188
- const [requestedFolderUid, setRequestedFolderUid] = useState<string | null>(null);
189
- const [requestedAggregateType, setRequestedAggregateType] = useState<string | null>(null);
190
- const [drawerOpen, setDrawerOpen] = useState(false);
191
-
192
- useEffect(() => {
193
- const params = new URLSearchParams(window.location.search);
194
- setRequestedMailboxUid(params.get("mailboxUid"));
195
- setRequestedFolderUid(params.get("folderUid"));
196
- setRequestedAggregateType(params.get("aggregate"));
197
- }, []);
198
-
199
- useEffect(() => {
200
- if (!userUid) {
201
- return;
202
- }
203
- listMailboxes({ limit: MAILBOX_LIST_LIMIT })
204
- .then((result) => {
205
- setMailboxes(result);
206
- setStatus("ready");
207
- })
208
- .catch((err) => {
209
- setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
210
- setStatus("error");
211
- });
212
- }, [userUid]);
213
-
214
- const aggregateFolderType: AggregateFolderType | undefined = isAggregateFolderType(requestedAggregateType)
215
- ? requestedAggregateType
216
- : undefined;
217
-
218
- const mailboxUid: string | undefined = aggregateFolderType
219
- ? undefined
220
- : (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
221
- mailboxes[0]?.uid;
222
-
223
- // Fans out one listFolders() call per accessible mailbox in parallel - each call catches its own
224
- // failure into an MailboxFolders.error rather than letting Promise.all reject, so one mailbox's fetch
225
- // failure renders that section's own inline Alert instead of blanking out every other mailbox's
226
- // folder tree.
227
- useEffect(() => {
228
- if (mailboxes.length === 0) {
229
- // Deliberately leaves foldersLoading as-is: this also runs once before listMailboxes() has
230
- // resolved, and clearing it here would briefly render an empty sidebar the moment mailboxes
231
- // arrive, before their folders do. A genuinely mailbox-less caller gets MailboxProvisioning.
232
- setMailboxFolders([]);
233
- return;
234
- }
235
- setFoldersLoading(true);
236
- void Promise.all(
237
- mailboxes.map((mailbox) =>
238
- listFolders(mailbox.uid)
239
- .then((result): MailboxFolders => ({ mailbox, folders: result.filter((f) => MAIL_FOLDER_TYPES.has(f.type)) }))
240
- .catch(
241
- (err): MailboxFolders => ({
242
- mailbox,
243
- folders: [],
244
- error: err instanceof ApiRequestError ? err.message : "Could not load folders.",
245
- }),
246
- ),
247
- ),
248
- )
249
- .then(setMailboxFolders)
250
- .finally(() => setFoldersLoading(false));
251
- }, [mailboxes]);
252
-
253
- const selectedMailboxFolders = mailboxFolders.find((mf) => mf.mailbox.uid === mailboxUid)?.folders ?? [];
254
- const folderUid: string | undefined = aggregateFolderType
255
- ? undefined
256
- : (requestedFolderUid && selectedMailboxFolders.some((f) => f.uid === requestedFolderUid) ? requestedFolderUid : undefined) ??
257
- selectedMailboxFolders.find((f) => f.type === "inbox")?.uid;
258
-
259
- // The mailbox `KeyEnrollmentGate`/`LocalIndexLifecycle`/`ComposeButton` treat as "the" mailbox when
260
- // there's no single selected one to use (aggregate mode) - the caller's own owned mailbox if they
261
- // have one, else whichever accessible mailbox happens to be first. See MailShell's own doc comment on
262
- // this being an accepted limitation: an aggregate-view message from a *different*, not-yet-visited
263
- // mailbox may still need that mailbox's own folder view opened directly to unlock/decrypt it.
264
- const defaultMailboxUid = mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
265
- const activeMailboxUid = mailboxUid ?? defaultMailboxUid;
266
-
267
- /** Files a newly created folder under its own mailbox, leaving every other mailbox's list untouched -
268
- * and ignoring a type this sidebar doesn't list at all, the same filter the fetch above applies. */
269
- const onFolderCreated = useCallback((folder: Folder) => {
270
- if (!MAIL_FOLDER_TYPES.has(folder.type)) {
271
- return;
272
- }
273
- setMailboxFolders((prev) =>
274
- prev.map((entry) =>
275
- // Not one already there: another client's create event can arrive after (or twice, or alongside) our own.
276
- entry.mailbox.uid === folder.mailboxUid && !entry.folders.some((existing) => existing.uid === folder.uid)
277
- ? { ...entry, folders: [...entry.folders, folder] }
278
- : entry,
279
- ),
280
- );
281
- }, []);
282
-
283
- // New mail without a reload: push events for every folder, plus a safety-net poll. Held in state beside `mailboxFolders`
284
- // rather than written into it, so a refreshed unread count never looks like a change of folders to the list on screen
285
- // (which reloads, and forgets its selection, whenever the folders do).
286
- const { live, unreadCounts } = useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated });
287
-
288
- const contextValue = useMemo<MailShellContextValue>(
289
- () => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live }),
290
- [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live],
291
- );
292
-
293
- // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else for a
294
- // mailbox-less caller to do here yet, so the icon rail/header/folder tree don't render at all. Checks
295
- // `mailboxes.length` directly (not `!mailboxUid`) since `mailboxUid` is legitimately undefined in
296
- // aggregate mode even with mailboxes present.
297
- if (userUid && status === "ready" && mailboxes.length === 0) {
298
- return <MailboxProvisioning />;
299
- }
300
-
301
- let inner: ReactNode = null;
302
- if (userUid && status === "checking") {
303
- // Renders immediately (no network round trip needed) so switching into Mail never shows a blank
304
- // pane while `listMailboxes()` is in flight — the outline below mirrors the real sidebar's shape.
305
- inner = (
306
- <aside className="w-64 shrink-0 bg-surface border-r border-border flex flex-col p-3 gap-4">
307
- <Skeleton height="h-9" className="rounded-sm" />
308
- <SkeletonList count={6} />
309
- </aside>
310
- );
311
- } else if (userUid && status === "error") {
312
- inner = (
313
- <div className="flex-1 flex items-center justify-center p-8">
314
- <div className="w-full max-w-md">
315
- <Alert>{error}</Alert>
316
- </div>
317
- </div>
318
- );
319
- } else if (userUid && status === "ready") {
320
- // A function, not a plain JSX constant — it's rendered twice (desktop `<aside>` + mobile
321
- // `Drawer`), possibly *simultaneously* mounted (the aside is only CSS-hidden below `md`, not
322
- // unmounted).
323
- const sidebarContent = () => (
324
- <>
325
- <div className="p-3">
326
- <ComposeButton />
327
- </div>
328
- {foldersLoading ? (
329
- <div className="flex-1 overflow-y-auto px-3 pb-3">
330
- <SkeletonList count={6} className="pt-1" />
331
- </div>
332
- ) : (
333
- <nav className="flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-3">
334
- {mailboxes.length > 1 && (
335
- <div>
336
- <div className="text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5">
337
- All Mailboxes
338
- </div>
339
- <div className="flex flex-col gap-0.5">
340
- {AGGREGATE_FOLDER_TYPES.map((type) => {
341
- const unread = aggregateUnreadCount(mailboxFolders, type, unreadCounts);
342
- return (
343
- <a
344
- key={type}
345
- href={`/?aggregate=${encodeURIComponent(type)}`}
346
- className={[
347
- "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
348
- aggregateFolderType === type
349
- ? "bg-primary/10 text-primary-dark font-semibold"
350
- : "text-text hover:bg-surface-alt",
351
- ].join(" ")}
352
- >
353
- <span>{FOLDER_LABELS[type]}</span>
354
- {unread > 0 && (
355
- <span className="text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted">
356
- {unread}
357
- </span>
358
- )}
359
- </a>
360
- );
361
- })}
362
- </div>
363
- </div>
364
- )}
365
- {mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (
366
- <div key={mailbox.uid}>
367
- <div className="text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5 truncate">
368
- {mailbox.displayName}
369
- {mailbox.ownerUserUid ? "" : " (shared)"}
370
- </div>
371
- {mailboxError && (
372
- <div className="px-2.5 pb-1">
373
- <Alert>{mailboxError}</Alert>
374
- </div>
375
- )}
376
- <div className="flex flex-col gap-0.5">
377
- {sortedFoldersOf(folders).map((folder) => {
378
- const unread = unreadOf(folder, unreadCounts);
379
- return (
380
- <a
381
- key={folder.uid}
382
- href={`/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`}
383
- className={[
384
- "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
385
- folder.uid === folderUid
386
- ? "bg-primary/10 text-primary-dark font-semibold"
387
- : "text-text hover:bg-surface-alt",
388
- ].join(" ")}
389
- >
390
- <span>{FOLDER_LABELS[folder.type] ?? folder.name}</span>
391
- {unread > 0 && (
392
- <span className="text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted">
393
- {unread}
394
- </span>
395
- )}
396
- </a>
397
- );
398
- })}
399
- </div>
400
- </div>
401
- ))}
402
- </nav>
403
- )}
404
- </>
405
- );
406
-
407
- inner = (
408
- <>
409
- <aside className="hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col">{sidebarContent()}</aside>
410
- <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Folders">
411
- <div className="flex flex-col">{sidebarContent()}</div>
412
- </Drawer>
413
- <main className="flex-1 min-w-0 overflow-y-auto">
414
- <button
415
- type="button"
416
- className="md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text"
417
- aria-label="Open folders"
418
- onClick={() => setDrawerOpen(true)}
419
- >
420
- <HiOutlineBars3 size={20} aria-hidden="true" />
421
- </button>
422
- <MailShellContext.Provider value={contextValue}>{children}</MailShellContext.Provider>
423
- </main>
424
- </>
425
- );
426
- }
427
-
428
- // Wraps AppShell unconditionally, at a stable tree position regardless of whether mailboxUid has
429
- // resolved yet - KeyEnrollmentGate itself passes `children` through untouched until a real
430
- // mailboxUid is supplied (see its own doc comment). Wrapping only once ready (a conditional tree
431
- // position) would make AppShell itself remount the moment mailboxUid resolves, tearing down
432
- // whatever state/effects it had already started (confirmed by direct reproduction: the
433
- // impersonation banner's own internal state was lost exactly at that transition).
434
- const activeMailbox = mailboxes.find((mb) => mb.uid === activeMailboxUid);
435
- return (
436
- <KeyEnrollmentGate
437
- mailboxUid={activeMailboxUid}
438
- mailboxAddress={activeMailbox?.primarySmtpAddress}
439
- mailboxKeys={activeMailbox?.keys}
440
- // Unlocking is only actually required to sign/encrypt a compose, read an already-encrypted
441
- // message, or change encryption settings - not merely to open Mail. Those specific call sites
442
- // (ComposeWindow, MessageDetailPane) request an unlock on demand via useUnlockPrompt() instead.
443
- // First-time provisioning (a mailbox with no vault at all yet) still always blocks - see this
444
- // prop's own doc comment on KeyEnrollmentGateProps.
445
- blocking={false}
446
- // A shared/delegated mailbox the caller doesn't own is never provisioned from here - its keys
447
- // belong to its owner (or its admins), not to whoever happens to open it first - and neither is
448
- // the impersonated user's own mailbox: an administrator must never choose its password/recovery codes.
449
- canProvision={!impersonating && activeMailbox?.ownerUserUid === userUid}
450
- >
451
- <LocalIndexLifecycle
452
- mailboxUid={activeMailboxUid}
453
- mailboxKeys={activeMailbox?.keys}
454
- folders={mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? []}
455
- // A full page may be truncated - pruning against it would delete indexes of accessible mailboxes
456
- // beyond the limit, so it's only trusted as the complete list when it came back short.
457
- accessibleMailboxUids={status === "ready" && mailboxes.length < MAILBOX_LIST_LIMIT ? mailboxes.map((mb) => mb.uid) : undefined}
458
- />
459
- <AppShell
460
- active="mail"
461
- userUid={userUid}
462
- authServerUrl={authServerUrl}
463
- impersonating={impersonating}
464
- impersonationBaseUrl={impersonationBaseUrl}
465
- trusted={trusted}
466
- pluginNav={pluginNav}
467
- >
468
- {inner}
469
- </AppShell>
470
- </KeyEnrollmentGate>
471
- );
472
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from "react";
6
+ import { HiOutlineBars3 } from "react-icons/hi2";
7
+ import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
8
+ import { Folder, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
9
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
+ import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
11
+ import AppShell, { AppShellProps } from "../../layout/AppShell.js";
12
+ import { useLocationSearch, useNavigate } from "../../../navigation/AppRouter.js";
13
+ import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
14
+ import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
15
+ import { prefetchComposeWindow, useCompose } from "../compose/ComposeContext.js";
16
+ import LocalIndexLifecycle from "../../../search/LocalIndexLifecycle.js";
17
+ import { LiveUpdates, NO_LIVE_UPDATES } from "../../../mail/useMailLiveUpdates.js";
18
+ import { FOLDER_ORDER, MAILBOX_LIST_LIMIT, MailConnectionContext, useMailConnection } from "../../../mail/useMailConnection.js";
19
+ import {
20
+ CountTracker,
21
+ FolderBadge,
22
+ FolderCount,
23
+ badgeFor,
24
+ badgeLabel,
25
+ countOfFolder,
26
+ inboxUnreadTotal,
27
+ } from "../../../mail/folderCounts.js";
28
+ import { useUnreadTitle } from "../../../mail/useUnreadTitle.js";
29
+ import NewMailToasts from "../NewMailToasts.js";
30
+ import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
31
+ import { SHORTCUTS } from "../../../keyboard/keymap.js";
32
+ import { useKeyEnvironment } from "../../../keyboard/ShortcutProvider.js";
33
+ import { useShortcut } from "../../../keyboard/useShortcut.js";
34
+
35
+ export type MailShellProps = Omit<AppShellProps, "active">;
36
+
37
+ /** One mailbox's own mail folders (already filtered to `MAIL_FOLDER_TYPES` and unsorted) - one entry per
38
+ * mailbox in `mailboxes`, fetched in parallel so every accessible mailbox's folder tree can render
39
+ * simultaneously (see `MailShell`'s own doc comment on why this replaced the old single-mailbox `folders`
40
+ * field). `error` is set (and `folders` left empty) for a mailbox whose own `listFolders()` call failed -
41
+ * one mailbox's fetch failure must never blank out every other mailbox's section. */
42
+ export interface MailboxFolders {
43
+ mailbox: Mailbox;
44
+ folders: Folder[];
45
+ error?: string;
46
+ }
47
+
48
+ /** The well-known mail folder types an "aggregate" pseudo-folder can merge across every accessible
49
+ * mailbox - a fixed, small set: `outbox` is deliberately excluded (transient per-mailbox send-queue
50
+ * state, not a "merge across mailboxes" concept), and there's no aggregate concept for `calendar`/
51
+ * `contacts`/`tasks`/`notes`/`user` folders (Mail's own sidebar already excludes those - see
52
+ * `MAIL_FOLDER_TYPES`). */
53
+ export const AGGREGATE_FOLDER_TYPES = ["inbox", "sent_items", "drafts", "deleted_items", "junk"] as const;
54
+ export type AggregateFolderType = (typeof AGGREGATE_FOLDER_TYPES)[number];
55
+
56
+ function isAggregateFolderType(value: string | null): value is AggregateFolderType {
57
+ return !!value && (AGGREGATE_FOLDER_TYPES as readonly string[]).includes(value);
58
+ }
59
+
60
+ export interface MailShellContextValue {
61
+ /** The mailbox currently selected (`?mailboxUid=`) - `undefined` while an aggregate pseudo-folder is
62
+ * active instead (`aggregateFolderType` set), since there's no single mailbox to speak of then. */
63
+ mailboxUid?: string;
64
+ /** The folder currently selected (`?folderUid=`, or the selected mailbox's Inbox) - `undefined` in
65
+ * aggregate mode, for the same reason. */
66
+ folderUid?: string;
67
+ /** Set only when an aggregate pseudo-folder (`?aggregate=inbox` etc.) is selected instead of a real,
68
+ * single mailbox+folder - mutually exclusive with `mailboxUid`/`folderUid` above. */
69
+ aggregateFolderType?: AggregateFolderType;
70
+ mailboxes: Mailbox[];
71
+ /** Every accessible mailbox's own mail folders - replaces the old single-mailbox `folders: Folder[]`
72
+ * now that every mailbox's tree renders at once (see `MailboxFolders`'s own doc comment). */
73
+ mailboxFolders: MailboxFolders[];
74
+ /**
75
+ * Adds a folder a page has just created (the Move to prompt's "New folder") to the tree this shell
76
+ * already fetched, so it appears in the sidebar and in every folder picker without a reload - this
77
+ * framework has no client-side router, so a reload is a whole page load.
78
+ *
79
+ * A no-op on the default context value, which is only ever read outside a real shell.
80
+ */
81
+ onFolderCreated: (folder: Folder) => void;
82
+ /**
83
+ * Bumped whenever new mail (or another change to a message) may have arrived - a push event, a reconnect or the safety-net
84
+ * poll - so the list on screen can quietly refetch its first page. See `useMailLiveUpdates()`. Never changes on the default
85
+ * context value, which is only ever read outside a real shell.
86
+ */
87
+ live: LiveUpdates;
88
+ /**
89
+ * Applies a change to a message (`previous` to `next`; `next` absent when it was deleted) to the folder badges at once, and
90
+ * hands back what to call when the server has answered: `settle()` keeps it, `revert()` takes it back. Every place that
91
+ * reads, moves or deletes a message goes through this (see `setReadState()`), so the badges follow. A no-op on the default
92
+ * context value, which is only ever read outside a real shell.
93
+ */
94
+ trackMessageChange: (previous: Message, next: Message | null) => CountTracker;
95
+ }
96
+
97
+ const NO_TRACKER: CountTracker = { settle: () => undefined, revert: () => undefined };
98
+
99
+ const MailShellContext = createContext<MailShellContextValue>({
100
+ mailboxes: [],
101
+ mailboxFolders: [],
102
+ onFolderCreated: () => undefined,
103
+ live: NO_LIVE_UPDATES,
104
+ trackMessageChange: () => NO_TRACKER,
105
+ });
106
+
107
+ /** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
108
+ export function useMailShell(): MailShellContextValue {
109
+ return useContext(MailShellContext);
110
+ }
111
+
112
+ const FOLDER_LABELS: Record<string, string> = {
113
+ inbox: "Inbox",
114
+ sent_items: "Sent Items",
115
+ drafts: "Drafts",
116
+ outbox: "Outbox",
117
+ junk: "Junk Email",
118
+ archive: "Archive",
119
+ deleted_items: "Deleted Items",
120
+ };
121
+
122
+ function folderSortKey(folder: Folder): number {
123
+ const idx = FOLDER_ORDER.indexOf(folder.type);
124
+ return idx === -1 ? FOLDER_ORDER.length : idx;
125
+ }
126
+
127
+ function sortedFoldersOf(folders: Folder[]): Folder[] {
128
+ return [...folders].sort((a, b) => folderSortKey(a) - folderSortKey(b) || a.name.localeCompare(b.name));
129
+ }
130
+
131
+ /** The badge an "All Mailboxes" entry shows: the folders of that type, summed across every mailbox, under the same rules as a single folder's. */
132
+ function aggregateBadge(mailboxFolders: MailboxFolders[], type: AggregateFolderType, counts: Record<string, FolderCount>): FolderBadge | undefined {
133
+ const sum = mailboxFolders.reduce(
134
+ (total, mf) => {
135
+ const folder = mf.folders.find((f) => f.type === type);
136
+ const count = folder ? countOfFolder(folder, counts) : { unread: 0, total: 0 };
137
+ return { unread: total.unread + count.unread, total: total.total + count.total };
138
+ },
139
+ { unread: 0, total: 0 },
140
+ );
141
+ return badgeFor(type, sum);
142
+ }
143
+
144
+ /** A folder's badge: an accent pill with the unread count, or - for the folders that show how many they hold - plain muted text. */
145
+ function FolderBadgeChip({ badge }: { badge: FolderBadge }) {
146
+ return (
147
+ <span
148
+ className={[
149
+ "text-xs rounded-pill py-0.5 px-1.5",
150
+ badge.kind === "unread" ? "font-bold bg-primary/15 text-primary-dark" : "font-medium text-text-muted",
151
+ ].join(" ")}
152
+ >
153
+ <span aria-hidden="true">{badge.value}</span>
154
+ <span className="sr-only"> {badgeLabel(badge)}</span>
155
+ </span>
156
+ );
157
+ }
158
+
159
+ type Status = "checking" | "error" | "ready";
160
+
161
+ export { MAILBOX_LIST_LIMIT };
162
+
163
+ /**
164
+ * A separate component (not inlined into `MailShell`'s own render) so `useCompose()` resolves against
165
+ * `ComposeProvider` correctly: that provider is rendered *inside* the `AppShell` that `MailShell` itself
166
+ * returns, i.e. a descendant of `MailShell`, not an ancestor — a hook call made directly in `MailShell`'s
167
+ * own function body would see only whatever context exists *above* `MailShell`, never a provider one of
168
+ * its own descendants creates. This button, rendered as part of `AppShell`'s `children`, sits correctly
169
+ * inside that subtree.
170
+ *
171
+ * Opens with the caller's own mailbox (the shell has already listed them), so the window can start on its
172
+ * signature, draft and encryption lookups straight away instead of first asking which mailbox that is; the
173
+ * compose window's own From field is where the sender is chosen. Fetches the window's code as the pointer or
174
+ * keyboard reaches the button, so the click finds it already here.
175
+ */
176
+ function ComposeButton({ mailboxUid }: { mailboxUid?: string }) {
177
+ const { openCompose } = useCompose();
178
+ const env = useKeyEnvironment();
179
+ return (
180
+ <button
181
+ type="button"
182
+ title={withHint("Compose", SHORTCUTS.mail.create, env)}
183
+ aria-keyshortcuts={ariaKeyShortcuts(SHORTCUTS.mail.create, env)}
184
+ onClick={() => openCompose({ mailboxUid })}
185
+ onPointerEnter={prefetchComposeWindow}
186
+ onFocus={prefetchComposeWindow}
187
+ className="block text-center w-full py-2.5 px-4 rounded-sm font-semibold text-sm bg-primary text-white hover:bg-primary-dark"
188
+ >
189
+ Compose
190
+ </button>
191
+ );
192
+ }
193
+
194
+ /**
195
+ * Mail's keyboard shortcuts that don't belong to a list or a message: "New message". A component of its own for the reason `ComposeButton`
196
+ * is - `useCompose()` only resolves below `ComposeProvider`, i.e. inside the `AppShell` this shell renders - and mounted once, whatever number
197
+ * of Compose buttons (the sidebar, the folders drawer) are on screen. Opens the same window, for the same mailbox, as the button.
198
+ */
199
+ function MailShortcuts({ mailboxUid }: { mailboxUid?: string }) {
200
+ const { openCompose } = useCompose();
201
+ useShortcut(SHORTCUTS.mail.create, () => openCompose({ mailboxUid }));
202
+ return null;
203
+ }
204
+
205
+ /**
206
+ * Mail's own contextual sidebar (every accessible mailbox's own folder tree, plus a merged "All Mailboxes"
207
+ * aggregate section) + content area, rendered inside the shared `AppShell` chrome (icon rail, header,
208
+ * impersonation banner — see that component). There is no client-side router in this framework (see
209
+ * `ReactRoute`'s file-convention resolver) — the selected mailbox/folder (or aggregate pseudo-folder) live
210
+ * in the URL's `?mailboxUid=`/`?folderUid=`/`?aggregate=` query params, read once on mount (never during
211
+ * the initial render itself, matching every other query-param reader in this codebase — e.g.
212
+ * `apps/admin/quarantine`'s `readMailboxUid()` — so the server-rendered and just-hydrated client markup
213
+ * match).
214
+ *
215
+ * Every accessible mailbox's folder tree renders at once - there is no "switch mailbox" affordance
216
+ * anymore (the mailbox `<select>` this shell used to have is gone) - so a single-mailbox user sees zero
217
+ * behavior change from before this, and a multi-mailbox user sees every mailbox's folders (and the new
218
+ * aggregate section) simultaneously, matching the shared-mailbox feature's own "display as a separate set
219
+ * of folders" requirement.
220
+ */
221
+ export default function MailShell({
222
+ userUid,
223
+ authServerUrl,
224
+ impersonating,
225
+ impersonationBaseUrl,
226
+ trusted,
227
+ trustedRoles,
228
+ pluginNav,
229
+ children,
230
+ }: PropsWithChildren<MailShellProps>) {
231
+ // The mailboxes, their folders and the live connection come from the persistent app frame when there is one (`AppChrome` owns them, so
232
+ // they survive Mail -> Calendar -> Mail and the push socket, pop-ups, counters and tab title work in every app). A page rendered
233
+ // outside the frame - a test, a plugin page - has no frame to ask, so this shell runs the same hook for itself, and renders the pop-ups.
234
+ const navigate = useNavigate();
235
+ const hosted = useContext(MailConnectionContext);
236
+ const own = useMailConnection({ userUid, enabled: !hosted, open: navigate });
237
+ const { status, error, mailboxes, mailboxFolders, foldersLoading, onFolderCreated, live, folderCounts, notifications } = hosted ?? own;
238
+ const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
239
+ const [requestedFolderUid, setRequestedFolderUid] = useState<string | null>(null);
240
+ const [requestedAggregateType, setRequestedAggregateType] = useState<string | null>(null);
241
+ const [drawerOpen, setDrawerOpen] = useState(false);
242
+
243
+ // The selection lives in the URL, and the router (`AppRouter`) changes the URL without a page load when a folder link is
244
+ // clicked - so it is read from the router's location, which updates, rather than once from `window.location`. Still read
245
+ // in an effect, never during the first render, so the server render and the hydrating render agree.
246
+ const search = useLocationSearch();
247
+ useEffect(() => {
248
+ const params = new URLSearchParams(search);
249
+ setRequestedMailboxUid(params.get("mailboxUid"));
250
+ setRequestedFolderUid(params.get("folderUid"));
251
+ setRequestedAggregateType(params.get("aggregate"));
252
+ }, [search]);
253
+ // Choosing a folder (or mailbox) changes the URL without a page load now, so the drawer that held the choice - which used to go
254
+ // with the page - is closed here.
255
+ useEffect(() => {
256
+ setDrawerOpen(false);
257
+ }, [search]);
258
+
259
+ const aggregateFolderType: AggregateFolderType | undefined = isAggregateFolderType(requestedAggregateType)
260
+ ? requestedAggregateType
261
+ : undefined;
262
+
263
+ const mailboxUid: string | undefined = aggregateFolderType
264
+ ? undefined
265
+ : (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
266
+ mailboxes[0]?.uid;
267
+
268
+ const selectedMailboxFolders = mailboxFolders.find((mf) => mf.mailbox.uid === mailboxUid)?.folders ?? [];
269
+ const folderUid: string | undefined = aggregateFolderType
270
+ ? undefined
271
+ : (requestedFolderUid && selectedMailboxFolders.some((f) => f.uid === requestedFolderUid) ? requestedFolderUid : undefined) ??
272
+ selectedMailboxFolders.find((f) => f.type === "inbox")?.uid;
273
+
274
+ // The mailbox `KeyEnrollmentGate`/`LocalIndexLifecycle`/`ComposeButton` treat as "the" mailbox when
275
+ // there's no single selected one to use (aggregate mode) - the caller's own owned mailbox if they
276
+ // have one, else whichever accessible mailbox happens to be first. See MailShell's own doc comment on
277
+ // this being an accepted limitation: an aggregate-view message from a *different*, not-yet-visited
278
+ // mailbox may still need that mailbox's own folder view opened directly to unlock/decrypt it.
279
+ const defaultMailboxUid = mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
280
+ const activeMailboxUid = mailboxUid ?? defaultMailboxUid;
281
+
282
+ const counts = folderCounts.counts;
283
+ const trackMessageChange = folderCounts.track;
284
+ // `(3) Acme: Mail` in the tab strip while there is unread mail in an Inbox - kept by the frame when there is one.
285
+ useUnreadTitle(inboxUnreadTotal(mailboxFolders, counts), { enabled: !hosted });
286
+
287
+ const contextValue = useMemo<MailShellContextValue>(
288
+ () => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live, trackMessageChange }),
289
+ [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live, trackMessageChange],
290
+ );
291
+
292
+ // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else for a
293
+ // mailbox-less caller to do here yet, so the icon rail/header/folder tree don't render at all. Checks
294
+ // `mailboxes.length` directly (not `!mailboxUid`) since `mailboxUid` is legitimately undefined in
295
+ // aggregate mode even with mailboxes present.
296
+ if (userUid && status === "ready" && mailboxes.length === 0) {
297
+ return <MailboxProvisioning />;
298
+ }
299
+
300
+ let inner: ReactNode = null;
301
+ if (userUid && status === "checking") {
302
+ // Renders immediately (no network round trip needed) so switching into Mail never shows a blank
303
+ // pane while `listMailboxes()` is in flight — the outline below mirrors the real sidebar's shape.
304
+ inner = (
305
+ <aside className="w-64 shrink-0 bg-surface border-r border-border flex flex-col p-3 gap-4">
306
+ <Skeleton height="h-9" className="rounded-sm" />
307
+ <SkeletonList count={6} />
308
+ </aside>
309
+ );
310
+ } else if (userUid && status === "error") {
311
+ inner = (
312
+ <div className="flex-1 flex items-center justify-center p-8">
313
+ <div className="w-full max-w-md">
314
+ <Alert>{error}</Alert>
315
+ </div>
316
+ </div>
317
+ );
318
+ } else if (userUid && status === "ready") {
319
+ // A function, not a plain JSX constant — it's rendered twice (desktop `<aside>` + mobile
320
+ // `Drawer`), possibly *simultaneously* mounted (the aside is only CSS-hidden below `md`, not
321
+ // unmounted).
322
+ const sidebarContent = () => (
323
+ <>
324
+ <div className="p-3">
325
+ <ComposeButton mailboxUid={defaultMailboxUid} />
326
+ </div>
327
+ {foldersLoading ? (
328
+ <div className="flex-1 overflow-y-auto px-3 pb-3">
329
+ <SkeletonList count={6} className="pt-1" />
330
+ </div>
331
+ ) : (
332
+ <nav className="flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-3">
333
+ {mailboxes.length > 1 && (
334
+ <div>
335
+ <div className="text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5">
336
+ All Mailboxes
337
+ </div>
338
+ <div className="flex flex-col gap-0.5">
339
+ {AGGREGATE_FOLDER_TYPES.map((type) => {
340
+ const badge = aggregateBadge(mailboxFolders, type, counts);
341
+ return (
342
+ <a
343
+ key={type}
344
+ href={`/?aggregate=${encodeURIComponent(type)}`}
345
+ className={[
346
+ "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
347
+ aggregateFolderType === type
348
+ ? "bg-primary/10 text-primary-dark font-semibold"
349
+ : "text-text hover:bg-surface-alt",
350
+ ].join(" ")}
351
+ >
352
+ <span className={badge?.kind === "unread" ? "font-semibold" : undefined}>{FOLDER_LABELS[type]}</span>
353
+ {badge && <FolderBadgeChip badge={badge} />}
354
+ </a>
355
+ );
356
+ })}
357
+ </div>
358
+ </div>
359
+ )}
360
+ {mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (
361
+ <div key={mailbox.uid}>
362
+ <div className="text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5 truncate">
363
+ {mailbox.displayName}
364
+ {mailbox.ownerUserUid ? "" : " (shared)"}
365
+ </div>
366
+ {mailboxError && (
367
+ <div className="px-2.5 pb-1">
368
+ <Alert>{mailboxError}</Alert>
369
+ </div>
370
+ )}
371
+ <div className="flex flex-col gap-0.5">
372
+ {sortedFoldersOf(folders).map((folder) => {
373
+ const badge = badgeFor(folder.type, countOfFolder(folder, counts));
374
+ return (
375
+ <a
376
+ key={folder.uid}
377
+ href={`/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`}
378
+ className={[
379
+ "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
380
+ folder.uid === folderUid
381
+ ? "bg-primary/10 text-primary-dark font-semibold"
382
+ : "text-text hover:bg-surface-alt",
383
+ ].join(" ")}
384
+ >
385
+ <span className={badge?.kind === "unread" ? "font-semibold" : undefined}>
386
+ {FOLDER_LABELS[folder.type] ?? folder.name}
387
+ </span>
388
+ {badge && <FolderBadgeChip badge={badge} />}
389
+ </a>
390
+ );
391
+ })}
392
+ </div>
393
+ </div>
394
+ ))}
395
+ </nav>
396
+ )}
397
+ </>
398
+ );
399
+
400
+ inner = (
401
+ <>
402
+ <MailShortcuts mailboxUid={defaultMailboxUid} />
403
+ <aside className="hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col">{sidebarContent()}</aside>
404
+ <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Folders">
405
+ <div className="flex flex-col">{sidebarContent()}</div>
406
+ </Drawer>
407
+ <main className="flex-1 min-w-0 overflow-y-auto">
408
+ <button
409
+ type="button"
410
+ className="md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text"
411
+ aria-label="Open folders"
412
+ onClick={() => setDrawerOpen(true)}
413
+ >
414
+ <HiOutlineBars3 size={20} aria-hidden="true" />
415
+ </button>
416
+ <MailShellContext.Provider value={contextValue}>{children}</MailShellContext.Provider>
417
+ </main>
418
+ {!hosted && (
419
+ <NewMailToasts
420
+ toasts={notifications.toasts}
421
+ onDismiss={notifications.dismiss}
422
+ offerDesktop={notifications.offerDesktop}
423
+ onEnableDesktop={() => void notifications.enableDesktop()}
424
+ onDeclineDesktop={notifications.declineDesktop}
425
+ />
426
+ )}
427
+ </>
428
+ );
429
+ }
430
+
431
+ // Wraps AppShell unconditionally, at a stable tree position regardless of whether mailboxUid has
432
+ // resolved yet - KeyEnrollmentGate itself passes `children` through untouched until a real
433
+ // mailboxUid is supplied (see its own doc comment). Wrapping only once ready (a conditional tree
434
+ // position) would make AppShell itself remount the moment mailboxUid resolves, tearing down
435
+ // whatever state/effects it had already started (confirmed by direct reproduction: the
436
+ // impersonation banner's own internal state was lost exactly at that transition).
437
+ const activeMailbox = mailboxes.find((mb) => mb.uid === activeMailboxUid);
438
+ return (
439
+ <KeyEnrollmentGate
440
+ mailboxUid={activeMailboxUid}
441
+ mailboxAddress={activeMailbox?.primarySmtpAddress}
442
+ mailboxKeys={activeMailbox?.keys}
443
+ // Unlocking is only actually required to sign/encrypt a compose, read an already-encrypted
444
+ // message, or change encryption settings - not merely to open Mail. Those specific call sites
445
+ // (ComposeWindow, MessageDetailPane) request an unlock on demand via useUnlockPrompt() instead.
446
+ // First-time provisioning (a mailbox with no vault at all yet) still always blocks - see this
447
+ // prop's own doc comment on KeyEnrollmentGateProps.
448
+ blocking={false}
449
+ // A shared/delegated mailbox the caller doesn't own is never provisioned from here - its keys
450
+ // belong to its owner (or its admins), not to whoever happens to open it first - and neither is
451
+ // the impersonated user's own mailbox: an administrator must never choose its password/recovery codes.
452
+ canProvision={!impersonating && activeMailbox?.ownerUserUid === userUid}
453
+ >
454
+ <LocalIndexLifecycle
455
+ mailboxUid={activeMailboxUid}
456
+ mailboxKeys={activeMailbox?.keys}
457
+ folders={mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? []}
458
+ // A full page may be truncated - pruning against it would delete indexes of accessible mailboxes
459
+ // beyond the limit, so it's only trusted as the complete list when it came back short.
460
+ accessibleMailboxUids={status === "ready" && mailboxes.length < MAILBOX_LIST_LIMIT ? mailboxes.map((mb) => mb.uid) : undefined}
461
+ />
462
+ <AppShell
463
+ active="mail"
464
+ userUid={userUid}
465
+ authServerUrl={authServerUrl}
466
+ impersonating={impersonating}
467
+ impersonationBaseUrl={impersonationBaseUrl}
468
+ trusted={trusted}
469
+ trustedRoles={trustedRoles}
470
+ pluginNav={pluginNav}
471
+ >
472
+ {inner}
473
+ </AppShell>
474
+ </KeyEnrollmentGate>
475
+ );
476
+ }