@rapidmx/web-client 0.2.0 → 0.3.1

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 (59) hide show
  1. package/apps/admin/data-requests/index.tsx +485 -0
  2. package/apps/admin/escrow-scopes/[uid].tsx +237 -0
  3. package/apps/admin/escrow-scopes/index.tsx +124 -0
  4. package/apps/admin/escrow-scopes/new/index.tsx +128 -0
  5. package/apps/admin/mailboxes/[uid].tsx +66 -8
  6. package/apps/admin/retention-policy/index.tsx +132 -0
  7. package/apps/escrow/_layout.tsx +41 -0
  8. package/apps/escrow/audit-log/index.tsx +168 -0
  9. package/apps/escrow/index.tsx +139 -0
  10. package/apps/escrow/matters/[uid].tsx +521 -0
  11. package/apps/escrow/matters/new/index.tsx +173 -0
  12. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +171 -0
  13. package/apps/shared/components/admin/layout/AdminShell.tsx +24 -0
  14. package/apps/shared/components/contacts/ContactDetailPane.tsx +44 -0
  15. package/apps/shared/components/escrow/layout/EscrowShell.tsx +134 -0
  16. package/apps/shared/components/forms/StringListField.tsx +104 -0
  17. package/apps/shared/components/layout/AppShell.tsx +4 -0
  18. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +265 -0
  19. package/apps/shared/components/mail/ConversationThreadPane.tsx +8 -1
  20. package/apps/shared/components/mail/MessageDetailPane.tsx +214 -9
  21. package/apps/shared/components/mail/compose/ComposeContext.tsx +10 -1
  22. package/apps/shared/components/mail/compose/ComposeWindow.tsx +271 -24
  23. package/apps/shared/components/mail/layout/MailShell.tsx +302 -287
  24. package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -0
  25. package/apps/www/index.tsx +122 -11
  26. package/apps/www/messages/[uid].tsx +15 -1
  27. package/apps/www/settings/encryption/index.tsx +664 -0
  28. package/apps/www/settings/labels/index.tsx +201 -0
  29. package/apps/www/settings/privacy/index.tsx +436 -0
  30. package/dist/apps/admin/data-requests/index.js +197 -0
  31. package/dist/apps/admin/escrow-scopes/[uid].js +141 -0
  32. package/dist/apps/admin/escrow-scopes/index.js +31 -0
  33. package/dist/apps/admin/escrow-scopes/new/index.js +69 -0
  34. package/dist/apps/admin/mailboxes/[uid].js +26 -2
  35. package/dist/apps/admin/retention-policy/index.js +65 -0
  36. package/dist/apps/escrow/_layout.js +10 -0
  37. package/dist/apps/escrow/audit-log/index.js +66 -0
  38. package/dist/apps/escrow/index.js +31 -0
  39. package/dist/apps/escrow/matters/[uid].js +234 -0
  40. package/dist/apps/escrow/matters/new/index.js +75 -0
  41. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +26 -0
  42. package/dist/apps/shared/components/admin/layout/AdminShell.js +19 -1
  43. package/dist/apps/shared/components/contacts/ContactDetailPane.js +10 -1
  44. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +75 -0
  45. package/dist/apps/shared/components/forms/StringListField.js +39 -0
  46. package/dist/apps/shared/components/layout/AppShell.js +4 -0
  47. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +142 -0
  48. package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
  49. package/dist/apps/shared/components/mail/MessageDetailPane.js +105 -6
  50. package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -1
  51. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +184 -21
  52. package/dist/apps/shared/components/mail/layout/MailShell.js +11 -2
  53. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -0
  54. package/dist/apps/www/index.js +110 -10
  55. package/dist/apps/www/messages/[uid].js +12 -2
  56. package/dist/apps/www/settings/encryption/index.js +330 -0
  57. package/dist/apps/www/settings/labels/index.js +95 -0
  58. package/dist/apps/www/settings/privacy/index.js +167 -0
  59. package/package.json +9 -3
@@ -1,287 +1,302 @@
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 { 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 MailboxProvisioning from "../../layout/MailboxProvisioning.js";
14
- import { useCompose } from "../compose/ComposeContext.js";
15
-
16
- export type MailShellProps = Omit<AppShellProps, "active">;
17
-
18
- export interface MailShellContextValue {
19
- /** The mailbox currently selected (`?mailboxUid=`, or the caller's first accessible mailbox). */
20
- mailboxUid?: string;
21
- /** The folder currently selected (`?folderUid=`, or the selected mailbox's Inbox). */
22
- folderUid?: string;
23
- mailboxes: Mailbox[];
24
- folders: Folder[];
25
- }
26
-
27
- const MailShellContext = createContext<MailShellContextValue>({ mailboxes: [], folders: [] });
28
-
29
- /** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
30
- export function useMailShell(): MailShellContextValue {
31
- return useContext(MailShellContext);
32
- }
33
-
34
- const FOLDER_LABELS: Record<string, string> = {
35
- inbox: "Inbox",
36
- sent_items: "Sent Items",
37
- drafts: "Drafts",
38
- outbox: "Outbox",
39
- junk: "Junk Email",
40
- deleted_items: "Deleted Items",
41
- };
42
-
43
- /** Well-known folders sort first, in Gmail/Outlook's conventional order; anything else (incl. `user`) sorts after, alphabetically. */
44
- const FOLDER_ORDER = ["inbox", "drafts", "outbox", "sent_items", "junk", "deleted_items"];
45
-
46
- /**
47
- * A mailbox's `calendar`/`contacts`/`tasks`/`notes` folders back their own dedicated apps (see
48
- * `CalendarShell`/`ContactsShell`/`TasksShell`), not Mail — `listFolders()` returns every well-known
49
- * folder for the mailbox regardless of which app owns it, so Mail's own folder tree must filter down
50
- * to just the mail ones itself, or those other apps' folders leak into this sidebar.
51
- */
52
- const MAIL_FOLDER_TYPES = new Set([...FOLDER_ORDER, "user"]);
53
-
54
- function folderSortKey(folder: Folder): number {
55
- const idx = FOLDER_ORDER.indexOf(folder.type);
56
- return idx === -1 ? FOLDER_ORDER.length : idx;
57
- }
58
-
59
- type Status = "checking" | "error" | "ready";
60
-
61
- /**
62
- * A separate component (not inlined into `MailShell`'s own render) so `useCompose()` resolves against
63
- * `ComposeProvider` correctly: that provider is rendered *inside* the `AppShell` that `MailShell` itself
64
- * returns, i.e. a descendant of `MailShell`, not an ancestor — a hook call made directly in `MailShell`'s
65
- * own function body would see only whatever context exists *above* `MailShell`, never a provider one of
66
- * its own descendants creates. This button, rendered as part of `AppShell`'s `children`, sits correctly
67
- * inside that subtree.
68
- */
69
- function ComposeButton({ mailboxUid }: { mailboxUid: string }) {
70
- const { openCompose } = useCompose();
71
- return (
72
- <button
73
- type="button"
74
- onClick={() => openCompose({ mailboxUid })}
75
- 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"
76
- >
77
- Compose
78
- </button>
79
- );
80
- }
81
-
82
- /**
83
- * Mail's own contextual sidebar (mailbox switcher + folder tree) + content area, rendered inside the shared
84
- * `AppShell` chrome (icon rail, header, impersonation banner — see that component). There is no client-side
85
- * router in this framework (see `ReactRoute`'s file-convention resolver) — the selected mailbox/folder live
86
- * in the URL's `?mailboxUid=`/`?folderUid=` query params, read once on mount (never during the initial
87
- * render itself, matching every other query-param reader in this codebase — e.g. `apps/admin/quarantine`'s
88
- * `readMailboxUid()` — so the server-rendered and just-hydrated client markup match).
89
- */
90
- export default function MailShell({
91
- userUid,
92
- authServerUrl,
93
- impersonating,
94
- impersonationBaseUrl,
95
- trusted,
96
- children,
97
- }: PropsWithChildren<MailShellProps>) {
98
- const [status, setStatus] = useState<Status>("checking");
99
- const [error, setError] = useState<string | null>(null);
100
- const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
101
- const [folders, setFolders] = useState<Folder[]>([]);
102
- const [foldersLoading, setFoldersLoading] = useState(true);
103
- const [folderError, setFolderError] = useState<string | null>(null);
104
- const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
105
- const [requestedFolderUid, setRequestedFolderUid] = useState<string | null>(null);
106
- const [drawerOpen, setDrawerOpen] = useState(false);
107
-
108
- useEffect(() => {
109
- const params = new URLSearchParams(window.location.search);
110
- setRequestedMailboxUid(params.get("mailboxUid"));
111
- setRequestedFolderUid(params.get("folderUid"));
112
- }, []);
113
-
114
- useEffect(() => {
115
- if (!userUid) {
116
- return;
117
- }
118
- listMailboxes({ limit: 100 })
119
- .then((result) => {
120
- setMailboxes(result);
121
- setStatus("ready");
122
- })
123
- .catch((err) => {
124
- setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
125
- setStatus("error");
126
- });
127
- }, [userUid]);
128
-
129
- const mailboxUid: string | undefined =
130
- (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
131
- mailboxes[0]?.uid;
132
-
133
- useEffect(() => {
134
- if (!mailboxUid) {
135
- setFolders([]);
136
- return;
137
- }
138
- setFolderError(null);
139
- setFoldersLoading(true);
140
- listFolders(mailboxUid)
141
- .then((result) => setFolders(result.filter((f) => MAIL_FOLDER_TYPES.has(f.type))))
142
- .catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load folders."))
143
- .finally(() => setFoldersLoading(false));
144
- }, [mailboxUid]);
145
-
146
- const folderUid: string | undefined =
147
- (requestedFolderUid && folders.some((f) => f.uid === requestedFolderUid) ? requestedFolderUid : undefined) ??
148
- folders.find((f) => f.type === "inbox")?.uid;
149
-
150
- const contextValue = useMemo<MailShellContextValue>(
151
- () => ({ mailboxUid, folderUid, mailboxes, folders }),
152
- [mailboxUid, folderUid, mailboxes, folders],
153
- );
154
-
155
- const sortedFolders = useMemo(
156
- () => [...folders].sort((a, b) => folderSortKey(a) - folderSortKey(b) || a.name.localeCompare(b.name)),
157
- [folders],
158
- );
159
-
160
- // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
161
- // for a mailbox-less caller to do here yet, so the icon rail/header/folder tree don't render at all.
162
- if (userUid && status === "ready" && !mailboxUid) {
163
- return <MailboxProvisioning />;
164
- }
165
-
166
- let inner: ReactNode = null;
167
- if (userUid && status === "checking") {
168
- // Renders immediately (no network round trip needed) so switching into Mail never shows a blank
169
- // pane while `listMailboxes()` is in flight — the outline below mirrors the real sidebar's shape.
170
- inner = (
171
- <aside className="w-64 shrink-0 bg-surface border-r border-border flex flex-col p-3 gap-4">
172
- <Skeleton height="h-9" className="rounded-sm" />
173
- <SkeletonList count={6} />
174
- </aside>
175
- );
176
- } else if (userUid && status === "error") {
177
- inner = (
178
- <div className="flex-1 flex items-center justify-center p-8">
179
- <div className="w-full max-w-md">
180
- <Alert>{error}</Alert>
181
- </div>
182
- </div>
183
- );
184
- } else if (userUid && status === "ready") {
185
- // A function, not a plain JSX constant — it's rendered twice (desktop `<aside>` + mobile
186
- // `Drawer`), possibly *simultaneously* mounted (the aside is only CSS-hidden below `md`, not
187
- // unmounted), so the mailbox-switcher `<select>`'s `id`/its `<label>`'s `htmlFor` need a distinct
188
- // value per instance. Two elements sharing one id breaks label association (and is invalid HTML).
189
- const sidebarContent = (idPrefix: string) => (
190
- <>
191
- <div className="p-3">
192
- <ComposeButton mailboxUid={mailboxUid} />
193
- </div>
194
- {mailboxes.length > 1 && (
195
- <div className="px-3 pb-2">
196
- <label
197
- className="block text-xs font-bold uppercase tracking-wide text-text-muted mb-1"
198
- htmlFor={`${idPrefix}-mailbox-switcher`}
199
- >
200
- Mailbox
201
- </label>
202
- <select
203
- id={`${idPrefix}-mailbox-switcher`}
204
- className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
205
- value={mailboxUid}
206
- onChange={(e) => {
207
- window.location.href = `/?mailboxUid=${encodeURIComponent(e.target.value)}`;
208
- }}
209
- >
210
- {mailboxes.map((mb) => (
211
- <option key={mb.uid} value={mb.uid}>
212
- {mb.displayName}
213
- {mb.ownerUserUid ? "" : " (shared)"}
214
- </option>
215
- ))}
216
- </select>
217
- </div>
218
- )}
219
- {folderError && (
220
- <div className="px-3 pb-2">
221
- <Alert>{folderError}</Alert>
222
- </div>
223
- )}
224
- <nav className="flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-0.5">
225
- {foldersLoading ? (
226
- <SkeletonList count={5} className="pt-1" />
227
- ) : (
228
- sortedFolders.map((folder) => (
229
- <a
230
- key={folder.uid}
231
- href={`/?mailboxUid=${encodeURIComponent(mailboxUid)}&folderUid=${encodeURIComponent(folder.uid)}`}
232
- className={[
233
- "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
234
- folder.uid === folderUid
235
- ? "bg-primary/10 text-primary-dark font-semibold"
236
- : "text-text hover:bg-surface-alt",
237
- ].join(" ")}
238
- >
239
- <span>{FOLDER_LABELS[folder.type] ?? folder.name}</span>
240
- {folder.unreadCount > 0 && (
241
- <span className="text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted">
242
- {folder.unreadCount}
243
- </span>
244
- )}
245
- </a>
246
- ))
247
- )}
248
- </nav>
249
- </>
250
- );
251
-
252
- inner = (
253
- <>
254
- <aside className="hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col">
255
- {sidebarContent("desktop")}
256
- </aside>
257
- <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Folders">
258
- <div className="flex flex-col">{sidebarContent("mobile")}</div>
259
- </Drawer>
260
- <main className="flex-1 min-w-0 overflow-y-auto">
261
- <button
262
- type="button"
263
- 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"
264
- aria-label="Open folders"
265
- onClick={() => setDrawerOpen(true)}
266
- >
267
- <HiOutlineBars3 size={20} aria-hidden="true" />
268
- </button>
269
- <MailShellContext.Provider value={contextValue}>{children}</MailShellContext.Provider>
270
- </main>
271
- </>
272
- );
273
- }
274
-
275
- return (
276
- <AppShell
277
- active="mail"
278
- userUid={userUid}
279
- authServerUrl={authServerUrl}
280
- impersonating={impersonating}
281
- impersonationBaseUrl={impersonationBaseUrl}
282
- trusted={trusted}
283
- >
284
- {inner}
285
- </AppShell>
286
- );
287
- }
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 { 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
+
17
+ export type MailShellProps = Omit<AppShellProps, "active">;
18
+
19
+ export interface MailShellContextValue {
20
+ /** The mailbox currently selected (`?mailboxUid=`, or the caller's first accessible mailbox). */
21
+ mailboxUid?: string;
22
+ /** The folder currently selected (`?folderUid=`, or the selected mailbox's Inbox). */
23
+ folderUid?: string;
24
+ mailboxes: Mailbox[];
25
+ folders: Folder[];
26
+ }
27
+
28
+ const MailShellContext = createContext<MailShellContextValue>({ mailboxes: [], folders: [] });
29
+
30
+ /** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
31
+ export function useMailShell(): MailShellContextValue {
32
+ return useContext(MailShellContext);
33
+ }
34
+
35
+ const FOLDER_LABELS: Record<string, string> = {
36
+ inbox: "Inbox",
37
+ sent_items: "Sent Items",
38
+ drafts: "Drafts",
39
+ outbox: "Outbox",
40
+ junk: "Junk Email",
41
+ archive: "Archive",
42
+ deleted_items: "Deleted Items",
43
+ };
44
+
45
+ /** Well-known folders sort first, in Gmail/Outlook's conventional order; anything else (incl. `user`) sorts after, alphabetically. */
46
+ const FOLDER_ORDER = ["inbox", "drafts", "outbox", "sent_items", "junk", "archive", "deleted_items"];
47
+
48
+ /**
49
+ * A mailbox's `calendar`/`contacts`/`tasks`/`notes` folders back their own dedicated apps (see
50
+ * `CalendarShell`/`ContactsShell`/`TasksShell`), not Mail — `listFolders()` returns every well-known
51
+ * folder for the mailbox regardless of which app owns it, so Mail's own folder tree must filter down
52
+ * to just the mail ones itself, or those other apps' folders leak into this sidebar.
53
+ */
54
+ const MAIL_FOLDER_TYPES = new Set([...FOLDER_ORDER, "user"]);
55
+
56
+ function folderSortKey(folder: Folder): number {
57
+ const idx = FOLDER_ORDER.indexOf(folder.type);
58
+ return idx === -1 ? FOLDER_ORDER.length : idx;
59
+ }
60
+
61
+ type Status = "checking" | "error" | "ready";
62
+
63
+ /**
64
+ * A separate component (not inlined into `MailShell`'s own render) so `useCompose()` resolves against
65
+ * `ComposeProvider` correctly: that provider is rendered *inside* the `AppShell` that `MailShell` itself
66
+ * returns, i.e. a descendant of `MailShell`, not an ancestor — a hook call made directly in `MailShell`'s
67
+ * own function body would see only whatever context exists *above* `MailShell`, never a provider one of
68
+ * its own descendants creates. This button, rendered as part of `AppShell`'s `children`, sits correctly
69
+ * inside that subtree.
70
+ */
71
+ function ComposeButton({ mailboxUid }: { mailboxUid: string }) {
72
+ const { openCompose } = useCompose();
73
+ return (
74
+ <button
75
+ type="button"
76
+ onClick={() => openCompose({ mailboxUid })}
77
+ 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"
78
+ >
79
+ Compose
80
+ </button>
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Mail's own contextual sidebar (mailbox switcher + folder tree) + content area, rendered inside the shared
86
+ * `AppShell` chrome (icon rail, header, impersonation banner — see that component). There is no client-side
87
+ * router in this framework (see `ReactRoute`'s file-convention resolver) — the selected mailbox/folder live
88
+ * in the URL's `?mailboxUid=`/`?folderUid=` query params, read once on mount (never during the initial
89
+ * render itself, matching every other query-param reader in this codebase — e.g. `apps/admin/quarantine`'s
90
+ * `readMailboxUid()` — so the server-rendered and just-hydrated client markup match).
91
+ */
92
+ export default function MailShell({
93
+ userUid,
94
+ authServerUrl,
95
+ impersonating,
96
+ impersonationBaseUrl,
97
+ trusted,
98
+ children,
99
+ }: PropsWithChildren<MailShellProps>) {
100
+ const [status, setStatus] = useState<Status>("checking");
101
+ const [error, setError] = useState<string | null>(null);
102
+ const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
103
+ const [folders, setFolders] = useState<Folder[]>([]);
104
+ const [foldersLoading, setFoldersLoading] = useState(true);
105
+ const [folderError, setFolderError] = useState<string | null>(null);
106
+ const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
107
+ const [requestedFolderUid, setRequestedFolderUid] = useState<string | null>(null);
108
+ const [drawerOpen, setDrawerOpen] = useState(false);
109
+
110
+ useEffect(() => {
111
+ const params = new URLSearchParams(window.location.search);
112
+ setRequestedMailboxUid(params.get("mailboxUid"));
113
+ setRequestedFolderUid(params.get("folderUid"));
114
+ }, []);
115
+
116
+ useEffect(() => {
117
+ if (!userUid) {
118
+ return;
119
+ }
120
+ listMailboxes({ limit: 100 })
121
+ .then((result) => {
122
+ setMailboxes(result);
123
+ setStatus("ready");
124
+ })
125
+ .catch((err) => {
126
+ setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
127
+ setStatus("error");
128
+ });
129
+ }, [userUid]);
130
+
131
+ const mailboxUid: string | undefined =
132
+ (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
133
+ mailboxes[0]?.uid;
134
+
135
+ useEffect(() => {
136
+ if (!mailboxUid) {
137
+ setFolders([]);
138
+ return;
139
+ }
140
+ setFolderError(null);
141
+ setFoldersLoading(true);
142
+ listFolders(mailboxUid)
143
+ .then((result) => setFolders(result.filter((f) => MAIL_FOLDER_TYPES.has(f.type))))
144
+ .catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load folders."))
145
+ .finally(() => setFoldersLoading(false));
146
+ }, [mailboxUid]);
147
+
148
+ const folderUid: string | undefined =
149
+ (requestedFolderUid && folders.some((f) => f.uid === requestedFolderUid) ? requestedFolderUid : undefined) ??
150
+ folders.find((f) => f.type === "inbox")?.uid;
151
+
152
+ const contextValue = useMemo<MailShellContextValue>(
153
+ () => ({ mailboxUid, folderUid, mailboxes, folders }),
154
+ [mailboxUid, folderUid, mailboxes, folders],
155
+ );
156
+
157
+ const sortedFolders = useMemo(
158
+ () => [...folders].sort((a, b) => folderSortKey(a) - folderSortKey(b) || a.name.localeCompare(b.name)),
159
+ [folders],
160
+ );
161
+
162
+ // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
163
+ // for a mailbox-less caller to do here yet, so the icon rail/header/folder tree don't render at all.
164
+ if (userUid && status === "ready" && !mailboxUid) {
165
+ return <MailboxProvisioning />;
166
+ }
167
+
168
+ let inner: ReactNode = null;
169
+ if (userUid && status === "checking") {
170
+ // Renders immediately (no network round trip needed) so switching into Mail never shows a blank
171
+ // pane while `listMailboxes()` is in flight — the outline below mirrors the real sidebar's shape.
172
+ inner = (
173
+ <aside className="w-64 shrink-0 bg-surface border-r border-border flex flex-col p-3 gap-4">
174
+ <Skeleton height="h-9" className="rounded-sm" />
175
+ <SkeletonList count={6} />
176
+ </aside>
177
+ );
178
+ } else if (userUid && status === "error") {
179
+ inner = (
180
+ <div className="flex-1 flex items-center justify-center p-8">
181
+ <div className="w-full max-w-md">
182
+ <Alert>{error}</Alert>
183
+ </div>
184
+ </div>
185
+ );
186
+ } else if (userUid && status === "ready") {
187
+ // A function, not a plain JSX constant — it's rendered twice (desktop `<aside>` + mobile
188
+ // `Drawer`), possibly *simultaneously* mounted (the aside is only CSS-hidden below `md`, not
189
+ // unmounted), so the mailbox-switcher `<select>`'s `id`/its `<label>`'s `htmlFor` need a distinct
190
+ // value per instance. Two elements sharing one id breaks label association (and is invalid HTML).
191
+ const sidebarContent = (idPrefix: string) => (
192
+ <>
193
+ <div className="p-3">
194
+ <ComposeButton mailboxUid={mailboxUid} />
195
+ </div>
196
+ {mailboxes.length > 1 && (
197
+ <div className="px-3 pb-2">
198
+ <label
199
+ className="block text-xs font-bold uppercase tracking-wide text-text-muted mb-1"
200
+ htmlFor={`${idPrefix}-mailbox-switcher`}
201
+ >
202
+ Mailbox
203
+ </label>
204
+ <select
205
+ id={`${idPrefix}-mailbox-switcher`}
206
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
207
+ value={mailboxUid}
208
+ onChange={(e) => {
209
+ window.location.href = `/?mailboxUid=${encodeURIComponent(e.target.value)}`;
210
+ }}
211
+ >
212
+ {mailboxes.map((mb) => (
213
+ <option key={mb.uid} value={mb.uid}>
214
+ {mb.displayName}
215
+ {mb.ownerUserUid ? "" : " (shared)"}
216
+ </option>
217
+ ))}
218
+ </select>
219
+ </div>
220
+ )}
221
+ {folderError && (
222
+ <div className="px-3 pb-2">
223
+ <Alert>{folderError}</Alert>
224
+ </div>
225
+ )}
226
+ <nav className="flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-0.5">
227
+ {foldersLoading ? (
228
+ <SkeletonList count={5} className="pt-1" />
229
+ ) : (
230
+ sortedFolders.map((folder) => (
231
+ <a
232
+ key={folder.uid}
233
+ href={`/?mailboxUid=${encodeURIComponent(mailboxUid)}&folderUid=${encodeURIComponent(folder.uid)}`}
234
+ className={[
235
+ "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
236
+ folder.uid === folderUid
237
+ ? "bg-primary/10 text-primary-dark font-semibold"
238
+ : "text-text hover:bg-surface-alt",
239
+ ].join(" ")}
240
+ >
241
+ <span>{FOLDER_LABELS[folder.type] ?? folder.name}</span>
242
+ {folder.unreadCount > 0 && (
243
+ <span className="text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted">
244
+ {folder.unreadCount}
245
+ </span>
246
+ )}
247
+ </a>
248
+ ))
249
+ )}
250
+ </nav>
251
+ </>
252
+ );
253
+
254
+ inner = (
255
+ <>
256
+ <aside className="hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col">
257
+ {sidebarContent("desktop")}
258
+ </aside>
259
+ <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Folders">
260
+ <div className="flex flex-col">{sidebarContent("mobile")}</div>
261
+ </Drawer>
262
+ <main className="flex-1 min-w-0 overflow-y-auto">
263
+ <button
264
+ type="button"
265
+ 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"
266
+ aria-label="Open folders"
267
+ onClick={() => setDrawerOpen(true)}
268
+ >
269
+ <HiOutlineBars3 size={20} aria-hidden="true" />
270
+ </button>
271
+ <MailShellContext.Provider value={contextValue}>{children}</MailShellContext.Provider>
272
+ </main>
273
+ </>
274
+ );
275
+ }
276
+
277
+ // Wraps AppShell unconditionally, at a stable tree position regardless of whether mailboxUid has
278
+ // resolved yet - KeyEnrollmentGate itself passes `children` through untouched until a real
279
+ // mailboxUid is supplied (see its own doc comment). Wrapping only once ready (a conditional tree
280
+ // position) would make AppShell itself remount the moment mailboxUid resolves, tearing down
281
+ // whatever state/effects it had already started (confirmed by direct reproduction: the
282
+ // impersonation banner's own internal state was lost exactly at that transition).
283
+ const selectedMailbox = mailboxes.find((mb) => mb.uid === mailboxUid);
284
+ return (
285
+ <KeyEnrollmentGate
286
+ mailboxUid={mailboxUid}
287
+ mailboxAddress={selectedMailbox?.primarySmtpAddress}
288
+ mailboxKeys={selectedMailbox?.keys}
289
+ >
290
+ <AppShell
291
+ active="mail"
292
+ userUid={userUid}
293
+ authServerUrl={authServerUrl}
294
+ impersonating={impersonating}
295
+ impersonationBaseUrl={impersonationBaseUrl}
296
+ trusted={trusted}
297
+ >
298
+ {inner}
299
+ </AppShell>
300
+ </KeyEnrollmentGate>
301
+ );
302
+ }
@@ -23,9 +23,12 @@ export const SETTINGS_SECTIONS: SettingsSectionDef[] = [
23
23
  { id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies" },
24
24
  { id: "filters", href: "/settings/filters", label: "Mail Filters" },
25
25
  { id: "signatures", href: "/settings/signatures", label: "Signatures" },
26
+ { id: "labels", href: "/settings/labels", label: "Labels" },
26
27
  { id: "focused-inbox", href: "/settings/focused-inbox", label: "Focused Inbox" },
27
28
  { id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts" },
28
29
  { id: "booking-types", href: "/settings/booking-types", label: "Booking Links" },
30
+ { id: "encryption", href: "/settings/encryption", label: "Encryption" },
31
+ { id: "privacy", href: "/settings/privacy", label: "Privacy & Data" },
29
32
  ];
30
33
 
31
34
  export type SettingsSectionId = (typeof SETTINGS_SECTIONS)[number]["id"];