@rapidmx/web-client 0.15.1 → 0.16.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 (189) hide show
  1. package/README.md +373 -373
  2. package/apps/admin/branding/index.tsx +39 -39
  3. package/apps/admin/data-requests/index.tsx +482 -480
  4. package/apps/admin/domains/[uid].tsx +164 -164
  5. package/apps/admin/escrow-scopes/[uid].tsx +348 -348
  6. package/apps/admin/index.tsx +127 -124
  7. package/apps/admin/mailboxes/[uid].tsx +269 -224
  8. package/apps/admin/mailboxes/new/index.tsx +28 -28
  9. package/apps/admin/plugins/index.tsx +15 -15
  10. package/apps/admin/retention-policy/index.tsx +39 -39
  11. package/apps/admin/signing-certificates/index.tsx +343 -343
  12. package/apps/escrow/audit-log/index.tsx +196 -196
  13. package/apps/escrow/matters/[uid].tsx +621 -621
  14. package/apps/shared/auth/adminAccess.ts +99 -99
  15. package/apps/shared/components/admin/layout/AdminShell.tsx +378 -378
  16. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -0
  17. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
  18. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -0
  19. package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
  20. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
  21. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +50 -3
  22. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
  23. package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1545
  24. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
  25. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
  26. package/apps/shared/components/admin/setup/SetupWizard.tsx +444 -444
  27. package/apps/shared/components/admin/usePagedList.tsx +129 -129
  28. package/apps/shared/components/calendar/EventEditor.tsx +5 -10
  29. package/apps/shared/components/calendar/EventFormParts.tsx +16 -47
  30. package/apps/shared/components/calendar/EventModal.tsx +206 -206
  31. package/apps/shared/components/calendar/GuestInput.tsx +100 -0
  32. package/apps/shared/components/calendar/MonthView.tsx +185 -185
  33. package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
  34. package/apps/shared/components/calendar/RequestChangeForm.tsx +23 -54
  35. package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
  36. package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
  37. package/apps/shared/components/calendar/allDay.ts +124 -124
  38. package/apps/shared/components/calendar/eventForm.ts +2 -2
  39. package/apps/shared/components/calendar/eventFormat.ts +49 -22
  40. package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
  41. package/apps/shared/components/contacts/ContactForm.tsx +383 -383
  42. package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
  43. package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
  44. package/apps/shared/components/contacts/layout/ContactsShell.tsx +217 -216
  45. package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
  46. package/apps/shared/components/layout/AppShell.tsx +480 -480
  47. package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
  48. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
  49. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
  50. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
  51. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
  52. package/apps/shared/components/layout/UserMenu.tsx +439 -424
  53. package/apps/shared/components/mail/ConversationList.tsx +323 -323
  54. package/apps/shared/components/mail/ConversationThreadPane.tsx +612 -491
  55. package/apps/shared/components/mail/MailSelectionBar.tsx +236 -227
  56. package/apps/shared/components/mail/MessageDetailPane.tsx +1559 -1559
  57. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
  58. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
  59. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1677
  60. package/apps/shared/components/mail/compose/RecipientInput.tsx +54 -11
  61. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
  62. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
  63. package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
  64. package/apps/shared/components/mail/layout/FolderBadgeChip.tsx +21 -0
  65. package/apps/shared/components/mail/layout/MailShell.tsx +137 -113
  66. package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
  67. package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
  68. package/apps/shared/components/mail/verificationSeals.ts +125 -125
  69. package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
  70. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
  71. package/apps/shared/components/settings/layout/SettingsShell.tsx +255 -254
  72. package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
  73. package/apps/shared/components/tasks/layout/TasksShell.tsx +218 -217
  74. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
  75. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
  76. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
  77. package/apps/shared/keyboard/dispatch.ts +124 -124
  78. package/apps/shared/keyboard/format.ts +89 -89
  79. package/apps/shared/keyboard/keymap.ts +114 -114
  80. package/apps/shared/keyboard/registry.ts +65 -65
  81. package/apps/shared/keyboard/targets.ts +79 -79
  82. package/apps/shared/mail/folderCounts.ts +17 -0
  83. package/apps/shared/mail/folderTree.ts +143 -143
  84. package/apps/shared/mail/listAllPages.ts +39 -39
  85. package/apps/shared/mail/newMailNotifications.ts +171 -171
  86. package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
  87. package/apps/shared/mail/outbox/sendJob.ts +19 -1
  88. package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
  89. package/apps/shared/mail/primaryMailbox.ts +71 -0
  90. package/apps/shared/mail/useCollapsedSections.ts +146 -0
  91. package/apps/shared/mail/useMailConnection.ts +205 -203
  92. package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
  93. package/apps/shared/mail/useMarkMessageRead.ts +47 -47
  94. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  95. package/apps/shared/navigation/AppRouter.tsx +300 -300
  96. package/apps/shared/navigation/routerContext.tsx +83 -83
  97. package/apps/shared/notifications/store.ts +560 -560
  98. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
  99. package/apps/shared/search/crossMailboxSearch.ts +169 -0
  100. package/apps/shared/search/localIndexBuilder.ts +481 -481
  101. package/apps/shared/signing/enrollmentStorage.ts +33 -33
  102. package/apps/shared/signing/enrollmentTracker.ts +385 -385
  103. package/apps/shared/signing/enrollmentView.ts +251 -251
  104. package/apps/shared/signing/useNow.ts +19 -19
  105. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
  106. package/apps/shared/styles/app.css +396 -386
  107. package/apps/www/calendar/index.tsx +578 -571
  108. package/apps/www/contacts/[uid].tsx +109 -109
  109. package/apps/www/contacts/index.tsx +6 -0
  110. package/apps/www/index.tsx +2847 -2479
  111. package/apps/www/messages/[uid].tsx +135 -135
  112. package/apps/www/settings/auto-reply/index.tsx +133 -133
  113. package/apps/www/settings/encryption/index.tsx +1287 -1287
  114. package/apps/www/settings/filters/[uid].tsx +176 -176
  115. package/apps/www/settings/filters/index.tsx +102 -102
  116. package/apps/www/settings/filters/new/index.tsx +140 -140
  117. package/apps/www/settings/labels/index.tsx +204 -204
  118. package/apps/www/settings/privacy/index.tsx +492 -492
  119. package/apps/www/settings/profile/index.tsx +248 -248
  120. package/apps/www/settings/read-receipts/index.tsx +147 -147
  121. package/apps/www/settings/sharing/index.tsx +256 -256
  122. package/apps/www/settings/signatures/[uid].tsx +172 -172
  123. package/apps/www/settings/signatures/index.tsx +88 -88
  124. package/apps/www/settings/signatures/new/index.tsx +135 -135
  125. package/apps/www/tasks/index.tsx +649 -649
  126. package/dist/apps/admin/data-requests/index.js +1 -1
  127. package/dist/apps/admin/index.js +2 -1
  128. package/dist/apps/admin/mailboxes/[uid].js +12 -2
  129. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
  130. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.d.ts +35 -0
  131. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +115 -0
  132. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.d.ts +16 -0
  133. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +92 -0
  134. package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +25 -3
  136. package/dist/apps/shared/components/calendar/EventEditor.js +5 -10
  137. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -2
  138. package/dist/apps/shared/components/calendar/EventFormParts.js +5 -16
  139. package/dist/apps/shared/components/calendar/GuestInput.d.ts +45 -0
  140. package/dist/apps/shared/components/calendar/GuestInput.js +30 -0
  141. package/dist/apps/shared/components/calendar/RequestChangeForm.js +11 -22
  142. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -2
  143. package/dist/apps/shared/components/calendar/eventFormat.d.ts +21 -8
  144. package/dist/apps/shared/components/calendar/eventFormat.js +50 -23
  145. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -2
  146. package/dist/apps/shared/components/contacts/ContactsSidebar.js +1 -1
  147. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  148. package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
  149. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -3
  150. package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
  151. package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
  152. package/dist/apps/shared/components/layout/UserMenu.d.ts +4 -1
  153. package/dist/apps/shared/components/layout/UserMenu.js +6 -5
  154. package/dist/apps/shared/components/mail/ConversationThreadPane.js +127 -22
  155. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -1
  156. package/dist/apps/shared/components/mail/MailSelectionBar.js +3 -3
  157. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -4
  158. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +21 -1
  159. package/dist/apps/shared/components/mail/compose/RecipientInput.js +18 -10
  160. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.d.ts +6 -0
  161. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.js +9 -0
  162. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
  163. package/dist/apps/shared/components/mail/layout/MailShell.js +64 -45
  164. package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
  165. package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
  166. package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
  167. package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
  168. package/dist/apps/shared/components/settings/layout/SettingsShell.js +6 -5
  169. package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
  170. package/dist/apps/shared/components/tasks/layout/TasksShell.js +4 -3
  171. package/dist/apps/shared/mail/folderCounts.d.ts +10 -0
  172. package/dist/apps/shared/mail/folderCounts.js +12 -0
  173. package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +80 -0
  174. package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
  175. package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
  176. package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
  177. package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
  178. package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
  179. package/dist/apps/shared/mail/primaryMailbox.js +61 -0
  180. package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
  181. package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
  182. package/dist/apps/shared/mail/useMailConnection.js +3 -1
  183. package/dist/apps/shared/search/crossMailboxSearch.d.ts +72 -0
  184. package/dist/apps/shared/search/crossMailboxSearch.js +136 -0
  185. package/dist/apps/shared/styles/app.css +396 -386
  186. package/dist/apps/www/calendar/index.js +3 -2
  187. package/dist/apps/www/contacts/index.js +3 -1
  188. package/dist/apps/www/index.js +426 -189
  189. package/package.json +2 -2
@@ -173,5 +173,5 @@ function ErasureRequestsSection() {
173
173
  setDenying(false);
174
174
  }
175
175
  }
176
- return (_jsxs("section", { children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted mb-3", children: "Erasure requests" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Self-service only \u2014 a mailbox owner requests their own account's erasure; there is no admin-initiated path here, only review." }), list.loadError && _jsx(Alert, { children: list.loadError }), list.loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : list.items.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No erasure requests." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: list.items.map((request) => (_jsxs("li", { className: "border border-border rounded-sm p-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsxs("span", { className: "text-sm", children: [request.mailboxUid, " \u00B7 ", new Date(request.dateCreated).toLocaleString(), request.status === "denied" && request.reason && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.reason] })), request.status === "completed" && request.purgedCount !== undefined && (_jsxs("span", { className: "text-text-muted", children: [" \u2014 ", request.purgedCount, " records purged"] }))] }), _jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: statusLabel(request.status) })] }), request.status === "pending" && (_jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "button", className: "!w-auto", onClick: () => setApproveTarget(request), children: "Approve" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setDenyTarget(request), children: "Deny" })] }))] }, request.uid))) })), _jsx(LoadMoreButton, { list: list, label: "Load more erasure requests" }), _jsxs(Modal, { open: approveTarget !== null, onClose: dismissApproveModal, title: "Approve erasure request", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Approving permanently erases the mailbox ", _jsx("strong", { className: "break-all", children: approveTarget?.mailboxUid }), " ", "and everything in it - mail, contacts, calendars, tasks, and notes."] }), _jsx("p", { className: "text-sm font-semibold text-danger mb-4", children: "This is irreversible and cannot be undone." }), approveError && _jsx(Alert, { children: approveError }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: approving, onClick: closeApproveModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: approving, disabled: approving, onClick: handleApprove, children: "Erase mailbox" })] })] }), _jsxs(Modal, { open: denyTarget !== null, onClose: dismissDenyModal, title: "Deny erasure request", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm mb-4", children: [_jsx("span", { className: "font-semibold", children: "Reason" }), _jsx("input", { "aria-label": "Denial reason", className: INPUT_CLASS, value: denyReason, onChange: (e) => setDenyReason(e.target.value) })] }), denyError && _jsx(Alert, { children: denyError }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: denying, onClick: closeDenyModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: denying, disabled: denying || !denyReason.trim(), onClick: handleDeny, children: "Deny" })] })] })] }));
176
+ return (_jsxs("section", { children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted mb-3", children: "Erasure requests" }), _jsxs("p", { className: "text-xs text-text-muted mb-3", children: ["A mailbox owner requests their own account's erasure and it is reviewed here. Erasing the data a", " ", _jsx("em", { children: "deleted" }), " mailbox left behind is started from the ", _jsx("a", { href: "/admin", className: "text-primary-dark hover:underline", children: "Mailboxes page" }), "; those requests are approved when filed and are marked \u201CDeleted mailbox data\u201D below."] }), list.loadError && _jsx(Alert, { children: list.loadError }), list.loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : list.items.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No erasure requests." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: list.items.map((request) => (_jsxs("li", { className: "border border-border rounded-sm p-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsxs("span", { className: "text-sm", children: [request.mailboxUid, " \u00B7 ", new Date(request.dateCreated).toLocaleString(), request.leftoverOnly && _jsx("span", { className: "text-text-muted", children: " \u00B7 Deleted mailbox data" }), request.status === "denied" && request.reason && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.reason] })), request.status === "completed" && request.purgedCount !== undefined && (_jsxs("span", { className: "text-text-muted", children: [" \u2014 ", request.purgedCount, " records purged"] }))] }), _jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: statusLabel(request.status) })] }), request.status === "pending" && (_jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "button", className: "!w-auto", onClick: () => setApproveTarget(request), children: "Approve" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setDenyTarget(request), children: "Deny" })] }))] }, request.uid))) })), _jsx(LoadMoreButton, { list: list, label: "Load more erasure requests" }), _jsxs(Modal, { open: approveTarget !== null, onClose: dismissApproveModal, title: "Approve erasure request", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Approving permanently erases the mailbox ", _jsx("strong", { className: "break-all", children: approveTarget?.mailboxUid }), " ", "and everything in it - mail, contacts, calendars, tasks, and notes."] }), _jsx("p", { className: "text-sm font-semibold text-danger mb-4", children: "This is irreversible and cannot be undone." }), approveError && _jsx(Alert, { children: approveError }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: approving, onClick: closeApproveModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: approving, disabled: approving, onClick: handleApprove, children: "Erase mailbox" })] })] }), _jsxs(Modal, { open: denyTarget !== null, onClose: dismissDenyModal, title: "Deny erasure request", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm mb-4", children: [_jsx("span", { className: "font-semibold", children: "Reason" }), _jsx("input", { "aria-label": "Denial reason", className: INPUT_CLASS, value: denyReason, onChange: (e) => setDenyReason(e.target.value) })] }), denyError && _jsx(Alert, { children: denyError }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: denying, onClick: closeDenyModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: denying, disabled: denying || !denyReason.trim(), onClick: handleDeny, children: "Deny" })] })] })] }));
177
177
  }
@@ -8,6 +8,7 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import { listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
9
9
  import { reopenSetup } from "@rapidmx/react-shared/admin/setupApi.js";
10
10
  import AdminShell from "../shared/components/admin/layout/AdminShell.js";
11
+ import LeftoverMailboxesSection from "../shared/components/admin/mailboxes/LeftoverMailboxesSection.js";
11
12
  import MailboxTable from "../shared/components/admin/mailboxes/MailboxTable.js";
12
13
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
13
14
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
@@ -45,5 +46,5 @@ function MailboxesListContent() {
45
46
  setConfirmingSetup(false);
46
47
  }
47
48
  }
48
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-5", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Mailboxes" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: reopening, onClick: () => setConfirmingSetup(true), children: "Run setup again" }), _jsx("a", { href: "/admin/mailboxes/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New mailbox" }) })] })] }), _jsx("p", { className: "text-sm text-text-muted mb-4", children: "Administrative details only (addresses, owners, quota). The console never shows a mailbox\u2019s mail: to see someone\u2019s account, impersonate them from their mailbox page." }), error && _jsx(Alert, { children: error }), _jsxs(Modal, { open: confirmingSetup, onClose: () => setConfirmingSetup(false), title: "Run setup again?", children: [_jsx("p", { className: "text-sm mb-4", children: "Setup starts again from its first step, and the admin console sends administrators to it until it's finished. Your existing settings, domains and mailboxes are kept." }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingSetup(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: reopening, disabled: reopening, onClick: () => void handleRunSetup(), children: "Run setup" })] })] }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(MailboxTable, { mailboxes: mailboxes })), _jsxs("div", { className: "flex gap-3 items-center mt-4", children: [_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: page === 0 || loading, onClick: () => setPage((p) => Math.max(0, p - 1)), children: "Previous" }), _jsxs("span", { className: "text-sm text-text-muted", children: ["Page ", page + 1] }), _jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: !hasNextPage || loading, onClick: () => setPage((p) => p + 1), children: "Next" })] })] }));
49
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-5", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Mailboxes" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: reopening, onClick: () => setConfirmingSetup(true), children: "Run setup again" }), _jsx("a", { href: "/admin/mailboxes/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New mailbox" }) })] })] }), _jsx("p", { className: "text-sm text-text-muted mb-4", children: "Administrative details only (addresses, owners, quota). The console never shows a mailbox\u2019s mail: to see someone\u2019s account, impersonate them from their mailbox page." }), error && _jsx(Alert, { children: error }), _jsxs(Modal, { open: confirmingSetup, onClose: () => setConfirmingSetup(false), title: "Run setup again?", children: [_jsx("p", { className: "text-sm mb-4", children: "Setup starts again from its first step, and the admin console sends administrators to it until it's finished. Your existing settings, domains and mailboxes are kept." }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingSetup(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: reopening, disabled: reopening, onClick: () => void handleRunSetup(), children: "Run setup" })] })] }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(MailboxTable, { mailboxes: mailboxes })), _jsxs("div", { className: "flex gap-3 items-center mt-4", children: [_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: page === 0 || loading, onClick: () => setPage((p) => Math.max(0, p - 1)), children: "Previous" }), _jsxs("span", { className: "text-sm text-text-muted", children: ["Page ", page + 1] }), _jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: !hasNextPage || loading, onClick: () => setPage((p) => p + 1), children: "Next" })] }), _jsx(LeftoverMailboxesSection, {})] }));
49
50
  }
@@ -35,6 +35,9 @@ function MailboxDetailContent({ uid, impersonationBaseUrl, currentUserUid, }) {
35
35
  const [confirmingDelete, setConfirmingDelete] = useState(false);
36
36
  const [deleting, setDeleting] = useState(false);
37
37
  const [deleteError, setDeleteError] = useState(null);
38
+ // Deleting keeps the mailbox's data; this also erases it in the same step, after the address is typed to confirm.
39
+ const [eraseData, setEraseData] = useState(false);
40
+ const [typedAddress, setTypedAddress] = useState("");
38
41
  // Ignored while the action is under way (Escape/Close; Cancel is disabled then too), so its result or error
39
42
  // can't land on a modal reopened afterwards.
40
43
  function closeAccessModal() {
@@ -61,6 +64,9 @@ function MailboxDetailContent({ uid, impersonationBaseUrl, currentUserUid, }) {
61
64
  if (deleting)
62
65
  return;
63
66
  setConfirmingDelete(false);
67
+ setEraseData(false);
68
+ setTypedAddress("");
69
+ setDeleteError(null);
64
70
  }
65
71
  // Only ever invoked from the delete-confirmation modal below, which itself only renders once
66
72
  // `mailbox` is resolved (the `error || !mailbox` branch below returns before this content mounts).
@@ -68,7 +74,8 @@ function MailboxDetailContent({ uid, impersonationBaseUrl, currentUserUid, }) {
68
74
  setDeleting(true);
69
75
  setDeleteError(null);
70
76
  try {
71
- await deleteMailbox(mailbox.uid, mailbox.version);
77
+ await deleteMailbox(mailbox.uid, mailbox.version, { erase: eraseData });
78
+ // The Mailboxes page it goes to lists what a delete without `erase` kept, and how far an erasure has got.
72
79
  window.location.href = "/admin";
73
80
  }
74
81
  catch (err) {
@@ -98,5 +105,8 @@ function MailboxDetailContent({ uid, impersonationBaseUrl, currentUserUid, }) {
98
105
  const ingestQueueHref = `/admin/ingest-queue?mailboxUid=${encodeURIComponent(mailbox.uid)}`;
99
106
  return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All mailboxes" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: mailbox.primarySmtpAddress })] }), _jsxs("div", { className: "flex gap-3 shrink-0", children: [mailbox.ownerUserUid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingAccess(true), children: "Impersonate this user" })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete mailbox" })] })] }), _jsxs("p", { className: "text-sm text-text-muted", children: ["The console shows administrative details only - never a mailbox\u2019s mail or settings.", mailbox.ownerUserUid
100
107
  ? " To see this mailbox as its owner sees it, impersonate them: that is recorded, and you can stop at any time."
101
- : " This shared mailbox has no owner: add yourself under Shared access to open it in the mail client."] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Display name" }), _jsx("dd", { children: mailbox.displayName }), _jsx("dt", { className: "text-text-muted", children: "Owner" }), _jsx("dd", { children: mailbox.ownerUserUid ?? "None (shared mailbox)" }), _jsx("dt", { className: "text-text-muted", children: "Timezone" }), _jsx("dd", { children: mailbox.timezone }), _jsx("dt", { className: "text-text-muted", children: "Quota" }), _jsxs("dd", { children: [formatBytes(mailbox.usedBytes), " / ", formatBytes(mailbox.quotaBytes)] }), _jsx("dt", { className: "text-text-muted", children: "Alias addresses" }), _jsx("dd", { children: mailbox.aliasAddresses.length > 0 ? mailbox.aliasAddresses.join(", ") : "None" }), mailbox.isResource && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Resource type" }), _jsx("dd", { className: "capitalize", children: mailbox.resourceType ?? "room" })] })), _jsx("dt", { className: "text-text-muted", children: "Created" }), _jsx("dd", { children: new Date(mailbox.dateCreated).toLocaleString() })] }), _jsxs("div", { className: "flex gap-4 mt-5 pt-5 border-t border-border text-sm font-medium", children: [_jsx("a", { href: quarantineHref, className: "text-primary-dark hover:underline", children: "View quarantine" }), _jsx("a", { href: ingestQueueHref, className: "text-primary-dark hover:underline", children: "View ingest queue" })] })] }), _jsx(ShareAccessCard, { mailboxUid: mailbox.uid, ownerUserUid: mailbox.ownerUserUid, currentUserUid: currentUserUid }), _jsx(EscrowScopeCard, { mailbox: mailbox, onUpdate: setMailbox }), mailbox.isResource && _jsx(ResourceSettingsCard, { mailbox: mailbox, onUpdate: setMailbox }), _jsxs(Modal, { open: confirmingAccess, onClose: closeAccessModal, title: "Impersonate this user", children: [_jsxs("p", { className: "text-sm mb-3", children: ["You\u2019ll be signed in as ", _jsx("strong", { className: "break-all", children: mailbox.ownerUserUid }), ", the owner of ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), ", and see everything they can - and act as them, until you stop impersonating. This is the only way an administrator sees another user\u2019s mail."] }), accessError && _jsx(Alert, { children: accessError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: impersonating, onClick: closeAccessModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: impersonating, disabled: impersonating, onClick: handleAccessMailbox, children: "Impersonate" })] })] }), _jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete mailbox", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Are you sure you want to delete ", _jsx("strong", { children: mailbox.primarySmtpAddress }), "? This permanently deletes the mailbox and everything in it, and cannot be undone. It fails if this mailbox is a custodian on an active legal hold."] }), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting, onClick: handleDelete, children: "Delete" })] })] })] }));
108
+ : " This shared mailbox has no owner: add yourself under Shared access to open it in the mail client."] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Display name" }), _jsx("dd", { children: mailbox.displayName }), _jsx("dt", { className: "text-text-muted", children: "Owner" }), _jsx("dd", { children: mailbox.ownerUserUid ?? "None (shared mailbox)" }), _jsx("dt", { className: "text-text-muted", children: "Timezone" }), _jsx("dd", { children: mailbox.timezone }), _jsx("dt", { className: "text-text-muted", children: "Quota" }), _jsxs("dd", { children: [formatBytes(mailbox.usedBytes), " / ", formatBytes(mailbox.quotaBytes)] }), _jsx("dt", { className: "text-text-muted", children: "Alias addresses" }), _jsx("dd", { children: mailbox.aliasAddresses.length > 0 ? mailbox.aliasAddresses.join(", ") : "None" }), mailbox.isResource && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Resource type" }), _jsx("dd", { className: "capitalize", children: mailbox.resourceType ?? "room" })] })), _jsx("dt", { className: "text-text-muted", children: "Created" }), _jsx("dd", { children: new Date(mailbox.dateCreated).toLocaleString() })] }), _jsxs("div", { className: "flex gap-4 mt-5 pt-5 border-t border-border text-sm font-medium", children: [_jsx("a", { href: quarantineHref, className: "text-primary-dark hover:underline", children: "View quarantine" }), _jsx("a", { href: ingestQueueHref, className: "text-primary-dark hover:underline", children: "View ingest queue" })] })] }), _jsx(ShareAccessCard, { mailboxUid: mailbox.uid, ownerUserUid: mailbox.ownerUserUid, currentUserUid: currentUserUid }), _jsx(EscrowScopeCard, { mailbox: mailbox, onUpdate: setMailbox }), mailbox.isResource && _jsx(ResourceSettingsCard, { mailbox: mailbox, onUpdate: setMailbox }), _jsxs(Modal, { open: confirmingAccess, onClose: closeAccessModal, title: "Impersonate this user", children: [_jsxs("p", { className: "text-sm mb-3", children: ["You\u2019ll be signed in as ", _jsx("strong", { className: "break-all", children: mailbox.ownerUserUid }), ", the owner of ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), ", and see everything they can - and act as them, until you stop impersonating. This is the only way an administrator sees another user\u2019s mail."] }), accessError && _jsx(Alert, { children: accessError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: impersonating, onClick: closeAccessModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: impersonating, disabled: impersonating, onClick: handleAccessMailbox, children: "Impersonate" })] })] }), _jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete mailbox", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Are you sure you want to delete ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), "? This cannot be undone. It fails if this mailbox is a custodian on an active legal hold."] }), _jsxs("p", { className: "text-sm mb-3", children: ["Deleting removes the mailbox itself. Its mail, contacts, calendars and everything else in it are", " ", _jsx("strong", { children: "kept" }), " until they are erased, and until then the address can\u2019t be used for a new mailbox. You can erase them later from the Mailboxes page."] }), _jsxs("label", { className: "flex items-start gap-2 text-sm mb-3", children: [_jsx("input", { type: "checkbox", className: "mt-0.5", checked: eraseData, disabled: deleting, onChange: (e) => {
109
+ setEraseData(e.target.checked);
110
+ setTypedAddress("");
111
+ } }), _jsx("span", { children: "Also erase all of its data now (permanent)" })] }), eraseData && (_jsxs("div", { className: "mb-3", children: [_jsx("p", { className: "text-sm font-semibold text-danger mb-2", children: "Everything in this mailbox will be permanently erased, and this cannot be undone." }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsxs("span", { className: "font-semibold", children: ["Type ", _jsx("span", { className: "break-all", children: mailbox.primarySmtpAddress }), " to confirm"] }), _jsx("input", { "aria-label": "Type the address to confirm", className: "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary", value: typedAddress, disabled: deleting, autoComplete: "off", spellCheck: false, onChange: (e) => setTypedAddress(e.target.value) })] })] })), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting || (eraseData && typedAddress.trim().toLowerCase() !== mailbox.primarySmtpAddress.trim().toLowerCase()), onClick: handleDelete, children: eraseData ? "Delete and erase all its data" : "Delete" })] })] })] }));
102
112
  }
@@ -199,10 +199,10 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
199
199
  id === active
200
200
  ? "bg-primary/10 text-primary-dark"
201
201
  : "text-text-muted hover:bg-surface-alt hover:text-text",
202
- ].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(Drawer, { open: menuOpen, onClose: () => setMenuOpen(false), title: "Admin", children: _jsx("nav", { "aria-label": "Admin menu", className: "flex flex-col gap-1 -mx-2", children: navItems.map(({ id, href, label, icon: Icon }) => (_jsxs("a", { href: href, "aria-current": id === active ? "page" : undefined, onClick: () => setMenuOpen(false), className: [
202
+ ].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(Drawer, { open: menuOpen, onClose: () => setMenuOpen(false), title: "Admin", fullScreen: true, children: _jsx("nav", { "aria-label": "Admin menu", className: "flex flex-col gap-1 -mx-2", children: navItems.map(({ id, href, label, icon: Icon }) => (_jsxs("a", { href: href, "aria-current": id === active ? "page" : undefined, onClick: () => setMenuOpen(false), className: [
203
203
  "flex items-center gap-3 px-3 py-2.5 rounded-sm text-sm font-medium",
204
204
  id === active ? "bg-primary/10 text-primary-dark" : "text-text hover:bg-surface-alt",
205
- ].join(" "), children: [_jsx(Icon, { size: 20, "aria-hidden": "true" }), label] }, id))) }) }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-4 md:px-6", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("button", { type: "button", className: "md:hidden shrink-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open menu", onClick: () => setMenuOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide truncate", children: activeItem?.label })] }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut })] }), _jsx("div", { id: "app-content", className: "flex-1", children: _jsx("main", { className: "max-w-6xl mx-auto px-6 py-8", children: children }) })] })] }) }));
205
+ ].join(" "), children: [_jsx(Icon, { size: 20, "aria-hidden": "true" }), label] }, id))) }) }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-4 md:px-6", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("button", { type: "button", className: "md:hidden shrink-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open menu", onClick: () => setMenuOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide truncate", children: activeItem?.label })] }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut, showMailLink: true })] }), _jsx("div", { id: "app-content", className: "flex-1", children: _jsx("main", { className: "max-w-6xl mx-auto px-6 py-8", children: children }) })] })] }) }));
206
206
  }
207
207
  // The user's own colours and background (`AppearanceProvider`), from what the last webmail page cached in this browser: the console never asks the server.
208
208
  return (_jsxs(AppearanceProvider, { userUid: userUid, lookUp: false, children: [content, _jsx(FrameBrandingFooter, { parsed: footer, appTitle: [...adminNavItems(pluginNav), ...OFF_RAIL_ITEMS].find((item) => item.id === active)?.label })] }));
@@ -0,0 +1,35 @@
1
+ import React from "react";
2
+ import { DataSubjectErasureStatus } from "@rapidmx/react-shared/mail/erasureRequestApi.js";
3
+ /** How often the request is asked how far the erasure has got. */
4
+ export declare const ERASE_POLL_INTERVAL_MS = 2000;
5
+ export interface EraseLeftoverDataDialogProps {
6
+ /** The deleted mailbox's address. */
7
+ address: string;
8
+ /** What is left, when the caller knows it (the list of deleted mailboxes does): named in the confirmation. */
9
+ counts?: {
10
+ folders: number;
11
+ messages: number;
12
+ };
13
+ /** An erasure already filed for the address (approved or running): the dialog then watches it instead of asking first. */
14
+ resume?: {
15
+ uid: string;
16
+ status: DataSubjectErasureStatus;
17
+ };
18
+ /** The button shown once the data is erased (`onDone` runs when it is pressed - the dialog closes itself first). Default "Done". */
19
+ doneLabel?: string;
20
+ onDone?: () => void;
21
+ /** Called once when the data is erased - whether or not the dialog is still open by then. */
22
+ onErased?: (address: string) => void;
23
+ onClose: () => void;
24
+ /** How often to ask how far the erasure has got. */
25
+ pollIntervalMs?: number;
26
+ }
27
+ /**
28
+ * Erases the data a deleted mailbox left behind - permanently - after the administrator types its address to confirm, then shows
29
+ * how far it has got: the erasure runs on the server in the background, so this asks about it every few seconds until it has
30
+ * finished or been refused. Mount it (with the address as its `key`) only while it is wanted; it holds its own state.
31
+ *
32
+ * The request that files the erasure can't be interrupted (closing is ignored while it is under way); once it is filed, closing
33
+ * only stops watching - the erasure carries on, and the deleted-mailbox list shows it.
34
+ */
35
+ export default function EraseLeftoverDataDialog({ address, counts, resume, doneLabel, onDone, onErased, onClose, pollIntervalMs, }: EraseLeftoverDataDialogProps): React.JSX.Element;
@@ -0,0 +1,115 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useRef, useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { eraseLeftoverMailbox } from "@rapidmx/react-shared/admin/leftoverMailboxApi.js";
9
+ import { getErasureRequest } from "@rapidmx/react-shared/mail/erasureRequestApi.js";
10
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
12
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
13
+ import { notify } from "../../../notifications/store.js";
14
+ const INPUT_CLASS = "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
15
+ const DANGER_BUTTON_CLASS = "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger";
16
+ /** How often the request is asked how far the erasure has got. */
17
+ export const ERASE_POLL_INTERVAL_MS = 2000;
18
+ /** Where an erasure is, in words. */
19
+ function progressLabel(status) {
20
+ return status === "in_progress" ? "Erasing the data…" : "Waiting for the erasure to start…";
21
+ }
22
+ /**
23
+ * Erases the data a deleted mailbox left behind - permanently - after the administrator types its address to confirm, then shows
24
+ * how far it has got: the erasure runs on the server in the background, so this asks about it every few seconds until it has
25
+ * finished or been refused. Mount it (with the address as its `key`) only while it is wanted; it holds its own state.
26
+ *
27
+ * The request that files the erasure can't be interrupted (closing is ignored while it is under way); once it is filed, closing
28
+ * only stops watching - the erasure carries on, and the deleted-mailbox list shows it.
29
+ */
30
+ export default function EraseLeftoverDataDialog({ address, counts, resume, doneLabel = "Done", onDone, onErased, onClose, pollIntervalMs = ERASE_POLL_INTERVAL_MS, }) {
31
+ const [phase, setPhase] = useState(resume ? "watching" : "confirm");
32
+ const [typed, setTyped] = useState("");
33
+ const [requestUid, setRequestUid] = useState(resume?.uid);
34
+ const [status, setStatus] = useState(resume?.status);
35
+ const [error, setError] = useState(null);
36
+ // A check on the erasure failed (offline, a dropped connection): it is tried again, and the admin is told meanwhile.
37
+ const [checkFailed, setCheckFailed] = useState(false);
38
+ const onErasedRef = useRef(onErased);
39
+ onErasedRef.current = onErased;
40
+ const confirmed = typed.trim().toLowerCase() === address.trim().toLowerCase();
41
+ async function handleErase(e) {
42
+ e.preventDefault();
43
+ if (!confirmed) {
44
+ return;
45
+ }
46
+ setError(null);
47
+ setPhase("filing");
48
+ try {
49
+ const request = await eraseLeftoverMailbox(address);
50
+ setRequestUid(request.uid);
51
+ setStatus(request.status);
52
+ setPhase("watching");
53
+ }
54
+ catch (err) {
55
+ // The server's own words: a legal hold names its matter, a mailbox that exists again says so.
56
+ setError(err instanceof ApiRequestError ? err.message : "Could not start the erasure.");
57
+ setPhase("confirm");
58
+ }
59
+ }
60
+ // Watches the request until it has finished or been refused.
61
+ useEffect(() => {
62
+ if (phase !== "watching" || !requestUid) {
63
+ return;
64
+ }
65
+ let cancelled = false;
66
+ let timer;
67
+ const check = async () => {
68
+ try {
69
+ const request = await getErasureRequest(requestUid);
70
+ if (cancelled) {
71
+ return;
72
+ }
73
+ setCheckFailed(false);
74
+ setStatus(request.status);
75
+ if (request.status === "completed") {
76
+ setPhase("done");
77
+ notify({ kind: "success", title: "Leftover data erased", message: `Everything left over from ${address} was erased.` });
78
+ onErasedRef.current?.(address);
79
+ return;
80
+ }
81
+ if (request.status === "denied") {
82
+ setError(request.reason || "The erasure was refused.");
83
+ setPhase("refused");
84
+ return;
85
+ }
86
+ }
87
+ catch {
88
+ if (cancelled) {
89
+ return;
90
+ }
91
+ setCheckFailed(true);
92
+ }
93
+ timer = setTimeout(() => void check(), pollIntervalMs);
94
+ };
95
+ void check();
96
+ return () => {
97
+ cancelled = true;
98
+ clearTimeout(timer);
99
+ };
100
+ }, [phase, requestUid, address, pollIntervalMs]);
101
+ // Closing is ignored while the request that files the erasure is under way, so its answer can't land on a dialog that was dismissed.
102
+ function dismiss() {
103
+ if (phase !== "filing") {
104
+ onClose();
105
+ }
106
+ }
107
+ function handleDone() {
108
+ onClose();
109
+ onDone?.();
110
+ }
111
+ const summary = counts
112
+ ? `It still has ${counts.folders} ${counts.folders === 1 ? "folder" : "folders"} and ${counts.messages} ${counts.messages === 1 ? "message" : "messages"}, and everything else stored with them.`
113
+ : "It still has its folders and everything stored in them.";
114
+ return (_jsxs(Modal, { open: true, onClose: dismiss, title: "Erase leftover data", children: [(phase === "confirm" || phase === "filing") && (_jsxs("form", { onSubmit: handleErase, children: [_jsxs("p", { className: "text-sm mb-3", children: [_jsx("strong", { className: "break-all", children: address }), " was deleted, but its data was kept. ", summary] }), _jsx("p", { className: "text-sm mb-3", children: "Erasing permanently deletes all of it - the mail, drafts and attachments, contacts, calendar events, tasks, notes, labels, filter rules and signatures, encryption keys, share links and access lists - and frees the address for a new mailbox." }), _jsx("p", { className: "text-sm font-semibold text-danger mb-4", children: "This is irreversible and cannot be undone." }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm mb-4", children: [_jsxs("span", { className: "font-semibold", children: ["Type ", _jsx("span", { className: "break-all", children: address }), " to confirm"] }), _jsx("input", { "aria-label": "Type the address to confirm", className: INPUT_CLASS, value: typed, disabled: phase === "filing", autoComplete: "off", spellCheck: false, onChange: (e) => setTyped(e.target.value) })] }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: phase === "filing", onClick: dismiss, children: "Cancel" }), _jsx(Button, { type: "submit", className: DANGER_BUTTON_CLASS, loading: phase === "filing", disabled: phase === "filing" || !confirmed, children: "Erase data" })] })] })), phase === "watching" && (_jsxs("div", { children: [_jsxs("p", { className: "text-sm mb-3", children: ["Erasing the data left over from ", _jsx("strong", { className: "break-all", children: address }), "."] }), _jsxs("p", { role: "status", className: "flex items-center gap-2 text-sm font-semibold mb-3", children: [_jsx("span", { className: "w-4 h-4 rounded-full border-2 border-current/30 border-t-current animate-spin", "aria-hidden": "true" }), progressLabel(status)] }), checkFailed && (_jsx("p", { className: "text-sm text-danger mb-3", children: "Could not check how far the erasure has got. Trying again\u2026" })), _jsx("p", { className: "text-sm text-text-muted mb-4", children: "You can close this window: the erasure carries on in the background, and the Mailboxes page shows how it is going." }), _jsx("div", { className: "flex justify-end", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Stop watching" }) })] })), phase === "done" && (_jsxs("div", { children: [_jsxs("p", { role: "status", className: "text-sm mb-4", children: ["Everything left over from ", _jsx("strong", { className: "break-all", children: address }), " was erased. The address is free to use again."] }), _jsx("div", { className: "flex justify-end", children: _jsx(Button, { type: "button", className: "!w-auto", onClick: handleDone, children: doneLabel }) })] })), phase === "refused" && (_jsxs("div", { children: [_jsx(Alert, { children: error }), _jsx("div", { className: "flex justify-end", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "OK" }) })] }))] }));
115
+ }
@@ -0,0 +1,16 @@
1
+ import React from "react";
2
+ /** How often the list is refreshed while an erasure is under way. */
3
+ export declare const LEFTOVER_REFRESH_INTERVAL_MS = 5000;
4
+ export interface LeftoverMailboxesSectionProps {
5
+ /** How often the list is refreshed while an erasure is under way. */
6
+ refreshIntervalMs?: number;
7
+ /** How often an open erasure asks how far it has got. */
8
+ pollIntervalMs?: number;
9
+ }
10
+ /**
11
+ * "Deleted mailboxes with remaining data": deleting a mailbox keeps everything in it, and the address can't be used again until
12
+ * that is erased, so this lists what is left (from `GET /mailboxes/leftover`) with an "Erase data" action for each. It shows nothing
13
+ * at all while there is nothing left - the section is only there when it is needed. An erasure that is under way shows as such, and
14
+ * the list refreshes itself until it has finished.
15
+ */
16
+ export default function LeftoverMailboxesSection({ refreshIntervalMs, pollIntervalMs }: LeftoverMailboxesSectionProps): React.JSX.Element;
@@ -0,0 +1,92 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useRef, useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { listLeftoverMailboxes } from "@rapidmx/react-shared/admin/leftoverMailboxApi.js";
9
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
+ import EraseLeftoverDataDialog from "./EraseLeftoverDataDialog.js";
12
+ /** How many deleted mailboxes one request asks for. */
13
+ const PAGE_SIZE = 50;
14
+ /** How often the list is refreshed while an erasure is under way. */
15
+ export const LEFTOVER_REFRESH_INTERVAL_MS = 5000;
16
+ const HEADING_CLASS = "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border";
17
+ const CELL_CLASS = "py-2.5 px-2.5 border-b border-border align-middle";
18
+ /** Whether the erasure the server last filed for `item` is still to finish. */
19
+ function isErasing(item) {
20
+ return item.erasure?.status === "approved" || item.erasure?.status === "in_progress";
21
+ }
22
+ /**
23
+ * "Deleted mailboxes with remaining data": deleting a mailbox keeps everything in it, and the address can't be used again until
24
+ * that is erased, so this lists what is left (from `GET /mailboxes/leftover`) with an "Erase data" action for each. It shows nothing
25
+ * at all while there is nothing left - the section is only there when it is needed. An erasure that is under way shows as such, and
26
+ * the list refreshes itself until it has finished.
27
+ */
28
+ export default function LeftoverMailboxesSection({ refreshIntervalMs = LEFTOVER_REFRESH_INTERVAL_MS, pollIntervalMs }) {
29
+ const [items, setItems] = useState([]);
30
+ const [next, setNext] = useState(undefined);
31
+ const [error, setError] = useState(null);
32
+ const [loadingMore, setLoadingMore] = useState(false);
33
+ const [target, setTarget] = useState(null);
34
+ // Only the newest reload is applied: a slow earlier one can't put back what a later one already removed.
35
+ const generation = useRef(0);
36
+ async function reload() {
37
+ const current = ++generation.current;
38
+ try {
39
+ const page = await listLeftoverMailboxes({ limit: PAGE_SIZE });
40
+ if (current !== generation.current)
41
+ return;
42
+ setItems(page.items);
43
+ setNext(page.next);
44
+ setError(null);
45
+ }
46
+ catch (err) {
47
+ if (current === generation.current) {
48
+ setError(err instanceof ApiRequestError ? err.message : "Could not check for deleted mailboxes with remaining data.");
49
+ }
50
+ }
51
+ }
52
+ async function loadMore() {
53
+ const current = generation.current;
54
+ setLoadingMore(true);
55
+ try {
56
+ const page = await listLeftoverMailboxes({ limit: PAGE_SIZE, after: next });
57
+ if (current !== generation.current)
58
+ return;
59
+ setItems((existing) => [...existing, ...page.items.filter((item) => !existing.some((seen) => seen.mailboxUid === item.mailboxUid))]);
60
+ setNext(page.next);
61
+ }
62
+ catch (err) {
63
+ if (current === generation.current) {
64
+ setError(err instanceof ApiRequestError ? err.message : "Could not load more deleted mailboxes.");
65
+ }
66
+ }
67
+ finally {
68
+ setLoadingMore(false);
69
+ }
70
+ }
71
+ useEffect(() => {
72
+ void reload();
73
+ }, []);
74
+ const anyErasing = items.some(isErasing);
75
+ useEffect(() => {
76
+ if (!anyErasing) {
77
+ return;
78
+ }
79
+ const timer = setInterval(() => void reload(), refreshIntervalMs);
80
+ return () => clearInterval(timer);
81
+ }, [anyErasing, refreshIntervalMs]);
82
+ // Only there when something is left (or the check failed).
83
+ const section = items.length > 0 || error !== null ? (_jsxs("section", { className: "mt-10", "aria-labelledby": "leftover-heading", children: [_jsx("h2", { id: "leftover-heading", className: "text-sm font-bold uppercase tracking-wide text-text-muted mb-2", children: "Deleted mailboxes with remaining data" }), _jsx("p", { className: "text-sm text-text-muted mb-3", children: "Deleting a mailbox keeps everything in it, and its address can\u2019t be used for a new mailbox until that data is erased. Erasing is permanent." }), error && _jsx(Alert, { children: error }), items.length > 0 && (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("caption", { className: "sr-only", children: "Deleted mailboxes with remaining data" }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", className: HEADING_CLASS, children: "Address" }), _jsx("th", { scope: "col", className: HEADING_CLASS, children: "Folders" }), _jsx("th", { scope: "col", className: HEADING_CLASS, children: "Messages" }), _jsx("th", { scope: "col", className: HEADING_CLASS, children: "Erasure" }), _jsx("th", { scope: "col", className: "border-b border-border", children: _jsx("span", { className: "sr-only", children: "Actions" }) })] }) }), _jsx("tbody", { children: items.map((item) => (_jsxs("tr", { children: [_jsx("td", { className: `${CELL_CLASS} break-all`, children: item.mailboxUid }), _jsx("td", { className: CELL_CLASS, children: item.folderCount }), _jsx("td", { className: CELL_CLASS, children: item.messageCount }), _jsx("td", { className: CELL_CLASS, children: isErasing(item) ? (_jsx("span", { className: "inline-block text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Erasing" })) : (_jsx("span", { className: "text-text-muted", children: "Not erased" })) }), _jsx("td", { className: `${CELL_CLASS} text-right`, children: _jsx(Button, { type: "button", variant: "secondary", className: isErasing(item) ? "!w-auto" : "!w-auto !border-danger !text-danger hover:!border-danger hover:!text-danger", "aria-label": `${isErasing(item) ? "Show the erasure of" : "Erase data of"} ${item.mailboxUid}`, onClick: () => setTarget(item), children: isErasing(item) ? "Show progress" : "Erase data" }) })] }, item.mailboxUid))) })] }) })), next !== undefined && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto mt-3", loading: loadingMore, disabled: loadingMore, onClick: () => void loadMore(), children: "Load more" }))] })) : null;
84
+ // The dialog outlives the row it was opened from - erasing the last one empties the list while its "erased" message is still up - and
85
+ // keeps its place among the fragment's children whether or not the section is there, so it is not rebuilt (and does not lose where it
86
+ // is) when the section goes.
87
+ const dialog = target && (_jsx(EraseLeftoverDataDialog, { address: target.mailboxUid, counts: { folders: target.folderCount, messages: target.messageCount }, resume: isErasing(target) && target.erasure ? { uid: target.erasure.uid, status: target.erasure.status } : undefined, pollIntervalMs: pollIntervalMs, onErased: () => void reload(), onClose: () => {
88
+ setTarget(null);
89
+ void reload();
90
+ } }, target.mailboxUid));
91
+ return (_jsxs(_Fragment, { children: [section, dialog] }));
92
+ }
@@ -12,9 +12,9 @@ import CopyButton from "@rapidmx/react-shared/components/buttons/CopyButton.js";
12
12
  const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
13
13
  const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
14
14
  /** A header that uses both variables - the snippet the Branding page offers to copy. */
15
- export const HEADER_HTML_EXAMPLE = `<div style="display:flex; align-items:center; justify-content:space-between; padding:0.5rem 1rem">
16
- <span>{APP_TITLE}</span>
17
- <span>{USER_MENU}</span>
15
+ export const HEADER_HTML_EXAMPLE = `<div style="display:flex; align-items:center; justify-content:space-between; padding:0.5rem 1rem">
16
+ <span>{APP_TITLE}</span>
17
+ <span>{USER_MENU}</span>
18
18
  </div>`;
19
19
  /** SVG logo/icon uploads are refused (the server refuses them too): an SVG served from this origin can carry script. */
20
20
  export function isSvgFile(file) {
@@ -6,12 +6,14 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
6
6
  import { useEffect, useRef, useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import { getMailboxPolicy } from "@rapidmx/react-shared/admin/mailboxPolicyApi.js";
9
+ import { leftoverConflictOf } from "@rapidmx/react-shared/admin/leftoverMailboxApi.js";
9
10
  import { createMailbox, listMailboxDomains, resolveMailboxOwner } from "@rapidmx/react-shared/mail/mailApi.js";
10
11
  import { deviceTimeZone } from "@rapidmx/react-shared/util/timeZone.js";
11
12
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
13
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
13
14
  import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
14
15
  import PrincipalResolver, { describePerson } from "../../sharing/PrincipalResolver.js";
16
+ import EraseLeftoverDataDialog from "../mailboxes/EraseLeftoverDataDialog.js";
15
17
  const INPUT_CLASS = "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
16
18
  const SELECT_CLASS = "text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
17
19
  /**
@@ -40,6 +42,10 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
40
42
  const [maxDurationMinutes, setMaxDurationMinutes] = useState("");
41
43
  const [error, setError] = useState(null);
42
44
  const [saving, setSaving] = useState(false);
45
+ // The address a deleted mailbox left data at, when creating a mailbox there was refused for that - and what to do about it.
46
+ const [leftover, setLeftover] = useState(null);
47
+ const [erasing, setErasing] = useState(false);
48
+ const formRef = useRef(null);
43
49
  // An empty `mail:domains` list (the default) means this server enforces no domain restriction —
44
50
  // the address field stays free text, exactly as before. Once an admin configures at least one
45
51
  // domain, every mailbox this server creates must be on one of them (enforced server-side too, in
@@ -61,10 +67,15 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
61
67
  .catch(() => undefined);
62
68
  }, []);
63
69
  const constrained = domains.length > 0;
70
+ const address = constrained ? `${localPart.trim()}@${domain}` : primarySmtpAddress.trim();
71
+ // What was said about one address is not said about another: typing a different one takes the explanation away.
72
+ useEffect(() => {
73
+ setLeftover((current) => (current && current.address !== address ? null : current));
74
+ }, [address]);
64
75
  async function handleSubmit(e) {
65
76
  e.preventDefault();
66
77
  setError(null);
67
- const address = constrained ? `${localPart.trim()}@${domain}` : primarySmtpAddress.trim();
78
+ setLeftover(null);
68
79
  if (constrained ? !localPart.trim() : !primarySmtpAddress.trim()) {
69
80
  setError("A primary SMTP address is required.");
70
81
  return;
@@ -106,13 +117,24 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
106
117
  onCreated(mailbox);
107
118
  }
108
119
  catch (err) {
109
- setError(err instanceof ApiRequestError ? err.message : "Could not create the mailbox.");
120
+ const conflict = leftoverConflictOf(err);
121
+ if (conflict) {
122
+ // Not a plain failure: the address is taken by what a deleted mailbox left, which can be erased right here.
123
+ setLeftover({ address, conflict });
124
+ }
125
+ else {
126
+ setError(err instanceof ApiRequestError ? err.message : "Could not create the mailbox.");
127
+ }
110
128
  }
111
129
  finally {
112
130
  setSaving(false);
113
131
  }
114
132
  }
115
- return (_jsxs(_Fragment, { children: [error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "bg-surface border border-border rounded-md p-6", children: [constrained ? (_jsx(FormField, { label: "Local part", htmlFor: "localPart", children: _jsxs("div", { className: "flex gap-2 items-center", children: [_jsx("input", { id: "localPart", type: "text", className: INPUT_CLASS, value: localPart, onChange: (e) => setLocalPart(e.target.value), placeholder: "support" }), _jsx("span", { className: "text-text-muted shrink-0", children: "@" }), _jsx("select", { "aria-label": "Domain", className: SELECT_CLASS, value: domain, onChange: (e) => setDomain(e.target.value), children: domains.map((d) => (_jsx("option", { value: d, children: d }, d))) })] }) })) : (_jsx(FormField, { label: "Primary SMTP address", htmlFor: "primarySmtpAddress", children: _jsx("input", { id: "primarySmtpAddress", type: "email", className: INPUT_CLASS, value: primarySmtpAddress, onChange: (e) => setPrimarySmtpAddress(e.target.value), placeholder: "support@example.com" }) })), _jsx(FormField, { label: "Display name", htmlFor: "displayName", children: _jsx("input", { id: "displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value), placeholder: "Support" }) }), _jsxs("div", { className: "mb-4", children: [_jsx("span", { className: "block text-sm font-semibold mb-1.5 text-text", children: "Owner" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "radio", name: "ownerMode", checked: ownerMode === "shared", onChange: () => {
133
+ return (_jsxs(_Fragment, { children: [error && _jsx(Alert, { children: error }), leftover && (_jsxs("div", { role: "alert", className: "py-3 px-3.5 rounded-sm text-sm mb-5 bg-danger-bg text-danger", children: [_jsx("p", { className: "font-semibold mb-1", children: leftover.conflict.reason === "mailbox-data-erasing"
134
+ ? "The data left at this address is being erased."
135
+ : "This address still has data from a deleted mailbox." }), _jsx("p", { className: "mb-3", children: leftover.conflict.reason === "mailbox-data-erasing"
136
+ ? "A new mailbox can be created there once the erasure has finished."
137
+ : "Deleting a mailbox keeps everything in it, and a new mailbox can't take the address until that data is erased." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setErasing(true), children: leftover.conflict.reason === "mailbox-data-erasing" ? "Show the erasure" : "Erase the leftover data" })] })), erasing && leftover && (_jsx(EraseLeftoverDataDialog, { address: leftover.address, resume: leftover.conflict.reason === "mailbox-data-erasing" ? leftover.conflict.erasure : undefined, doneLabel: "Create mailbox", onDone: () => formRef.current?.requestSubmit(), onClose: () => setErasing(false) }, leftover.address)), _jsxs("form", { ref: formRef, onSubmit: handleSubmit, className: "bg-surface border border-border rounded-md p-6", children: [constrained ? (_jsx(FormField, { label: "Local part", htmlFor: "localPart", children: _jsxs("div", { className: "flex gap-2 items-center", children: [_jsx("input", { id: "localPart", type: "text", className: INPUT_CLASS, value: localPart, onChange: (e) => setLocalPart(e.target.value), placeholder: "support" }), _jsx("span", { className: "text-text-muted shrink-0", children: "@" }), _jsx("select", { "aria-label": "Domain", className: SELECT_CLASS, value: domain, onChange: (e) => setDomain(e.target.value), children: domains.map((d) => (_jsx("option", { value: d, children: d }, d))) })] }) })) : (_jsx(FormField, { label: "Primary SMTP address", htmlFor: "primarySmtpAddress", children: _jsx("input", { id: "primarySmtpAddress", type: "email", className: INPUT_CLASS, value: primarySmtpAddress, onChange: (e) => setPrimarySmtpAddress(e.target.value), placeholder: "support@example.com" }) })), _jsx(FormField, { label: "Display name", htmlFor: "displayName", children: _jsx("input", { id: "displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value), placeholder: "Support" }) }), _jsxs("div", { className: "mb-4", children: [_jsx("span", { className: "block text-sm font-semibold mb-1.5 text-text", children: "Owner" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "radio", name: "ownerMode", checked: ownerMode === "shared", onChange: () => {
116
138
  setOwnerMode("shared");
117
139
  setResolvedOwner(null);
118
140
  } }), "Shared mailbox (no single owner)"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "radio", name: "ownerMode", checked: ownerMode === "owned", onChange: () => setOwnerMode("owned") }), "Owned by a specific person"] }), ownerMode === "owned" &&
@@ -131,6 +131,7 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
131
131
  timezone: occurrence?.timezone ?? deviceZone,
132
132
  attendees: occurrence?.attendees ?? [],
133
133
  guestDraft: "",
134
+ guestInvalid: [],
134
135
  recurrenceRule: occurrence?.recurrenceRule ?? null,
135
136
  reminderMinutes: occurrence?.reminderMinutesBeforeStart?.toString() ?? "",
136
137
  busyStatus: occurrence?.busyStatus ?? "busy",
@@ -195,12 +196,7 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
195
196
  }, [occurrence?.videoMeetingUid]);
196
197
  // "This event only" detaches a standalone, non-repeating copy - the series' rule doesn't apply to it.
197
198
  const editingSingleOccurrence = !!occurrence?.isRecurringOccurrence && editScope === "occurrence";
198
- dirtyRef.current = !!(values.title.trim() || values.location.trim() || values.attendees.length > 0 || values.guestDraft.trim() || hasDescriptionText(values.descriptionHtml));
199
- function addGuests(text) {
200
- const merged = mergeGuests(values.attendees, text);
201
- update({ attendees: merged.attendees, guestDraft: merged.invalid.join(" ") });
202
- return merged.invalid;
203
- }
199
+ dirtyRef.current = !!(values.title.trim() || values.location.trim() || values.attendees.length > 0 || values.guestDraft.trim() || values.guestInvalid.length > 0 || hasDescriptionText(values.descriptionHtml));
204
200
  function updateAttendee(index, patch) {
205
201
  setValues((prev) => ({ ...prev, attendees: prev.attendees.map((a, i) => (i === index ? { ...a, ...patch } : a)) }));
206
202
  }
@@ -382,8 +378,8 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
382
378
  setError("A title is required.");
383
379
  return;
384
380
  }
385
- // An address typed into the guests field but not added yet is added now - or, not being one, stops the save.
386
- const merged = mergeGuests(values.attendees, values.guestDraft);
381
+ // An address typed into the guests field but not added yet is added now - or, not being one (nor what was already flagged there), stops the save.
382
+ const merged = mergeGuests(values.attendees, [...values.guestInvalid, values.guestDraft].join(", "), [effectiveOrganizerAddress]);
387
383
  if (merged.invalid.length > 0) {
388
384
  setError(`“${merged.invalid[0]}” isn’t a valid email address.`);
389
385
  return;
@@ -394,7 +390,7 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
394
390
  return;
395
391
  }
396
392
  if (values.guestDraft) {
397
- update({ attendees: merged.attendees, guestDraft: "" });
393
+ update({ attendees: merged.attendees, guestDraft: "", guestInvalid: [] });
398
394
  }
399
395
  let startDate;
400
396
  let endDate;
@@ -521,7 +517,6 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
521
517
  deviceZone,
522
518
  startWeekday: startWeekdayCode(values.allDay ? values.start : Number.isNaN(startMs) ? "" : new Date(startMs).toISOString(), values.allDay, values.timezone),
523
519
  editingSingleOccurrence,
524
- addGuests,
525
520
  updateAttendee,
526
521
  removeAttendee,
527
522
  addResource,
@@ -17,8 +17,9 @@ export declare function VideoConferencingRow({ c }: {
17
17
  c: EventFormController;
18
18
  }): React.JSX.Element;
19
19
  /**
20
- * The guests field: an address box (Enter, a comma or a semicolon adds what is typed) and what has been added - chips in the quick popover,
21
- * rows with a role, the answer so far and a remove button in the full form, which also offers rooms and equipment.
20
+ * The guests field: compose's recipient field (`GuestInput`), which looks a name up among the contacts and the server's mailboxes and lists and
21
+ * turns what is picked, typed or pasted into chips - drawn in the field itself in the quick popover. In the full form the guests are rows with a
22
+ * role, the answer so far and a remove button instead (the field then draws only what is not an address), and rooms and equipment can be added.
22
23
  */
23
24
  export declare function GuestsField({ c, compact }: {
24
25
  c: EventFormController;