@rapidmx/web-client 0.4.0 → 0.5.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 (169) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/data-requests/index.tsx +480 -485
  3. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  4. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  5. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  6. package/apps/admin/index.tsx +19 -1
  7. package/apps/admin/ingest-queue/index.tsx +44 -5
  8. package/apps/admin/mailboxes/[uid].tsx +211 -184
  9. package/apps/admin/quarantine/index.tsx +46 -14
  10. package/apps/admin/transport-rules/[uid].tsx +30 -1
  11. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  12. package/apps/admin/transport-rules/index.tsx +24 -6
  13. package/apps/admin/transport-rules/new/index.tsx +30 -1
  14. package/apps/escrow/_layout.tsx +2 -3
  15. package/apps/escrow/audit-log/index.tsx +196 -168
  16. package/apps/escrow/matters/[uid].tsx +621 -521
  17. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  18. package/apps/shared/components/admin/layout/AdminShell.tsx +3 -1
  19. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  20. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  21. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  22. package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
  23. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
  24. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
  25. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
  26. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
  27. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
  28. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
  29. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
  30. package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
  31. package/apps/shared/components/admin/signOut.ts +79 -0
  32. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  33. package/apps/shared/components/calendar/EventModal.tsx +717 -549
  34. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  35. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  36. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  37. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  38. package/apps/shared/components/calendar/allDay.ts +124 -0
  39. package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
  40. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  41. package/apps/shared/components/contacts/ContactForm.tsx +377 -348
  42. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  43. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  44. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  45. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  46. package/apps/shared/components/layout/AppShell.tsx +87 -15
  47. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  48. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
  49. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  50. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  51. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
  52. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  53. package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
  54. package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
  55. package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
  56. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  57. package/apps/shared/components/mail/layout/MailShell.tsx +12 -2
  58. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  59. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  60. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  61. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  62. package/apps/shared/mail/listAllPages.ts +39 -0
  63. package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
  64. package/apps/shared/search/localIndexBuilder.ts +481 -179
  65. package/apps/shared/search/localIndexRpcClient.ts +228 -28
  66. package/apps/shared/search/localIndexSchema.ts +15 -6
  67. package/apps/shared/search/localIndexStorage.ts +98 -0
  68. package/apps/shared/search/localIndexVFS.ts +32 -1
  69. package/apps/shared/search/localIndexWorker.ts +648 -186
  70. package/apps/www/calendar/index.tsx +438 -417
  71. package/apps/www/contacts/[uid].tsx +21 -2
  72. package/apps/www/contacts/index.tsx +116 -12
  73. package/apps/www/index.tsx +1314 -1041
  74. package/apps/www/messages/[uid].tsx +18 -6
  75. package/apps/www/settings/auto-reply/index.tsx +134 -129
  76. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  77. package/apps/www/settings/encryption/index.tsx +739 -216
  78. package/apps/www/settings/filters/[uid].tsx +171 -155
  79. package/apps/www/settings/filters/new/index.tsx +8 -1
  80. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  81. package/apps/www/settings/privacy/index.tsx +494 -436
  82. package/apps/www/settings/read-receipts/index.tsx +148 -125
  83. package/apps/www/settings/sharing/index.tsx +17 -14
  84. package/apps/www/tasks/index.tsx +638 -579
  85. package/dist/apps/admin/audit-log/index.js +38 -8
  86. package/dist/apps/admin/data-requests/index.js +52 -72
  87. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  88. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  89. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  90. package/dist/apps/admin/index.js +4 -1
  91. package/dist/apps/admin/ingest-queue/index.js +24 -6
  92. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  93. package/dist/apps/admin/quarantine/index.js +20 -11
  94. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  95. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  96. package/dist/apps/admin/transport-rules/index.js +22 -6
  97. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  98. package/dist/apps/escrow/_layout.js +3 -3
  99. package/dist/apps/escrow/audit-log/index.js +26 -1
  100. package/dist/apps/escrow/matters/[uid].js +77 -42
  101. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  102. package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -1
  103. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  104. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  105. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  106. package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
  107. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
  108. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
  109. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
  110. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
  111. package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
  112. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
  113. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
  114. package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
  115. package/dist/apps/shared/components/admin/signOut.js +75 -0
  116. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  117. package/dist/apps/shared/components/calendar/EventModal.js +142 -21
  118. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  119. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  120. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  121. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  122. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  123. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
  124. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  125. package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
  126. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  127. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  128. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  129. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  130. package/dist/apps/shared/components/layout/AppShell.js +83 -14
  131. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  132. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
  133. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  134. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  135. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
  136. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  137. package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
  138. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  139. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
  140. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  141. package/dist/apps/shared/components/mail/layout/MailShell.js +12 -3
  142. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  143. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  144. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  145. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  146. package/dist/apps/shared/mail/listAllPages.js +25 -0
  147. package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
  148. package/dist/apps/shared/search/localIndexBuilder.js +292 -37
  149. package/dist/apps/shared/search/localIndexRpcClient.js +188 -25
  150. package/dist/apps/shared/search/localIndexSchema.js +12 -6
  151. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  152. package/dist/apps/shared/search/localIndexVFS.js +68 -40
  153. package/dist/apps/shared/search/localIndexWorker.js +519 -173
  154. package/dist/apps/www/calendar/index.js +28 -10
  155. package/dist/apps/www/contacts/[uid].js +13 -2
  156. package/dist/apps/www/contacts/index.js +80 -10
  157. package/dist/apps/www/index.js +339 -108
  158. package/dist/apps/www/messages/[uid].js +18 -6
  159. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  160. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  161. package/dist/apps/www/settings/encryption/index.js +514 -97
  162. package/dist/apps/www/settings/filters/[uid].js +22 -7
  163. package/dist/apps/www/settings/filters/new/index.js +8 -2
  164. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  165. package/dist/apps/www/settings/privacy/index.js +57 -22
  166. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  167. package/dist/apps/www/settings/sharing/index.js +7 -11
  168. package/dist/apps/www/tasks/index.js +50 -10
  169. package/package.json +2 -2
@@ -3,7 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import "../../styles/app.css";
6
- import React, { PropsWithChildren, useState, useEffect } from "react";
6
+ import React, { PropsWithChildren, useEffect, useRef, useState } from "react";
7
7
  import type { IconType } from "react-icons";
8
8
  import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelope, HiOutlineUsers } from "react-icons/hi2";
9
9
  import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
@@ -12,11 +12,38 @@ import { stopImpersonating } from "@rapidmx/react-shared/mail/mailApi.js";
12
12
  import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
13
13
  import { useIdleKeyTimeout } from "@rapidmx/react-shared/crypto/useIdleKeyTimeout.js";
14
14
  import ComposeProvider from "../mail/compose/ComposeContext.js";
15
+ import { flushComposeDrafts, markSigningOut } from "../mail/compose/composeFlushRegistry.js";
15
16
  import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
16
17
  import { BrandingFooter, BrandingHeader } from "./BrandingChrome.js";
17
18
  import UserMenu from "./UserMenu.js";
18
19
  import { UnlockPromptProvider } from "./UnlockPromptProvider.js";
19
- import { destroyAllLocalIndexes } from "../../search/localIndexRpcClient.js";
20
+ import { SIGN_OUT_CHANNEL, destroyAllLocalIndexes } from "../../search/localIndexRpcClient.js";
21
+ import { authApiFetch } from "@rapidmx/react-shared/util/api.js";
22
+ import { destroyUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
23
+ import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
24
+
25
+ /** How long sign-out waits for auth-server's logout before navigating anyway. */
26
+ export const LOGOUT_TIMEOUT_MS = 3_000;
27
+
28
+ /**
29
+ * Calls auth-server's logout (`POST /api/auth/logout`, cross-origin with credentials), which clears the auth
30
+ * cookie and invalidates the session's refresh token. Bounded by `LOGOUT_TIMEOUT_MS` and never rejects - a
31
+ * failure (unreachable server, CORS) must not keep the user from leaving.
32
+ */
33
+ async function logOutOfAuthServer(authServerUrl: string | undefined): Promise<void> {
34
+ if (!authServerUrl) {
35
+ return;
36
+ }
37
+ const controller = new AbortController();
38
+ const timer = setTimeout(() => controller.abort(), LOGOUT_TIMEOUT_MS);
39
+ try {
40
+ await authApiFetch(authServerUrl, "/auth/logout", { method: "POST", signal: controller.signal });
41
+ } catch {
42
+ // Navigate anyway - see this function's doc comment.
43
+ } finally {
44
+ clearTimeout(timer);
45
+ }
46
+ }
20
47
 
21
48
  export type AppShellApp = "mail" | "calendar" | "contacts" | "tasks";
22
49
 
@@ -85,6 +112,7 @@ export default function AppShell({
85
112
  children,
86
113
  }: PropsWithChildren<AppShellProps>) {
87
114
  const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
115
+ const signingOutRef = useRef(false);
88
116
  const { branding, iconSrc } = useBranding();
89
117
 
90
118
  useRedirectIfUnauthenticated(userUid, authServerUrl);
@@ -92,10 +120,11 @@ export default function AppShell({
92
120
  // specifically so activity in *any* app resets the idle clock - see that hook's own doc comment.
93
121
  useIdleKeyTimeout();
94
122
 
95
- // An administrator on a server that hasn't finished first-run setup is sent to the setup wizard. Everyone else
96
- // gets a 403 from the setup status check (it's admin-only), which is ignored along with any other failure.
123
+ // An administrator on a server that hasn't finished first-run setup is sent to the setup wizard. The status
124
+ // check is admin-only, so it's only made for a trusted caller - everyone else would just get a 403 on every
125
+ // page load. Any failure is ignored.
97
126
  useEffect(() => {
98
- if (!userUid || impersonating) {
127
+ if (!userUid || impersonating || !trusted) {
99
128
  return;
100
129
  }
101
130
  getSetupStatus()
@@ -105,15 +134,56 @@ export default function AppShell({
105
134
  }
106
135
  })
107
136
  .catch(() => undefined);
108
- }, [userUid, impersonating]);
109
-
110
- function handleSignOut() {
111
- // Best-effort, fire-and-forget: the Tier 2 local index MUST be destroyed on explicit logout, the
112
- // same as unlocked keys themselves (spec §11) - this component has no specific mailboxUid of its
113
- // own to pass (it's shared by every app, not just Mail), so this destroys every mailbox's index
114
- // the session has open rather than needing one threaded through. Not awaited - the navigation
115
- // below shouldn't wait on it, and destroyAllLocalIndexes() never throws either way.
116
- void destroyAllLocalIndexes();
137
+ }, [userUid, impersonating, trusted]);
138
+
139
+ // Another tab signing out (this app's `handleSignOut`, or the admin/escrow consoles' `signOutOfConsole()`)
140
+ // ended this session too - its auth cookie is gone - so this tab destroys its own unlocked keys and every
141
+ // local search index on the device, then leaves as well. The consoles have no local-index client of their
142
+ // own, so this is what actually removes the indexes after a console sign-out (the console also records a
143
+ // pending deletion, retried on the next mail load, in case no mail tab is open). `destroyAllLocalIndexes()`
144
+ // announces the sign-out on this same channel, which this tab then hears itself, so the ref is set first:
145
+ // each tab reacts once, and the tab that started the sign-out ignores its own announcement.
146
+ useEffect(() => {
147
+ if (!userUid || typeof BroadcastChannel === "undefined") {
148
+ return;
149
+ }
150
+ const channel = new BroadcastChannel(SIGN_OUT_CHANNEL);
151
+ channel.addEventListener("message", (event: MessageEvent<{ type?: string }>) => {
152
+ if (event.data?.type !== "sign-out" || signingOutRef.current) {
153
+ return;
154
+ }
155
+ signingOutRef.current = true;
156
+ // Compose windows must not ask "Leave site?" - that would let this forced navigation be cancelled.
157
+ markSigningOut();
158
+ destroyUnlockedKeys();
159
+ clearPinnedSignerCache();
160
+ // Bounded by its own timeout and never rejects - awaited so navigating doesn't kill the Worker mid-delete.
161
+ void destroyAllLocalIndexes().then(() => {
162
+ window.location.href = authServerUrl ?? "/";
163
+ });
164
+ });
165
+ return () => channel.close();
166
+ }, [userUid, authServerUrl]);
167
+
168
+ async function handleSignOut() {
169
+ signingOutRef.current = true;
170
+ // Before flushing and navigating: compose windows then skip their "Leave site?" prompt, which could
171
+ // otherwise cancel the sign-out's own navigation.
172
+ markSigningOut();
173
+ // Unlocked private keys never outlive an explicit sign-out.
174
+ destroyUnlockedKeys();
175
+ // Trusted signer pins read from contacts don't outlive the session either.
176
+ clearPinnedSignerCache();
177
+ // Open compose windows save edits still waiting on their autosave debounce while the session is still
178
+ // valid - logout invalidates it. Bounded the same way as logout itself, and never rejects.
179
+ await flushComposeDrafts(LOGOUT_TIMEOUT_MS);
180
+ // The Tier 2 local index MUST be destroyed on explicit logout, the same as unlocked keys themselves
181
+ // (spec §11). Destroys every index on this device (not only mailboxes opened this page load) and is
182
+ // awaited before navigating - a navigation tears down the Worker mid-delete otherwise.
183
+ // destroyAllLocalIndexes() is bounded by its own timeout and never rejects, so sign-out can't hang.
184
+ // In parallel, auth-server's logout clears the auth cookie and invalidates the session's refresh
185
+ // token - without it, "Sign Out" would only navigate away from a still-valid session.
186
+ await Promise.all([destroyAllLocalIndexes(), logOutOfAuthServer(authServerUrl)]);
117
187
  window.location.href = authServerUrl ?? "/";
118
188
  }
119
189
 
@@ -139,7 +209,9 @@ export default function AppShell({
139
209
  // ComposeWindow's sign/encrypt toggles and MessageDetailPane's encrypted-message view (both Mail)
140
210
  // are today's only useUnlockPrompt() callers, but this needs to be available to any app shell.
141
211
  <UnlockPromptProvider>
142
- <ComposeProvider userUid={userUid}>
212
+ {/* An impersonating admin acts with the impersonated user's access, so their own trusted role
213
+ mustn't skip the per-mailbox checks. */}
214
+ <ComposeProvider userUid={userUid} trusted={!!trusted && !impersonating}>
143
215
  <div className="min-h-screen flex flex-col bg-surface-alt">
144
216
  <BrandingHeader branding={branding} />
145
217
  {impersonating && (
@@ -3,23 +3,61 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React from "react";
6
+ import DOMPurify from "dompurify";
6
7
  import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
7
8
 
9
+ /** Elements that could run script, restyle the whole page, collect input, or embed another page - stripped on
10
+ * top of DOMPurify's defaults (which already drop `<script>`, `on*` handlers, and `javascript:` URLs). */
11
+ const FORBIDDEN_TAGS = [
12
+ "script",
13
+ "style",
14
+ "form",
15
+ "input",
16
+ "button",
17
+ "textarea",
18
+ "select",
19
+ "option",
20
+ "iframe",
21
+ "frame",
22
+ "frameset",
23
+ "object",
24
+ "embed",
25
+ "link",
26
+ "meta",
27
+ "base",
28
+ "svg",
29
+ "math",
30
+ ];
31
+ const FORBIDDEN_ATTRS = ["action", "formaction", "srcdoc"];
32
+
8
33
  /**
9
- * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`) — shared by every
10
- * chrome in this project (`AppShell`, `AdminShell`, and the public `apps/book` pages) so the setting applies
11
- * everywhere its own admin UI copy (`apps/admin/branding/index.tsx`) claims it does.
34
+ * Sanitizes admin-configured branding HTML before it's rendered. The server sanitizes on save too; this also
35
+ * covers anything stored before that. Returns `""` where there's no DOM to sanitize with (server-side
36
+ * rendering), so unsanitized HTML is never emitted.
12
37
  */
13
- export function BrandingHeader({ branding }: { branding: Branding | null }) {
14
- if (!branding?.headerHtml) {
15
- return null;
38
+ export function sanitizeBrandingHtml(html: string): string {
39
+ if (!DOMPurify.isSupported) {
40
+ return "";
16
41
  }
17
- return <div dangerouslySetInnerHTML={{ __html: branding.headerHtml }} />;
42
+ return DOMPurify.sanitize(html, { FORBID_TAGS: FORBIDDEN_TAGS, FORBID_ATTR: FORBIDDEN_ATTRS });
18
43
  }
19
44
 
20
- export function BrandingFooter({ branding }: { branding: Branding | null }) {
21
- if (!branding?.footerHtml) {
45
+ function BrandingHtml({ html }: { html: string | undefined }) {
46
+ const clean = html ? sanitizeBrandingHtml(html) : "";
47
+ if (!clean) {
22
48
  return null;
23
49
  }
24
- return <div dangerouslySetInnerHTML={{ __html: branding.footerHtml }} />;
50
+ return <div dangerouslySetInnerHTML={{ __html: clean }} />;
51
+ }
52
+
53
+ /**
54
+ * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized - shared by
55
+ * the webmail and admin chromes. The escrow console deliberately renders no branding HTML at all.
56
+ */
57
+ export function BrandingHeader({ branding }: { branding: Branding | null }) {
58
+ return <BrandingHtml html={branding?.headerHtml} />;
59
+ }
60
+
61
+ export function BrandingFooter({ branding }: { branding: Branding | null }) {
62
+ return <BrandingHtml html={branding?.footerHtml} />;
25
63
  }