portal-mojo 0.1.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 (369) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +55 -0
  3. package/docs/DetailView.md +173 -0
  4. package/docs/ModelTable.md +128 -0
  5. package/docs/README.md +98 -0
  6. package/docs/admin-assistant.md +72 -0
  7. package/docs/admin-bouncer.md +120 -0
  8. package/docs/admin-cloudwatch.md +38 -0
  9. package/docs/admin-credentials.md +116 -0
  10. package/docs/admin-dashboard.md +15 -0
  11. package/docs/admin-devices-geoip.md +284 -0
  12. package/docs/admin-dns-certificates.md +122 -0
  13. package/docs/admin-dns-records.md +65 -0
  14. package/docs/admin-dns-registrar.md +33 -0
  15. package/docs/admin-dns.md +80 -0
  16. package/docs/admin-identity-users.md +103 -0
  17. package/docs/admin-incidents.md +71 -0
  18. package/docs/admin-jobs.md +426 -0
  19. package/docs/admin-members.md +112 -0
  20. package/docs/admin-messaging.md +56 -0
  21. package/docs/admin-metrics-explorer.md +63 -0
  22. package/docs/admin-monitoring.md +99 -0
  23. package/docs/admin-network-security.md +393 -0
  24. package/docs/admin-phonehub.md +27 -0
  25. package/docs/admin-push.md +36 -0
  26. package/docs/admin-rules.md +87 -0
  27. package/docs/admin-security-tickets.md +40 -0
  28. package/docs/admin-settings.md +78 -0
  29. package/docs/admin-shortlinks.md +32 -0
  30. package/docs/admin-storage.md +141 -0
  31. package/docs/auth-pages.md +212 -0
  32. package/docs/auth.md +58 -0
  33. package/docs/calendar.md +122 -0
  34. package/docs/charts.md +303 -0
  35. package/docs/client.md +206 -0
  36. package/docs/collection-multiselect.md +117 -0
  37. package/docs/collection-select.md +132 -0
  38. package/docs/combobox.md +105 -0
  39. package/docs/dataview.md +226 -0
  40. package/docs/datepicker.md +151 -0
  41. package/docs/daterange.md +170 -0
  42. package/docs/datetimepicker.md +249 -0
  43. package/docs/defineModel.md +72 -0
  44. package/docs/detail-primitives.md +174 -0
  45. package/docs/feedback.md +85 -0
  46. package/docs/file-upload-ux.md +146 -0
  47. package/docs/forms.md +435 -0
  48. package/docs/grouping-and-fmt.md +98 -0
  49. package/docs/idioms.md +112 -0
  50. package/docs/image-editor.md +101 -0
  51. package/docs/loading.md +242 -0
  52. package/docs/location-address.md +76 -0
  53. package/docs/markdown.md +203 -0
  54. package/docs/menus-and-access.md +89 -0
  55. package/docs/multiselect-dropdown.md +153 -0
  56. package/docs/params.md +73 -0
  57. package/docs/popover.md +130 -0
  58. package/docs/realtime.md +61 -0
  59. package/docs/record-feed.md +238 -0
  60. package/docs/right-panel.md +42 -0
  61. package/docs/tabs-and-form-wizard.md +60 -0
  62. package/docs/taginput.md +120 -0
  63. package/docs/timepicker.md +173 -0
  64. package/docs/timezone-select.md +147 -0
  65. package/docs/worldmap.md +302 -0
  66. package/package.json +55 -0
  67. package/src/admin/assistant/AssistantFeed.tsx +29 -0
  68. package/src/admin/assistant/AssistantPanel.tsx +65 -0
  69. package/src/admin/assistant/api.ts +17 -0
  70. package/src/admin/assistant/data.ts +63 -0
  71. package/src/admin/assistant/index.ts +21 -0
  72. package/src/admin/assistant/launchers.tsx +33 -0
  73. package/src/admin/assistant/pages.tsx +78 -0
  74. package/src/admin/assistant/streaming.ts +238 -0
  75. package/src/admin/assistant/types.ts +30 -0
  76. package/src/admin/bouncer/devices.tsx +230 -0
  77. package/src/admin/bouncer/index.ts +34 -0
  78. package/src/admin/bouncer/models.ts +199 -0
  79. package/src/admin/bouncer/signals.tsx +182 -0
  80. package/src/admin/bouncer/signatures.tsx +220 -0
  81. package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
  82. package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
  83. package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
  84. package/src/admin/cloudwatch/data.ts +163 -0
  85. package/src/admin/cloudwatch/index.ts +16 -0
  86. package/src/admin/credentials/group-api-keys.tsx +433 -0
  87. package/src/admin/credentials/index.ts +4 -0
  88. package/src/admin/credentials/models.ts +244 -0
  89. package/src/admin/credentials/secret-dialog.tsx +77 -0
  90. package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
  91. package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
  92. package/src/admin/dashboard/data.ts +60 -0
  93. package/src/admin/dashboard/index.ts +2 -0
  94. package/src/admin/dns/CertificateDetail.tsx +115 -0
  95. package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
  96. package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
  97. package/src/admin/dns/CertificatesPage.tsx +85 -0
  98. package/src/admin/dns/DnsRecordEditor.tsx +110 -0
  99. package/src/admin/dns/DnsRecordsPage.tsx +36 -0
  100. package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
  101. package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
  102. package/src/admin/dns/DomainDetail.tsx +41 -0
  103. package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
  104. package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
  105. package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
  106. package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
  107. package/src/admin/dns/DomainsPage.tsx +27 -0
  108. package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
  109. package/src/admin/dns/RegistrantContactPage.tsx +42 -0
  110. package/src/admin/dns/api.ts +515 -0
  111. package/src/admin/dns/certificate-data.ts +221 -0
  112. package/src/admin/dns/data.ts +32 -0
  113. package/src/admin/dns/dns-data.ts +266 -0
  114. package/src/admin/dns/dns-integration.ts +24 -0
  115. package/src/admin/dns/domain-links.ts +37 -0
  116. package/src/admin/dns/index.ts +86 -0
  117. package/src/admin/dns/models.ts +418 -0
  118. package/src/admin/dns/purchase-data.ts +67 -0
  119. package/src/admin/dns/registrant-data.ts +52 -0
  120. package/src/admin/identity/index.ts +5 -0
  121. package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
  122. package/src/admin/identity/members/MemberDetail.tsx +226 -0
  123. package/src/admin/identity/members/MembersPage.tsx +144 -0
  124. package/src/admin/identity/members/index.ts +24 -0
  125. package/src/admin/identity/members/member-flows.tsx +132 -0
  126. package/src/admin/identity/members/models.ts +204 -0
  127. package/src/admin/identity/users/UserDetail.tsx +257 -0
  128. package/src/admin/identity/users/UsersPage.tsx +163 -0
  129. package/src/admin/identity/users/index.ts +27 -0
  130. package/src/admin/identity/users/models.ts +232 -0
  131. package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
  132. package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
  133. package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
  134. package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
  135. package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
  136. package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
  137. package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
  138. package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
  139. package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
  140. package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
  141. package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
  142. package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
  143. package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
  144. package/src/admin/identity/users/sections/actions.tsx +752 -0
  145. package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
  146. package/src/admin/identity/users/sections/queries.ts +74 -0
  147. package/src/admin/identity/users/sections/shared.tsx +299 -0
  148. package/src/admin/incidents/EventDetail.tsx +43 -0
  149. package/src/admin/incidents/EventsPage.tsx +52 -0
  150. package/src/admin/incidents/IncidentDetail.tsx +68 -0
  151. package/src/admin/incidents/IncidentsPage.tsx +102 -0
  152. package/src/admin/incidents/forensics.tsx +73 -0
  153. package/src/admin/incidents/index.ts +16 -0
  154. package/src/admin/incidents/models.ts +113 -0
  155. package/src/admin/incidents/sanitize.ts +71 -0
  156. package/src/admin/index.ts +383 -0
  157. package/src/admin/jobs/JobDashboardPage.tsx +111 -0
  158. package/src/admin/jobs/JobDetail.tsx +721 -0
  159. package/src/admin/jobs/JobRunnersPage.tsx +216 -0
  160. package/src/admin/jobs/JobsTablePage.tsx +122 -0
  161. package/src/admin/jobs/RunnerDetail.tsx +576 -0
  162. package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
  163. package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
  164. package/src/admin/jobs/columns.tsx +297 -0
  165. package/src/admin/jobs/control.ts +488 -0
  166. package/src/admin/jobs/index.ts +85 -0
  167. package/src/admin/jobs/models.ts +599 -0
  168. package/src/admin/jobs/queries.ts +272 -0
  169. package/src/admin/jobs/scheduled-task-form.tsx +493 -0
  170. package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
  171. package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
  172. package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
  173. package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
  174. package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
  175. package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
  176. package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
  177. package/src/admin/messaging/MailboxesPage.tsx +15 -0
  178. package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
  179. package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
  180. package/src/admin/messaging/SentMessagesPage.tsx +11 -0
  181. package/src/admin/messaging/api.ts +17 -0
  182. package/src/admin/messaging/data.ts +10 -0
  183. package/src/admin/messaging/index.ts +23 -0
  184. package/src/admin/messaging/models.ts +76 -0
  185. package/src/admin/messaging/push/PushPage.tsx +44 -0
  186. package/src/admin/messaging/push/api.ts +17 -0
  187. package/src/admin/messaging/push/index.ts +9 -0
  188. package/src/admin/messaging/push/models.ts +43 -0
  189. package/src/admin/messaging/sanitize.ts +44 -0
  190. package/src/admin/monitoring/LogInspector.tsx +160 -0
  191. package/src/admin/monitoring/LogsPage.tsx +135 -0
  192. package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
  193. package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
  194. package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
  195. package/src/admin/monitoring/index.ts +55 -0
  196. package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
  197. package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
  198. package/src/admin/monitoring/models.ts +155 -0
  199. package/src/admin/network/BlockedIPsPage.tsx +194 -0
  200. package/src/admin/network/BlockedIpDetail.tsx +262 -0
  201. package/src/admin/network/FirewallLogPage.tsx +115 -0
  202. package/src/admin/network/IPSetDetail.tsx +280 -0
  203. package/src/admin/network/IPSetEditor.tsx +191 -0
  204. package/src/admin/network/IPSetsPage.tsx +177 -0
  205. package/src/admin/network/geofence/BlocksTab.tsx +289 -0
  206. package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
  207. package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
  208. package/src/admin/network/geofence/PostureHeader.tsx +184 -0
  209. package/src/admin/network/geofence/RuleEditor.tsx +176 -0
  210. package/src/admin/network/geofence/RulesTab.tsx +254 -0
  211. package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
  212. package/src/admin/network/geofence/geofence-data.ts +674 -0
  213. package/src/admin/network/index.ts +68 -0
  214. package/src/admin/network/models.ts +607 -0
  215. package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
  216. package/src/admin/phonehub/api.ts +47 -0
  217. package/src/admin/phonehub/data.ts +6 -0
  218. package/src/admin/phonehub/index.ts +10 -0
  219. package/src/admin/phonehub/models.ts +68 -0
  220. package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
  221. package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
  222. package/src/admin/rules/RuleSetsPage.tsx +33 -0
  223. package/src/admin/rules/editors.tsx +54 -0
  224. package/src/admin/rules/handler-dsl.ts +213 -0
  225. package/src/admin/rules/index.ts +13 -0
  226. package/src/admin/rules/models.ts +112 -0
  227. package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
  228. package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
  229. package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
  230. package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
  231. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
  232. package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
  233. package/src/admin/security/devices/index.ts +40 -0
  234. package/src/admin/security/devices/models.ts +470 -0
  235. package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
  236. package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
  237. package/src/admin/security/geoip/geoip-forms.ts +102 -0
  238. package/src/admin/security/geoip/index.ts +27 -0
  239. package/src/admin/security/geoip/models.ts +397 -0
  240. package/src/admin/security/index.ts +31 -0
  241. package/src/admin/security/models.ts +137 -0
  242. package/src/admin/security/tickets.tsx +562 -0
  243. package/src/admin/security-permissions.ts +5 -0
  244. package/src/admin/settings/SettingDetail.tsx +57 -0
  245. package/src/admin/settings/SettingEditor.tsx +157 -0
  246. package/src/admin/settings/SettingsPage.tsx +72 -0
  247. package/src/admin/settings/index.ts +19 -0
  248. package/src/admin/settings/model.ts +143 -0
  249. package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
  250. package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
  251. package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
  252. package/src/admin/shortlinks/index.ts +18 -0
  253. package/src/admin/shortlinks/models.ts +246 -0
  254. package/src/admin/storage/BackendsPage.tsx +43 -0
  255. package/src/admin/storage/BucketDetail.tsx +77 -0
  256. package/src/admin/storage/BucketsPage.tsx +64 -0
  257. package/src/admin/storage/FileManagerDetail.tsx +36 -0
  258. package/src/admin/storage/FilePreview.tsx +54 -0
  259. package/src/admin/storage/FileUploadSurface.tsx +250 -0
  260. package/src/admin/storage/FileView.tsx +109 -0
  261. package/src/admin/storage/FilesPage.tsx +53 -0
  262. package/src/admin/storage/file-renditions.ts +99 -0
  263. package/src/admin/storage/index.ts +31 -0
  264. package/src/admin/storage/models.ts +585 -0
  265. package/src/admin/storage/storage-dialogs.tsx +80 -0
  266. package/src/charts/CircularProgress.tsx +281 -0
  267. package/src/charts/KPIStrip.tsx +162 -0
  268. package/src/charts/KPITile.tsx +125 -0
  269. package/src/charts/MetricsChart.tsx +459 -0
  270. package/src/charts/MetricsMiniWidget.tsx +588 -0
  271. package/src/charts/MiniChart.tsx +283 -0
  272. package/src/charts/PieChart.tsx +354 -0
  273. package/src/charts/SeriesChart.tsx +378 -0
  274. package/src/charts/chart-dialogs.tsx +149 -0
  275. package/src/charts/exportChart.ts +144 -0
  276. package/src/charts/index.ts +28 -0
  277. package/src/charts/pie-math.ts +127 -0
  278. package/src/charts/stats.ts +265 -0
  279. package/src/charts/worldmap/WorldMap.tsx +649 -0
  280. package/src/charts/worldmap/countryCentroids.ts +304 -0
  281. package/src/charts/worldmap/geo.ts +293 -0
  282. package/src/charts/worldmap/use-world-land.ts +47 -0
  283. package/src/charts/worldmap/world-land.ts +18 -0
  284. package/src/charts/worldmap/worldmap-data.ts +285 -0
  285. package/src/client/auth.ts +737 -0
  286. package/src/client/client.ts +268 -0
  287. package/src/client/duid.ts +31 -0
  288. package/src/client/errors.ts +33 -0
  289. package/src/client/group-context.ts +28 -0
  290. package/src/client/group.tsx +126 -0
  291. package/src/client/hooks.ts +50 -0
  292. package/src/client/index.ts +37 -0
  293. package/src/client/jwt.ts +75 -0
  294. package/src/client/location.ts +285 -0
  295. package/src/client/lookups.ts +41 -0
  296. package/src/client/markdown-fallback.tsx +115 -0
  297. package/src/client/markdown-parse.ts +386 -0
  298. package/src/client/markdown-sanitize.ts +217 -0
  299. package/src/client/markdown.ts +122 -0
  300. package/src/client/me.ts +188 -0
  301. package/src/client/mock.ts +9984 -0
  302. package/src/client/model.ts +227 -0
  303. package/src/client/params.ts +292 -0
  304. package/src/client/realtime-mock.ts +221 -0
  305. package/src/client/realtime.ts +561 -0
  306. package/src/client/record-feed.ts +329 -0
  307. package/src/client/safe-export.ts +71 -0
  308. package/src/client/types.ts +278 -0
  309. package/src/client/upload.ts +421 -0
  310. package/src/ui/AddressField.tsx +273 -0
  311. package/src/ui/AttachmentQueue.tsx +92 -0
  312. package/src/ui/CollectionMultiSelect.tsx +622 -0
  313. package/src/ui/CollectionSelect.tsx +517 -0
  314. package/src/ui/ComboBox.tsx +336 -0
  315. package/src/ui/DetailView.tsx +438 -0
  316. package/src/ui/FileDrop.tsx +194 -0
  317. package/src/ui/FileField.tsx +369 -0
  318. package/src/ui/FilterBar.tsx +269 -0
  319. package/src/ui/FormFields.tsx +77 -0
  320. package/src/ui/FormView.tsx +360 -0
  321. package/src/ui/FormWizard.tsx +336 -0
  322. package/src/ui/GroupSwitcher.tsx +202 -0
  323. package/src/ui/Guarded.tsx +16 -0
  324. package/src/ui/MarkdownView.tsx +89 -0
  325. package/src/ui/ModelTable.tsx +848 -0
  326. package/src/ui/MultiSelectDropdown.tsx +390 -0
  327. package/src/ui/Popover.tsx +204 -0
  328. package/src/ui/RecordFeed.tsx +627 -0
  329. package/src/ui/RequiresGroup.tsx +32 -0
  330. package/src/ui/RightPanel.tsx +107 -0
  331. package/src/ui/SidebarNav.tsx +330 -0
  332. package/src/ui/Tabs.tsx +161 -0
  333. package/src/ui/TagInput.tsx +381 -0
  334. package/src/ui/ThemeProvider.tsx +52 -0
  335. package/src/ui/UploadQueue.tsx +603 -0
  336. package/src/ui/armed-button.tsx +91 -0
  337. package/src/ui/date/Calendar.tsx +498 -0
  338. package/src/ui/date/DatePicker.tsx +289 -0
  339. package/src/ui/date/DateRangePicker.tsx +280 -0
  340. package/src/ui/date/DateTimePicker.tsx +587 -0
  341. package/src/ui/date/PresetRail.tsx +165 -0
  342. package/src/ui/date/TimePicker.tsx +624 -0
  343. package/src/ui/date/TimezoneSelect.tsx +267 -0
  344. package/src/ui/date/fns.ts +679 -0
  345. package/src/ui/detail/DataView.tsx +692 -0
  346. package/src/ui/detail/FlowStrip.tsx +80 -0
  347. package/src/ui/detail/KnownFieldsCard.tsx +121 -0
  348. package/src/ui/detail/MetadataSection.tsx +219 -0
  349. package/src/ui/detail/StackTraceView.tsx +150 -0
  350. package/src/ui/detail/StatusPanel.tsx +73 -0
  351. package/src/ui/detail/Timeline.tsx +52 -0
  352. package/src/ui/detail/index.ts +12 -0
  353. package/src/ui/field-registry.tsx +428 -0
  354. package/src/ui/field-wire.ts +322 -0
  355. package/src/ui/form-autosave.ts +583 -0
  356. package/src/ui/format.ts +444 -0
  357. package/src/ui/grouping.ts +150 -0
  358. package/src/ui/image-editor/ImageEditor.tsx +484 -0
  359. package/src/ui/image-editor/canvas.ts +254 -0
  360. package/src/ui/image-editor/index.ts +3 -0
  361. package/src/ui/image-editor/math.ts +416 -0
  362. package/src/ui/index.ts +58 -0
  363. package/src/ui/loading.tsx +604 -0
  364. package/src/ui/menu-registry.ts +213 -0
  365. package/src/ui/modal.tsx +297 -0
  366. package/src/ui/password.tsx +302 -0
  367. package/src/ui/schema-form-core.tsx +315 -0
  368. package/src/ui/toast.tsx +219 -0
  369. package/src/ui/ui.tsx +45 -0
@@ -0,0 +1,17 @@
1
+ import { useState } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { ArmedButton, Badge, DetailView, FlatRow, ModelTable, SchemaForm, fmt, formModal, modal, toast, type Column, type FilterDef, type FormData } from '../../ui';
4
+ import { auditEmailDomain, deleteMessagingRow, reconcileEmailDomain, saveEmailDomainAtomic } from './api';
5
+ import { exportEmailDomains } from './data';
6
+ import { openEmailDomainWizard } from './EmailDomainWizard';
7
+ import { EmailDomainModel, type EmailDomainRow } from './models';
8
+ import { redactMessagingError } from './sanitize';
9
+
10
+ const columns:Column<EmailDomainRow>[]=[{key:'name',label:'Domain',sortable:true,hideable:false},{key:'status',label:'Status',sortable:true,render:r=><Badge tone={r.status==='verified'||r.status==='ready'?'success':r.status==='missing'?'danger':'warning'}>{r.status}</Badge>},{key:'region',label:'Region',sortable:true},{key:'receiving_enabled',label:'Receiving',sortable:true,render:r=>r.receiving_enabled?'Enabled':'Disabled'},{key:'dns_mode',label:'DNS',sortable:true},{key:'created',label:'Created',sortable:true,render:r=>fmt.date(r.created)}];
11
+ const filters:FilterDef[]=[{key:'region',label:'Region',type:'text'},{key:'status',label:'Status',type:'select',options:['pending','verified','ready','missing'].map(v=>({value:v,label:v}))},{key:'receiving_enabled',label:'Receiving',type:'boolean'},{key:'dns_mode',label:'DNS mode',type:'select',options:[{value:'manual',label:'Manual'},{value:'route53',label:'Route 53'},{value:'godaddy',label:'GoDaddy'}]}];
12
+ const fields=[{name:'name',label:'Domain',type:'text',required:true},{name:'region',label:'AWS region',type:'text',required:true},{name:'receiving_enabled',label:'Enable receiving',type:'switch'},{name:'s3_inbound_bucket',label:'Inbound S3 bucket',type:'text'},{name:'s3_inbound_prefix',label:'Inbound prefix',type:'text'}] as const;
13
+
14
+ function DomainCreate({close}:{close:(value:unknown)=>void}) { const qc=useQueryClient(); const [draft,setDraft]=useState<FormData|null>(null);const [awsKey,setAwsKey]=useState('');const [awsSecret,setAwsSecret]=useState(''); return <div className="modal-pad"><h2 className="modal-title">Create email domain</h2><p className="form-alert">Creation may run best-effort SES audit/reconcile. A created row does not mean AWS converged.</p>{draft?<><p>Ready to create <b>{String(draft.name)}</b>. Optional credentials are write-only and discarded immediately after submission.</p><label className="field"><span className="field-label">AWS access key</span><input type="password" autoComplete="off" value={awsKey} onChange={event=>setAwsKey(event.target.value)}/></label><label className="field"><span className="field-label">AWS secret</span><input type="password" autoComplete="new-password" value={awsSecret} onChange={event=>setAwsSecret(event.target.value)}/></label><div className="modal-actions"><button className="btn" onClick={()=>setDraft(null)}>Back</button><ArmedButton label="Create domain" armedLabel="Click again to create and run best-effort AWS setup" onConfirm={async()=>{const payload={...draft,...(awsKey?{aws_key:awsKey}:{}),...(awsSecret?{aws_secret:awsSecret}:{})};setAwsKey('');setAwsSecret('');setDraft(null);try{const row=await saveEmailDomainAtomic(qc,null,payload);toast.success('Domain row created. AWS convergence is best effort.');close(row);}catch(error){toast.error(redactMessagingError(error));}}} /></div></>:<SchemaForm fields={[...fields]} submitText="Review" onCancel={()=>close(null)} onSubmit={setDraft}/>}</div>; }
15
+ function CredentialDialog({id,close}:{id:number;close:()=>void}){const qc=useQueryClient();const [key,setKey]=useState('');const [secret,setSecret]=useState('');const submit=async()=>{if(!key&&!secret)return;const payload={...(key?{aws_key:key}:{}),...(secret?{aws_secret:secret}:{})};setKey('');setSecret('');try{await saveEmailDomainAtomic(qc,id,payload);toast.success('AWS credentials replaced');close();}catch(error){toast.error(redactMessagingError(error));}};return <div className="modal-pad"><h2 className="modal-title">Replace AWS credentials</h2><p>Write-only values are never prefilled or cached.</p><label className="field"><span className="field-label">AWS access key</span><input type="password" autoComplete="off" value={key} onChange={e=>setKey(e.target.value)}/></label><label className="field"><span className="field-label">AWS secret</span><input type="password" autoComplete="new-password" value={secret} onChange={e=>setSecret(e.target.value)}/></label><div className="modal-actions"><button className="btn" onClick={close}>Cancel</button><ArmedButton label="Replace credentials" armedLabel="Click again to replace write-only credentials" disabled={!key&&!secret} onConfirm={submit}/></div></div>}
16
+ function DomainDetail({id,onClose}:{id:number;onClose:()=>void}) { const qc=useQueryClient(); const query=EmailDomainModel.useOne(id); if(!query.data)return <div className="modal-pad"><p>{query.isLoading?'Loading…':query.error instanceof Error?redactMessagingError(query.error):'Domain unavailable'}</p><button className="btn" onClick={onClose}>Close</button></div>; const row=query.data; const edit=async()=>{const data=await formModal({title:`Edit ${row.name}`,fields:[...fields].filter(f=>f.name!=='name'),initial:row as unknown as FormData});if(!data)return;try{await saveEmailDomainAtomic(qc,id,data);toast.success('Domain row updated');await query.refetch();}catch(error){toast.error(redactMessagingError(error));}}; const audit=async()=>{if(!await modal.confirm({title:'Audit SES domain?',message:'Audit performs a provider request and persists readiness fields.',confirmText:'Run audit'}))return;try{const result=await auditEmailDomain(qc,id);await modal.open((close)=><div className="modal-pad"><h2 className="modal-title">Audit result</h2><Badge tone={result.audit_pass?'success':'danger'}>{result.audit_pass?'Pass':'Attention needed'}</Badge>{Object.entries(result.checks).map(([k,v])=><FlatRow key={k} label={k.replaceAll('_',' ')}>{v?'Pass':'Fail'}</FlatRow>)}{result.items.map((item,index)=><FlatRow key={`${item.resource}:${index}`} label={item.resource}>{item.status}</FlatRow>)}{result.recommendations.map((recommendation,index)=><div className="known-card" key={`${recommendation.resource}:${index}`}><h3>{recommendation.resource||'Recommendation'}{recommendation.severity?` · ${recommendation.severity}`:''}</h3>{recommendation.action&&<p>{recommendation.action}</p>}{recommendation.explanation&&<p className="dim">{recommendation.explanation}</p>}</div>)}<div className="modal-actions"><button className="btn" onClick={()=>close(null)}>Close</button></div></div>);}catch(e){toast.error(redactMessagingError(e));}}; const reconcile=async()=>{if(!await modal.confirm({title:'Reconcile AWS resources?',message:'This may change SES identities, SNS topics, and receiving rules. It does not write DNS.',confirmText:'Reconcile'}))return;try{await reconcileEmailDomain(qc,id);toast.success('Reconcile completed');}catch(e){toast.error(redactMessagingError(e));}}; const onboard=()=>void openEmailDomainWizard(row,qc).catch(error=>toast.error(redactMessagingError(error))); const remove=async()=>{try{await deleteMessagingRow(qc,EmailDomainModel.endpoint,id);toast.success('Local domain row and cascaded mailbox/sent audit rows deleted');onClose();}catch(error){toast.error(redactMessagingError(error));}}; return <DetailView title={row.name} subtitle={`${row.region} · ${row.dns_mode}`} icon="bi-envelope-at" chips={[{text:row.status,tone:row.status==='verified'||row.status==='ready'?'success':'warning'}]} onClose={onClose} initialSection="overview" sections={[{key:'overview',label:'Overview',icon:'bi-info-circle',render:()=><div className="detail-section"><div className="storage-action-row"><button className="btn" onClick={()=>void edit()}>Edit</button><button className="btn" onClick={()=>void modal.open(close=><CredentialDialog id={id} close={()=>close(null)}/>)}>Credentials…</button><button className="btn" onClick={onboard}>Onboard…</button><button className="btn" onClick={()=>void audit()}>Audit…</button><button className="btn" onClick={()=>void reconcile()}>Reconcile…</button></div><FlatRow label="Status">{row.status}</FlatRow><FlatRow label="Receiving">{row.receiving_enabled?'Enabled':'Disabled'}</FlatRow><FlatRow label="S3 bucket">{row.s3_inbound_bucket||'—'}</FlatRow><FlatRow label="S3 prefix">{row.s3_inbound_prefix||'—'}</FlatRow><FlatRow label="Access key">{row.aws_key_masked||'Not configured'}</FlatRow><FlatRow label="Secret key">{row.aws_secret_masked||'Not configured'}</FlatRow><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow></div>},{key:'danger',label:'Delete',icon:'bi-trash',render:()=><div className="detail-section"><h3>Delete local email domain</h3><p>This erases local mailboxes and sent-message audit rows. SES identities, SNS topics/rules, S3 configuration, and provider DNS may remain orphaned.</p><ArmedButton label="Delete local domain" armedLabel={`Click again to delete ${row.name}`} onConfirm={remove}/></div>}]} />; }
17
+ export function EmailDomainsPage(){return <ModelTable model={EmailDomainModel} eyebrow="Communications · SES" title="Email Domains" columns={columns} filters={filters} defaultSort="name" searchable searchPlaceholder="Search domain, region, or status" columnChooser persistState persistKey="admin:email:domains" exportFormats={['csv','json']} exporter={exportEmailDomains} addLabel="Create domain" onAdd={()=>void modal.open(close=><DomainCreate close={close}/>,{size:'lg'})} onRowClick={row=>void modal.detail(close=><DomainDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
@@ -0,0 +1,13 @@
1
+ import { useState } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { ArmedButton, DetailView, FlatRow, ModelTable, Tabs, fmt, modal, toast, type Column } from '../../ui';
4
+ import { deleteMessagingRow } from './api';
5
+ import { exportEmailTemplates } from './data';
6
+ import { EmailTemplateModel, type EmailTemplateRow } from './models';
7
+ import { SandboxedEmailPreview } from './SandboxedEmailPreview';
8
+ import { redactMessagingError } from './sanitize';
9
+
10
+ const columns:Column<EmailTemplateRow>[]=[{key:'name',label:'Template',sortable:true,hideable:false},{key:'modified',label:'Modified',sortable:true,render:r=>fmt.datetime(r.modified)},{key:'created',label:'Created',sortable:true,render:r=>fmt.date(r.created)}];
11
+ function TemplateEditor({row,onSave,onClose}:{row?:EmailTemplateRow;onSave:(changes:Record<string,unknown>)=>Promise<void>;onClose:()=>void}){const [name,setName]=useState(row?.name??'');const [subject,setSubject]=useState(row?.subject_template??'');const [html,setHtml]=useState(row?.html_template??'');const [plain,setPlain]=useState(row?.text_template??'');const [busy,setBusy]=useState(false);const [error,setError]=useState('');const submit=async()=>{if(!name.trim())return;setBusy(true);setError('');try{await onSave({name:name.trim(),subject_template:subject,html_template:html,text_template:plain});onClose();}catch(reason){setError(redactMessagingError(reason));}finally{setBusy(false);}};return <div className="modal-pad"><h2 className="modal-title">{row?'Edit':'Create'} email template</h2>{error&&<div className="form-alert" role="alert">{error}</div>}<label className="field"><span className="field-label">Template name</span><input value={name} onChange={e=>setName(e.target.value)} required/></label><Tabs items={[{key:'subject',label:'Subject',panel:<label className="field"><span className="field-label">Subject template</span><textarea value={subject} onChange={e=>setSubject(e.target.value)}/></label>},{key:'html',label:'HTML',panel:<><label className="field"><span className="field-label">HTML template</span><textarea value={html} onChange={e=>setHtml(e.target.value)}/></label><SandboxedEmailPreview html={html}/></>},{key:'text',label:'Plain text',panel:<label className="field"><span className="field-label">Plain-text template</span><textarea value={plain} onChange={e=>setPlain(e.target.value)}/></label>}]} /><div className="modal-actions"><button className="btn" disabled={busy} onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy||!name.trim()} onClick={()=>void submit()}>{busy?'Saving…':'Save template'}</button></div></div>}
12
+ function TemplateDetail({id,onClose}:{id:number;onClose:()=>void}){const qc=useQueryClient();const query=EmailTemplateModel.useOne(id);const save=EmailTemplateModel.useSave();if(!query.data)return <div className="modal-pad"><p>{query.isLoading?'Loading template…':query.error instanceof Error?redactMessagingError(query.error):'Template unavailable'}</p><button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;const edit=()=>void modal.open(close=><TemplateEditor row={row} onClose={()=>close(null)} onSave={async changes=>{await save.mutateAsync({id,changes});toast.success('Template updated');await query.refetch();}}/>);const remove=async()=>{try{await deleteMessagingRow(qc,EmailTemplateModel.endpoint,id);toast.success('Template deleted');onClose();}catch(error){toast.error(redactMessagingError(error));}};return <DetailView title={row.name} subtitle={row.subject_template||'No subject template'} icon="bi-file-earmark-richtext" onClose={onClose} sections={[{key:'content',label:'Content',icon:'bi-layout-text-window',render:()=><div className="detail-section"><button className="btn" onClick={edit}>Edit</button><Tabs items={[{key:'subject',label:'Subject',panel:<FlatRow label="Subject">{row.subject_template||'—'}</FlatRow>},{key:'html',label:'HTML',panel:<SandboxedEmailPreview html={row.html_template}/>},{key:'text',label:'Plain text',panel:<SandboxedEmailPreview text={row.text_template}/>}]} /></div>},{key:'danger',label:'Delete',icon:'bi-trash',render:()=><div className="detail-section"><p>Future named sends may fail or fall back according to their backend caller after deletion.</p><ArmedButton label="Delete template" armedLabel={`Click again to delete ${row.name}`} onConfirm={remove}/></div>}]} />;}
13
+ export function EmailTemplatesPage(){const save=EmailTemplateModel.useSave();const add=()=>void modal.open(close=><TemplateEditor onClose={()=>close(null)} onSave={async changes=>{await save.mutateAsync({id:null,changes});toast.success('Template created');}}/>);return <ModelTable model={EmailTemplateModel} eyebrow="Communications · SES" title="Email Templates" columns={columns} defaultSort="name" searchable searchPlaceholder="Search template names" columnChooser persistState persistKey="admin:email:templates" exportFormats={['csv','json']} exporter={exportEmailTemplates} addLabel="Create template" onAdd={add} onRowClick={row=>void modal.detail(close=><TemplateDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
@@ -0,0 +1,15 @@
1
+ import { useQueryClient } from '@tanstack/react-query';
2
+ import { ArmedButton, Badge, DetailView, FlatRow, ModelTable, fmt, formModal, modal, toast, type Column, type FilterDef, type FormData } from '../../ui';
3
+ import { deleteMessagingRow, saveMailbox, sendEmail } from './api';
4
+ import { EmailDomainModel, MailboxModel, type MailboxRow, type SendEmailInput } from './models';
5
+ import { redactMessagingError } from './sanitize';
6
+
7
+ const columns:Column<MailboxRow>[]=[{key:'email',label:'Mailbox',sortable:true,hideable:false},{key:'domain',label:'Domain',render:r=>typeof r.domain==='object'?r.domain?.name??`#${r.domain?.id}`:`#${r.domain}`},{key:'allow_inbound',label:'Inbound',sortable:true,render:r=><Badge tone={r.allow_inbound?'success':'muted'}>{r.allow_inbound?'On':'Off'}</Badge>},{key:'allow_outbound',label:'Outbound',sortable:true,render:r=><Badge tone={r.allow_outbound?'success':'muted'}>{r.allow_outbound?'On':'Off'}</Badge>},{key:'is_system_default',label:'System default',sortable:true,render:r=>r.is_system_default?'Yes':'—'},{key:'is_domain_default',label:'Domain default',sortable:true,render:r=>r.is_domain_default?'Yes':'—'},{key:'created',label:'Created',sortable:true,render:r=>fmt.date(r.created)}];
8
+ const filters:FilterDef[]=[{key:'domain',label:'Domain id',type:'number'},{key:'allow_inbound',label:'Inbound',type:'boolean'},{key:'allow_outbound',label:'Outbound',type:'boolean'},{key:'is_system_default',label:'System default',type:'boolean'},{key:'is_domain_default',label:'Domain default',type:'boolean'}];
9
+ const mailboxFields=[{name:'email',label:'Email address',type:'email',required:true},{name:'domain',label:'Email domain',type:'collection',endpoint:EmailDomainModel.endpoint,labelField:'name',valueField:'id',required:true},{name:'allow_inbound',label:'Inbound enabled',type:'switch'},{name:'allow_outbound',label:'Outbound enabled',type:'switch'},{name:'is_system_default',label:'System default',type:'switch',help:'Saving may clear another system default.'},{name:'is_domain_default',label:'Domain default',type:'switch',help:'Saving may clear another default for this domain.'},{name:'async_handler',label:'Async handler',type:'text'}] as const;
10
+ function domainId(row:MailboxRow){return typeof row.domain==='number'?row.domain:row.domain?.id??null;}
11
+ function domainName(row:MailboxRow){return typeof row.domain==='object'?row.domain?.name??'':'';}
12
+ function validMatch(email:string,domain:string){return !domain||email.toLowerCase().endsWith(`@${domain.toLowerCase()}`);}
13
+ async function openSend(row:MailboxRow,qc:ReturnType<typeof useQueryClient>){const data=await formModal({title:`Send test from ${row.email}`,intro:'Only executable send fields are forwarded. A 2xx failed row is still a failed attempt.',fields:[{name:'to',label:'Recipients (comma separated)',type:'text',required:true},{name:'reply_to',label:'Reply-to',type:'email'},{name:'template_name',label:'Template name (optional)',type:'text'},{name:'template_context',label:'Template context JSON',type:'textarea'},{name:'subject',label:'Subject',type:'text'},{name:'body_text',label:'Plain text',type:'textarea'},{name:'body_html',label:'HTML',type:'textarea'}]});if(!data)return;const recipients=String(data.to??'').split(',').map(v=>v.trim()).filter(Boolean);if(!recipients.length){toast.error('Enter at least one recipient');return;}if(!await modal.confirm({title:'Submit email?',message:<><p>Sender: <b>{row.email}</b></p><p>Recipients: {recipients.join(', ')}</p><p>Submission remains pending until webhook updates.</p></>,confirmText:'Submit once'}))return;let context:Record<string,unknown>|undefined;try{if(data.template_context){const parsed=JSON.parse(String(data.template_context));if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw new Error();context=parsed as Record<string,unknown>;}}catch{toast.error('Template context must be a plain JSON object');return;}const input:SendEmailInput={from_email:row.email,to:recipients,...(data.reply_to?{reply_to:String(data.reply_to)}:{}),...(data.template_name?{template_name:String(data.template_name),template_context:context??{}}:{subject:String(data.subject??''),body_text:String(data.body_text??''),body_html:String(data.body_html??'')})};try{const sent=await sendEmail(qc,input);if(sent.status==='failed')toast.error('The delivery attempt failed. Open Sent Messages for details.');else toast.success('Message submitted; delivery is pending webhook confirmation.');}catch(e){toast.error(redactMessagingError(e));}}
14
+ function MailboxDetail({id,onClose}:{id:number;onClose:()=>void}){const qc=useQueryClient();const query=MailboxModel.useOne(id);if(!query.data)return <div className="modal-pad"><p>{query.isLoading?'Loading mailbox…':query.error instanceof Error?redactMessagingError(query.error):'Mailbox unavailable'}</p><button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;const edit=async()=>{const data=await formModal({title:`Edit ${row.email}`,fields:[...mailboxFields],initial:{...row,domain:domainId(row)??''} as unknown as FormData});if(!data)return;try{const chosen=await EmailDomainModel.fetchOne(qc,Number(data.domain));if(!validMatch(String(data.email),chosen.name)){toast.error(`Email must belong to ${chosen.name}`);return;}await saveMailbox(qc,id,data);toast.success('Mailbox updated; competing defaults were refreshed');await query.refetch();}catch(error){toast.error(redactMessagingError(error));}};const remove=async()=>{try{await deleteMessagingRow(qc,MailboxModel.endpoint,id);toast.success('Mailbox and its sent-message audit history deleted');onClose();}catch(error){toast.error(redactMessagingError(error));}};return <DetailView title={row.email} subtitle={domainName(row)||`Domain #${domainId(row)}`} icon="bi-inbox" onClose={onClose} initialSection="overview" sections={[{key:'overview',label:'Overview',icon:'bi-info-circle',render:()=><div className="detail-section"><div className="storage-action-row"><button className="btn" onClick={()=>void edit()}>Edit</button><button className="btn" disabled={!row.allow_outbound} onClick={()=>void openSend(row,qc)}>Send test…</button></div><FlatRow label="Inbound">{row.allow_inbound?'Enabled':'Disabled'}</FlatRow><FlatRow label="Outbound">{row.allow_outbound?'Enabled':'Disabled'}</FlatRow><FlatRow label="System default">{row.is_system_default?'Yes':'No'}</FlatRow><FlatRow label="Domain default">{row.is_domain_default?'Yes':'No'}</FlatRow><FlatRow label="Async handler">{row.async_handler||'—'}</FlatRow></div>},{key:'danger',label:'Delete',icon:'bi-trash',render:()=><div className="detail-section"><p>Deleting this mailbox cascades its sent-message audit history.</p><ArmedButton label="Delete mailbox" armedLabel={`Click again to delete ${row.email}`} onConfirm={remove}/></div>}]} />;}
15
+ export function MailboxesPage(){const qc=useQueryClient();const add=async()=>{const data=await formModal({title:'Create mailbox',fields:[...mailboxFields]});if(!data)return;try{const chosen=await EmailDomainModel.fetchOne(qc,Number(data.domain));if(!validMatch(String(data.email),chosen.name)){toast.error(`Email must belong to ${chosen.name}`);return;}await saveMailbox(qc,null,data);toast.success('Mailbox created');}catch(error){toast.error(redactMessagingError(error));}};return <ModelTable model={MailboxModel} eyebrow="Communications · SES" title="Mailboxes" columns={columns} filters={filters} defaultSort="email" searchable searchPlaceholder="Search mailbox addresses" columnChooser persistState persistKey="admin:email:mailboxes" addLabel="Create mailbox" onAdd={()=>void add()} onRowClick={row=>void modal.detail(close=><MailboxDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
@@ -0,0 +1,13 @@
1
+ import { useQueryClient } from '@tanstack/react-query';
2
+ import { ArmedButton, Badge, DetailView, FlatRow, ModelTable, fmt, groupByDay, modal, toast, type BatchAction, type Column, type FilterDef, type Section } from '../../ui';
3
+ import { useCan } from '../../client';
4
+ import { deleteMessagingRow } from './api';
5
+ import { exportPublicMessages } from './data';
6
+ import { PUBLIC_MESSAGE_DELETE_PERMISSIONS, PUBLIC_MESSAGE_MANAGE_PERMISSIONS, PublicMessageModel, type PublicMessageRow } from './models';
7
+ import { publicMessageMailto, redactMessagingError } from './sanitize';
8
+
9
+ const columns:Column<PublicMessageRow>[]=[{key:'created',label:'Received',sortable:true,hideable:false,render:r=>fmt.datetime(r.created)},{key:'kind',label:'Kind',sortable:true},{key:'name',label:'Name',sortable:true},{key:'email',label:'Email',sortable:true},{key:'subject',label:'Subject',sortable:true},{key:'status',label:'Status',sortable:true,render:r=><Badge tone={r.status==='open'?'warning':'success'}>{r.status}</Badge>}];
10
+ const filters:FilterDef[]=[{key:'kind__in',label:'Kind',type:'multiselect',options:[{value:'contact_us',label:'Contact us'},{value:'support',label:'Support'}]},{key:'status__in',label:'Status',type:'multiselect',options:[{value:'open',label:'Open'},{value:'closed',label:'Closed'}]},{key:'created',label:'Received',type:'daterange'}];
11
+ export function publicMessageGroupLabel(group:PublicMessageRow['group']):string{return group==null?'—':typeof group==='object'?group.name??`#${group.id}`:`#${group}`;}
12
+ function PublicMessageDetail({id,onClose}:{id:number;onClose:()=>void}){const qc=useQueryClient();const query=PublicMessageModel.useOne(id);const save=PublicMessageModel.useSave();const canManage=useCan(PUBLIC_MESSAGE_MANAGE_PERMISSIONS).can;const canDelete=useCan(PUBLIC_MESSAGE_DELETE_PERMISSIONS).can;if(!query.data)return <div className="modal-pad"><p>{query.isLoading?'Loading contact message…':query.error instanceof Error?redactMessagingError(query.error):'Contact message unavailable'}</p><button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;const setStatus=async(status:'open'|'closed')=>{try{await save.mutateAsync({id,changes:{status}});toast.success(`Message marked ${status}`);await query.refetch();}catch(error){toast.error(redactMessagingError(error));}};const remove=async()=>{try{await deleteMessagingRow(qc,PublicMessageModel.endpoint,id);toast.success('Contact message deleted');onClose();}catch(error){toast.error(redactMessagingError(error));}};const mailto=publicMessageMailto(row);const group=publicMessageGroupLabel(row.group);const sections:Section[]=[{key:'message',label:'Message',icon:'bi-envelope-open',render:()=><div className="detail-section"><div className="storage-action-row">{mailto&&<a className="btn" href={mailto}>Open mail app</a>}{canManage&&<button className="btn" onClick={()=>void setStatus(row.status==='open'?'closed':'open')}>Mark {row.status==='open'?'closed':'open'}</button>}</div><FlatRow label="Kind">{row.kind}</FlatRow><FlatRow label="Group">{group}</FlatRow><FlatRow label="Received">{fmt.datetime(row.created)}</FlatRow><div className="known-card"><h3>Message</h3><p className="pre-wrap">{row.message||'—'}</p></div></div>},{key:'context',label:'PII & context',icon:'bi-person-vcard',render:()=><div className="detail-section"><p className="form-alert">This section contains personally identifiable request data.</p><FlatRow label="IP address">{row.ip_address||'—'}</FlatRow><FlatRow label="User agent">{row.user_agent||'—'}</FlatRow>{Object.entries(row.metadata??{}).map(([key,value])=><FlatRow key={key} label={key.replaceAll('_',' ')}>{value}</FlatRow>)}{row.metadata_withheld&&<p className="dim">Additional metadata was withheld by the safe projection.</p>}</div>}];if(canDelete)sections.push({key:'delete',label:'Delete',icon:'bi-trash',render:()=><div className="detail-section"><ArmedButton label="Delete contact message" armedLabel="Click again to permanently delete" onConfirm={remove}/></div>});return <DetailView title={row.subject} subtitle={`${row.name} · ${row.email}`} icon="bi-chat-left-text" chips={[{text:row.status,tone:row.status==='open'?'warning':'success'}]} onClose={onClose} sections={sections} />;}
13
+ export function PublicMessagesPage(){const canManage=useCan(PUBLIC_MESSAGE_MANAGE_PERMISSIONS).can;const save=PublicMessageModel.useSave();const update=async(row:PublicMessageRow,status:'open'|'closed')=>{try{return await save.mutateAsync({id:row.id,changes:{status}});}catch(error){throw new Error(redactMessagingError(error));}};const actions:BatchAction<PublicMessageRow>[]=[{key:'open',label:'Mark open',eligible:r=>r.status!=='open',run:r=>update(r,'open')},{key:'closed',label:'Mark closed',eligible:r=>r.status!=='closed',run:r=>update(r,'closed')}];const grouping=groupByDay<PublicMessageRow>('created');return <ModelTable model={PublicMessageModel} eyebrow="Communications · Support" title="Contact Messages" columns={columns} filters={filters} defaultSort="-created" searchable searchPlaceholder="Search name, email, subject, or message" selectable={canManage} batchActions={canManage?actions:[]} {...grouping} columnChooser persistState persistKey="admin:messaging:public" exportFormats={['csv','json']} exporter={exportPublicMessages} onRowClick={row=>void modal.detail(close=><PublicMessageDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
@@ -0,0 +1,23 @@
1
+ import { useMemo } from 'react';
2
+
3
+ export const EMAIL_PREVIEW_CSP = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src 'none'; connect-src 'none'; frame-src 'none'; media-src 'none'; form-action 'none'; base-uri 'none'";
4
+ const DROP_ELEMENTS='script,style,link,base,meta,iframe,frame,frameset,object,embed,form,input,button,textarea,select,option,video,audio,source,track,picture,svg,math,canvas';
5
+ const DROP_ATTR=/^(?:on|href$|xlink:href$|src$|srcset$|action$|formaction$|poster$|ping$|background$|data$|codebase$|manifest$)/i;
6
+ function escapeHtml(value:string):string{return value.replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;');}
7
+
8
+ /** Parse into a fresh detached document, project safe presentation markup, then serialize. */
9
+ export function sanitizeEmailHtml(html:string):string{
10
+ if(typeof DOMParser==='undefined')return `<pre>${escapeHtml(html)}</pre>`;
11
+ const parsed=new DOMParser().parseFromString(`<!doctype html><html><body>${html}</body></html>`,'text/html');
12
+ parsed.body.querySelectorAll(DROP_ELEMENTS).forEach(node=>node.remove());
13
+ parsed.body.querySelectorAll('*').forEach((element)=>{
14
+ for(const attr of [...element.attributes]){
15
+ const name=attr.name.toLowerCase();
16
+ if(DROP_ATTR.test(name)||name==='xmlns'||name.startsWith('xmlns:')||(name==='style'&&/url\s*\(|@import|expression\s*\(/i.test(attr.value)))element.removeAttribute(attr.name);
17
+ }
18
+ if(element.tagName==='A'){element.removeAttribute('href');element.removeAttribute('target');element.setAttribute('aria-disabled','true');}
19
+ });
20
+ return parsed.body.innerHTML;
21
+ }
22
+ export function buildEmailPreviewDocument(html:string):string{const body=sanitizeEmailHtml(html);return `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="${EMAIL_PREVIEW_CSP}"><meta name="referrer" content="no-referrer"></head><body>${body}</body></html>`;}
23
+ export function SandboxedEmailPreview({html,text}:{html?:string|null;text?:string|null}){const srcDoc=useMemo(()=>buildEmailPreviewDocument(html??''),[html]);return <div className="email-preview">{text&&<pre className="email-preview-text">{text}</pre>}{html&&<iframe title="Sandboxed email HTML preview" sandbox="" referrerPolicy="no-referrer" srcDoc={srcDoc}/>} {!text&&!html&&<p className="dim">No message body.</p>}</div>;}
@@ -0,0 +1,11 @@
1
+ import { Badge, DetailView, FlatRow, ModelTable, fmt, groupByDay, modal, type Column, type FilterDef } from '../../ui';
2
+ import { exportSentMessages, SENT_STATUSES } from './data';
3
+ import { SentMessageModel, type SentMessageRow } from './models';
4
+ import { SandboxedEmailPreview } from './SandboxedEmailPreview';
5
+ import { redactMessagingError } from './sanitize';
6
+
7
+ const mailboxEmail=(row:SentMessageRow)=>typeof row.mailbox==='object'?row.mailbox?.email??row.mailbox?.name??`#${row.mailbox?.id}`:`#${row.mailbox}`;
8
+ const columns:Column<SentMessageRow>[]=[{key:'created',label:'Created',sortable:true,hideable:false,render:r=>fmt.datetime(r.created)},{key:'mailbox',label:'From',render:mailboxEmail},{key:'to_addresses',label:'To',render:r=>r.to_addresses.join(', ')},{key:'subject',label:'Subject',sortable:true},{key:'status',label:'Status',sortable:true,render:r=><Badge tone={r.status==='delivered'?'success':r.status==='failed'||r.status==='bounced'||r.status==='complained'?'danger':r.status==='sending'?'warning':'muted'}>{r.status==='sending'?'sending · pending':r.status}</Badge>},{key:'ses_message_id',label:'SES id',sortable:true,render:r=><code>{r.ses_message_id||'—'}</code>}];
9
+ const filters:FilterDef[]=[{key:'status',label:'Status',type:'select',options:SENT_STATUSES.map(v=>({value:v,label:v}))},{key:'mailbox',label:'Mailbox id',type:'number'},{key:'created',label:'Created',type:'daterange'}];
10
+ function SentDetail({id,onClose}:{id:number;onClose:()=>void}){const query=SentMessageModel.useOne(id);if(!query.data)return <div className="modal-pad"><p>{query.isLoading?'Loading sent message…':query.error instanceof Error?redactMessagingError(query.error):'Sent message unavailable'}</p><button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;return <DetailView title={row.subject||'(no subject)'} subtitle={`${mailboxEmail(row)} → ${row.to_addresses.join(', ')}`} icon="bi-send" chips={[{text:row.status,tone:row.status==='delivered'?'success':row.status==='failed'?'danger':'warning'}]} onClose={onClose} sections={[{key:'overview',label:'Delivery',icon:'bi-info-circle',render:()=><div className="detail-section"><FlatRow label="From">{mailboxEmail(row)}</FlatRow><FlatRow label="To">{row.to_addresses.join(', ')}</FlatRow><FlatRow label="CC">{row.cc_addresses.join(', ')||'—'}</FlatRow><FlatRow label="SES message id"><code>{row.ses_message_id||'—'}</code></FlatRow><FlatRow label="Status reason">{row.status_reason?redactMessagingError(row.status_reason):'—'}</FlatRow><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow></div>},{key:'content',label:'Content',icon:'bi-file-earmark-code',render:()=><div className="detail-section"><SandboxedEmailPreview text={row.body_text} html={row.body_html}/></div>}]} />;}
11
+ export function SentMessagesPage(){const grouping=groupByDay<SentMessageRow>('created');return <ModelTable model={SentMessageModel} eyebrow="Communications · SES" title="Sent Messages" columns={columns} filters={filters} defaultSort="-created" searchable searchPlaceholder="Search subject or SES message id" autoRefresh={15} {...grouping} columnChooser persistState persistKey="admin:email:sent" exportFormats={['csv','json']} exporter={exportSentMessages} onRowClick={row=>void modal.detail(close=><SentDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
@@ -0,0 +1,17 @@
1
+ import type { QueryClient } from '@tanstack/react-query';
2
+ import { mojoCall, mojoDelete, mojoSave, withFreshAuth } from '../../client';
3
+ import { EmailDomainModel, MailboxModel, SentMessageModel, type DomainActionResult, type DomainAuditResult, type DomainOnboardInput, type EmailDomainRow, type MailboxRow, type SendEmailInput, type SentMessageRow } from './models';
4
+ import { redactMessagingError, sanitizeAuditResult, sanitizeDomainActionResult, sanitizeEmailDomainRow, sanitizeMailboxRow, sanitizeSentMessageRow } from './sanitize';
5
+
6
+ const flights = new Map<string, Promise<unknown>>();
7
+ function singleFlight<T>(key: string, fn: () => Promise<T>): Promise<T> { const current=flights.get(key); if (current) return current as Promise<T>; const next=fn().finally(()=>flights.delete(key)); flights.set(key,next); return next; }
8
+ function safeChanges(input: Record<string, unknown>): Record<string, unknown> { const allowed=['name','region','receiving_enabled','s3_inbound_bucket','s3_inbound_prefix','dns_mode','sns_topic_bounce_arn','sns_topic_complaint_arn','sns_topic_delivery_arn','sns_topic_inbound_arn','aws_key','aws_secret']; const out:Record<string,unknown>={}; for (const key of allowed) if (input[key] !== undefined) out[key]=input[key]; for (const key of ['aws_key','aws_secret']) if (typeof out[key] !== 'string' || !(out[key] as string).trim()) delete out[key]; return out; }
9
+ async function reconcileCaches(qc: QueryClient, id?: number | null) { await qc.invalidateQueries({queryKey:EmailDomainModel.keys.root,refetchType:'none'}); if (id != null) { qc.removeQueries({queryKey:EmailDomainModel.keys.one(id),exact:true}); try { await EmailDomainModel.fetchOne(qc,id); } catch { /* the mutation error remains primary */ } } await qc.refetchQueries({type:'active',predicate:(q)=>q.queryKey[0]===EmailDomainModel.endpoint}); }
10
+ export async function saveEmailDomainAtomic(qc: QueryClient, id: number|null, changes: Record<string,unknown>): Promise<EmailDomainRow> { let saved:EmailDomainRow|undefined; let failure:unknown; let refreshFailed=false; try { saved=sanitizeEmailDomainRow(await withFreshAuth(()=>mojoSave<EmailDomainRow>(EmailDomainModel.endpoint,id,safeChanges(changes)))); } catch(error) { failure=error; } finally { try{await reconcileCaches(qc,saved?.id??id);}catch{refreshFailed=true;} } if (failure) throw new Error(`${redactMessagingError(failure)}${refreshFailed?' Authoritative refresh also failed; refresh before another action.':''}`); if (refreshFailed||!saved) throw new Error('The backend may have changed, but authoritative refresh failed. Refresh before another action.'); return saved; }
11
+ async function domainAction(qc:QueryClient,id:number,action:'onboard'|'audit'|'reconcile',body:Record<string,unknown>):Promise<DomainActionResult|DomainAuditResult>{ return singleFlight(`domain:${id}:${action}`,async()=>{ let result:DomainActionResult|DomainAuditResult|undefined;let failure:unknown;let refreshFailed=false;try { const response=await mojoCall(`${EmailDomainModel.endpoint}/${id}/${action}`,{method:'POST',body}); result=action==='audit'?sanitizeAuditResult(response.data):sanitizeDomainActionResult(response.data); } catch(error){failure=error;} finally {try{await reconcileCaches(qc,id);}catch{refreshFailed=true;}}if(failure)throw new Error(`${redactMessagingError(failure)}${refreshFailed?' Authoritative refresh also failed; refresh before another action.':''}`);if(refreshFailed)throw new Error('The action may have changed AWS state, but authoritative refresh failed. Refresh before another action.');return result!; }); }
12
+ export const auditEmailDomain=(qc:QueryClient,id:number)=>domainAction(qc,id,'audit',{}) as Promise<DomainAuditResult>;
13
+ export const reconcileEmailDomain=(qc:QueryClient,id:number)=>domainAction(qc,id,'reconcile',{}) as Promise<DomainActionResult>;
14
+ export const onboardEmailDomain=(qc:QueryClient,id:number,input:DomainOnboardInput)=>domainAction(qc,id,'onboard',{use_dnsman:Boolean(input.use_dnsman),...(input.region?{region:input.region}:{}),receiving_enabled:Boolean(input.receiving_enabled),...(input.s3_inbound_bucket?{s3_inbound_bucket:input.s3_inbound_bucket}:{}),...(input.s3_inbound_prefix!==undefined?{s3_inbound_prefix:input.s3_inbound_prefix}:{}),ensure_mail_from:Boolean(input.ensure_mail_from),...(input.mail_from_subdomain?{mail_from_subdomain:input.mail_from_subdomain}:{}),...(!input.use_dnsman?{dns_mode:'manual'}:{}),...(input.endpoints?{endpoints:input.endpoints}:{})}) as Promise<DomainActionResult>;
15
+ export async function saveMailbox(qc:QueryClient,id:number|null,changes:Record<string,unknown>):Promise<MailboxRow>{const allowed=['email','domain','allow_inbound','allow_outbound','async_handler','is_system_default','is_domain_default'];const body:Record<string,unknown>={};for(const key of allowed)if(changes[key]!==undefined)body[key]=changes[key];let row:MailboxRow|undefined;let failure:unknown;let refreshFailed=false;try{row=sanitizeMailboxRow(await mojoSave<MailboxRow>(MailboxModel.endpoint,id,body));}catch(error){failure=error;}finally{try{await MailboxModel.invalidate(qc);await qc.refetchQueries({type:'active',predicate:q=>q.queryKey[0]===MailboxModel.endpoint});}catch{refreshFailed=true;}}if(failure)throw new Error(`${redactMessagingError(failure)}${refreshFailed?' Mailbox refresh also failed.':''}`);if(refreshFailed||!row)throw new Error('The mailbox may have changed, but authoritative refresh failed. Refresh before another action.');return row;}
16
+ export async function deleteMessagingRow(qc:QueryClient,endpoint:string,id:number):Promise<void>{let failure:unknown;let refreshFailed=false;try{await withFreshAuth(()=>mojoDelete(endpoint,id));}catch(error){failure=error;}finally{try{await qc.invalidateQueries({queryKey:[endpoint]});await qc.refetchQueries({type:'active',predicate:q=>q.queryKey[0]===endpoint});}catch{refreshFailed=true;}}if(failure)throw new Error(`${redactMessagingError(failure)}${refreshFailed?' List refresh also failed.':''}`);if(refreshFailed)throw new Error('Deletion may have persisted, but list refresh failed. Refresh before another action.');}
17
+ export async function sendEmail(qc:QueryClient,input:SendEmailInput):Promise<SentMessageRow>{ return singleFlight(`send:${input.from_email}`,async()=>{let row:SentMessageRow|undefined;let failure:unknown;let refreshFailed=false;try { const response=await mojoCall('/api/aws/email/send',{method:'POST',body:input as unknown as Record<string,unknown>}); row=sanitizeSentMessageRow(response.data as SentMessageRow); } catch(error){failure=error;}finally{try{await SentMessageModel.invalidate(qc);await qc.refetchQueries({type:'active',predicate:q=>q.queryKey[0]===SentMessageModel.endpoint});}catch{refreshFailed=true;}}if(failure)throw new Error(`${redactMessagingError(failure)}${refreshFailed?' Sent history refresh also failed.':''}`);if(refreshFailed||!row)throw new Error('The send may have persisted, but sent history refresh failed. Refresh before sending again.');return row; }); }
@@ -0,0 +1,10 @@
1
+ import { createSafeExporter } from '../../client';
2
+ import { EmailDomainModel, EmailTemplateModel, PublicMessageModel, SentMessageModel, type EmailDomainRow, type EmailTemplateRow, type PublicMessageRow, type SentMessageRow } from './models';
3
+ import { sanitizeEmailDomainRow, sanitizePublicMessageRow, sanitizeSentMessageRow, sanitizeTemplateRow } from './sanitize';
4
+
5
+ export const SENT_STATUSES = ['queued','sending','delivered','bounced','complained','failed','unknown'] as const;
6
+ export function spreadsheetSafe(value:unknown):unknown{if(typeof value!=='string')return value;return /^[\t\r\n\u0000-\u0020]*[=+\-@]/.test(value)?`'${value}`:value;}
7
+ export const exportEmailDomains = createSafeExporter<EmailDomainRow>({ endpoint: EmailDomainModel.endpoint, filename: 'email-domains', sanitizeRow: sanitizeEmailDomainRow, fields: [{key:'id'},{key:'name',value:r=>spreadsheetSafe(r.name)},{key:'region'},{key:'status'},{key:'receiving_enabled'},{key:'dns_mode'},{key:'created'},{key:'modified'}] });
8
+ export const exportSentMessages = createSafeExporter<SentMessageRow>({ endpoint: SentMessageModel.endpoint, filename: 'sent-messages', sanitizeRow: sanitizeSentMessageRow, fields: [{key:'id'},{key:'created'},{key:'modified'},{key:'mailbox',value:r=>typeof r.mailbox==='object'?spreadsheetSafe(r.mailbox?.email??''):r.mailbox},{key:'to_addresses',value:(r)=>spreadsheetSafe(r.to_addresses.join(', '))},{key:'subject',value:r=>spreadsheetSafe(r.subject)},{key:'status'},{key:'ses_message_id',value:r=>spreadsheetSafe(r.ses_message_id)}] });
9
+ export const exportEmailTemplates = createSafeExporter<EmailTemplateRow>({ endpoint: EmailTemplateModel.endpoint, filename: 'email-templates', sanitizeRow: sanitizeTemplateRow, fields: [{key:'id'},{key:'name',value:r=>spreadsheetSafe(r.name)},{key:'created'},{key:'modified'}] });
10
+ export const exportPublicMessages = createSafeExporter<PublicMessageRow>({ endpoint: PublicMessageModel.endpoint, filename: 'contact-messages', sanitizeRow: sanitizePublicMessageRow, fields: [{key:'id'},{key:'created'},{key:'modified'},{key:'kind'},{key:'status'},{key:'name',value:r=>spreadsheetSafe(r.name)},{key:'email',value:r=>spreadsheetSafe(r.email)},{key:'subject',value:r=>spreadsheetSafe(r.subject)}] });
@@ -0,0 +1,23 @@
1
+ import type { AdminSection } from '../index';
2
+ import { EmailDomainsPage } from './EmailDomainsPage';
3
+ import { EmailTemplatesPage } from './EmailTemplatesPage';
4
+ import { MailboxesPage } from './MailboxesPage';
5
+ import { PublicMessagesPage } from './PublicMessagesPage';
6
+ import { SentMessagesPage } from './SentMessagesPage';
7
+ import { EMAIL_ADMIN_PERMISSIONS, PUBLIC_MESSAGE_VIEW_PERMISSIONS } from './models';
8
+ export * from './push';
9
+
10
+ export * from './models';
11
+ export * from './sanitize';
12
+ export * from './api';
13
+ export * from './data';
14
+ export * from './SandboxedEmailPreview';
15
+ export * from './EmailDomainWizard';
16
+ export * from './EmailDomainsPage';
17
+ export * from './MailboxesPage';
18
+ export * from './SentMessagesPage';
19
+ export * from './EmailTemplatesPage';
20
+ export * from './PublicMessagesPage';
21
+
22
+ export const EMAIL_ADMIN_SECTION:AdminSection={id:'email',basePath:'email',title:'Email',icon:'bi-envelope-at',navigationGroup:'communications',permissions:EMAIL_ADMIN_PERMISSIONS,routes:[{path:'domains',label:'Email Domains',component:EmailDomainsPage,permissions:EMAIL_ADMIN_PERMISSIONS},{path:'mailboxes',label:'Mailboxes',component:MailboxesPage,permissions:EMAIL_ADMIN_PERMISSIONS},{path:'sent',label:'Sent Messages',component:SentMessagesPage,permissions:EMAIL_ADMIN_PERMISSIONS},{path:'templates',label:'Email Templates',component:EmailTemplatesPage,permissions:EMAIL_ADMIN_PERMISSIONS}]};
23
+ export const PUBLIC_MESSAGES_ADMIN_SECTION:AdminSection={id:'public-messages',basePath:'messaging',title:'Contact Messages',icon:'bi-chat-left-text',navigationGroup:'communications',permissions:PUBLIC_MESSAGE_VIEW_PERMISSIONS,routes:[{path:'public-messages',label:'Contact Messages',component:PublicMessagesPage,permissions:PUBLIC_MESSAGE_VIEW_PERMISSIONS}]};
@@ -0,0 +1,76 @@
1
+ import { defineModel, type Params } from '../../client';
2
+ import {
3
+ sanitizeEmailDomainRow, sanitizeMailboxRow, sanitizePublicMessageRow,
4
+ sanitizeSentMessageRow, sanitizeTemplateRow,
5
+ } from './sanitize';
6
+
7
+ export const EMAIL_ADMIN_PERMISSIONS = ['sys.manage_aws', 'sys.comms'];
8
+ export const PUBLIC_MESSAGE_VIEW_PERMISSIONS = ['sys.view_support', 'sys.security', 'sys.support'];
9
+ export const PUBLIC_MESSAGE_MANAGE_PERMISSIONS = ['sys.manage_support', 'sys.security', 'sys.support'];
10
+ export const PUBLIC_MESSAGE_DELETE_PERMISSIONS = ['sys.manage_support'];
11
+
12
+ export interface MessagingRelation { id: number; name?: string; email?: string }
13
+ export type EmailDomainStatus = 'pending' | 'verified' | 'ready' | 'missing' | 'unknown';
14
+ export type EmailDnsMode = 'manual' | 'route53' | 'godaddy' | 'unknown';
15
+ export type SentMessageStatus = 'queued' | 'sending' | 'delivered' | 'bounced' | 'complained' | 'failed' | 'unknown';
16
+
17
+ export interface EmailDomainRow {
18
+ id: number; created: number; modified: number; name: string; region: string; status: EmailDomainStatus;
19
+ receiving_enabled: boolean; s3_inbound_bucket: string | null; s3_inbound_prefix: string; dns_mode: EmailDnsMode;
20
+ aws_key_masked?: string | null; aws_secret_masked?: string | null;
21
+ sns_topic_bounce_arn?: string | null; sns_topic_complaint_arn?: string | null; sns_topic_delivery_arn?: string | null; sns_topic_inbound_arn?: string | null;
22
+ }
23
+ export interface MailboxRow {
24
+ id: number; created: number; modified: number; email: string; domain: MessagingRelation | number | null;
25
+ allow_inbound: boolean; allow_outbound: boolean; async_handler: string | null;
26
+ is_system_default: boolean; is_domain_default: boolean;
27
+ }
28
+ export interface SentMessageRow {
29
+ id: number; created: number; modified: number; mailbox: MessagingRelation | number | null;
30
+ to_addresses: string[]; cc_addresses: string[]; bcc_addresses?: string[]; subject: string; status: SentMessageStatus;
31
+ ses_message_id: string | null; status_reason?: string | null; body_text?: string | null; body_html?: string | null;
32
+ }
33
+ export interface EmailTemplateRow {
34
+ id: number; created: number; modified: number; name: string; subject_template?: string; html_template?: string | null; text_template?: string | null;
35
+ }
36
+ export interface PublicMessageRow {
37
+ id: number; created: number; modified: number; kind: 'contact_us' | 'support' | string; status: 'open' | 'closed' | string;
38
+ name: string; email: string; subject: string; group: MessagingRelation | number | null;
39
+ message?: string; metadata?: Record<string, string>; metadata_withheld?: boolean; ip_address?: string | null; user_agent?: string | null;
40
+ }
41
+ export interface EmailDnsRecord { type: string; name: string; value: string; ttl: number }
42
+ export interface DomainOnboardInput {
43
+ use_dnsman: boolean; region?: string; receiving_enabled?: boolean; s3_inbound_bucket?: string; s3_inbound_prefix?: string;
44
+ ensure_mail_from?: boolean; mail_from_subdomain?: string; dns_mode?: 'manual';
45
+ endpoints?: { bounce?: string; complaint?: string; delivery?: string; inbound?: string };
46
+ }
47
+ export interface DomainActionResult {
48
+ domain: string; region: string; status?: string; dns_records: EmailDnsRecord[]; dkim_tokens: string[]; notes: string[];
49
+ provider?: string; applied?: number; topic_arns?: Record<string, string>; receipt_rule?: string; rule_set?: string;
50
+ }
51
+ export interface DomainAuditResult {
52
+ domain: string; region: string; status: string; audit_pass: boolean; checks: Record<string, boolean>;
53
+ items: Array<{ resource: string; status: string }>;
54
+ recommendations: Array<{ resource: string; severity: string; action: string; explanation: string }>;
55
+ }
56
+ export interface SendEmailInput {
57
+ from_email: string; to: string[]; cc?: string[]; bcc?: string[]; reply_to?: string;
58
+ subject?: string; body_text?: string; body_html?: string; template_name?: string; template_context?: Record<string, unknown>;
59
+ }
60
+
61
+ const COMMON = new Set(['start', 'size', 'search', 'sort', 'dr_field', 'dr_start', 'dr_end']);
62
+ function allow(params: Params, graph: string, filters: readonly string[], sorts:readonly string[], defaultSort: string, dateRange=false): Params {
63
+ const out: Params = { graph, start: params.start ?? 0, size: params.size ?? 25 };
64
+ const allowed = new Set([...COMMON, ...filters]);
65
+ for (const [key, value] of Object.entries(params)) if (allowed.has(key) && value != null && value !== '') out[key] = value;
66
+ const requested=typeof params.sort==='string'?params.sort:'';const field=requested.replace(/^-/,'');
67
+ out.graph = graph; out.sort = sorts.includes(field) ? requested : defaultSort;
68
+ if(dateRange&&(out.dr_start||out.dr_end)){out.dr_field='created';}else{delete out.dr_field;delete out.dr_start;delete out.dr_end;}
69
+ return out;
70
+ }
71
+
72
+ export const EmailDomainModel = defineModel<EmailDomainRow>({ name: 'email-domain', endpoint: '/api/aws/email/domain', permissions: { view: EMAIL_ADMIN_PERMISSIONS, manage: EMAIL_ADMIN_PERMISSIONS, delete: EMAIL_ADMIN_PERMISSIONS }, normalizeListParams: (p) => allow(p, 'default', ['region', 'status', 'receiving_enabled', 'dns_mode'], ['name','region','status','receiving_enabled','dns_mode','created','modified'], 'name'), sanitizeRow: sanitizeEmailDomainRow });
73
+ export const MailboxModel = defineModel<MailboxRow>({ name: 'email-mailbox', endpoint: '/api/aws/email/mailbox', permissions: { view: EMAIL_ADMIN_PERMISSIONS, manage: EMAIL_ADMIN_PERMISSIONS, delete: EMAIL_ADMIN_PERMISSIONS }, normalizeListParams: (p) => allow(p, 'default', ['domain', 'allow_inbound', 'allow_outbound', 'is_system_default', 'is_domain_default'], ['email','allow_inbound','allow_outbound','is_system_default','is_domain_default','created','modified'], 'email'), sanitizeRow: sanitizeMailboxRow });
74
+ export const SentMessageModel = defineModel<SentMessageRow>({ name: 'sent-message', endpoint: '/api/aws/email/sent', permissions: { view: EMAIL_ADMIN_PERMISSIONS }, normalizeListParams: (p) => allow(p, 'basic', ['mailbox', 'status', 'status__in'], ['created','status','subject','ses_message_id'], '-created',true), sanitizeRow: sanitizeSentMessageRow });
75
+ export const EmailTemplateModel = defineModel<EmailTemplateRow>({ name: 'email-template', endpoint: '/api/aws/email/template', permissions: { view: EMAIL_ADMIN_PERMISSIONS, manage: EMAIL_ADMIN_PERMISSIONS, delete: EMAIL_ADMIN_PERMISSIONS }, normalizeListParams: (p) => allow(p, 'basic', [], ['name','created','modified'], 'name',true), sanitizeRow: sanitizeTemplateRow });
76
+ export const PublicMessageModel = defineModel<PublicMessageRow>({ name: 'public-message', endpoint: '/api/account/public_message', permissions: { view: PUBLIC_MESSAGE_VIEW_PERMISSIONS, manage: PUBLIC_MESSAGE_MANAGE_PERMISSIONS, delete: PUBLIC_MESSAGE_DELETE_PERMISSIONS }, normalizeListParams: (p) => allow(p, 'list', ['kind', 'kind__in', 'status', 'status__in'], ['created','modified','kind','status','name','email','subject'], '-created',true), sanitizeRow: sanitizePublicMessageRow });
@@ -0,0 +1,44 @@
1
+ import { useRef, useState } from 'react';
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { useSearchParams } from 'react-router-dom';
4
+ import { KPITile, MetricsChart } from '../../../charts';
5
+ import { useCan } from '../../../client';
6
+ import { Badge, DetailView, FlatRow, ModelTable, Tabs, fmt, modal, toast, type Column, type FilterDef } from '../../../ui';
7
+ import { fetchPushGroupChoices, fetchPushStats, parseFcmServiceAccount, savePushConfigImperative, testPushConfigConnection, type FcmCredentialEdit } from './api';
8
+ import {
9
+ PUSH_CONFIG_MANAGE_PERMISSIONS, PUSH_CONFIG_TEST_PERMISSIONS, PUSH_CONFIG_VIEW_PERMISSIONS,
10
+ PUSH_DELIVERY_VIEW_PERMISSIONS, PUSH_DEVICE_VIEW_PERMISSIONS, PUSH_GROUP_DIRECTORY_PERMISSIONS,
11
+ PUSH_METRICS_PERMISSIONS, PUSH_TEMPLATE_MANAGE_PERMISSIONS, PUSH_TEMPLATE_VIEW_PERMISSIONS,
12
+ PushConfigModel, PushDeliveryModel, PushDeviceModel, PushTemplateModel,
13
+ type PushConfigRow, type PushDeliveryRow, type PushDeviceRow, type PushTemplateRow,
14
+ } from './models';
15
+
16
+ const relationLabel=(value:{id:number;name?:string;display_name?:string}|null,fallback='—')=>value?.name??value?.display_name??(value?`#${value.id}`:fallback);
17
+ const statusTone=(status:string)=>status==='sent'||status==='delivered'?'success':status==='failed'?'danger':'warning';
18
+
19
+ export function PushStatsPanel(){const query=useQuery({queryKey:['/api/account/devices/push/stats','caller'],queryFn:fetchPushStats,staleTime:30_000});const row=query.data;return <div className="push-stats"><section><h2>Your activity</h2><p className="dim">These five counts belong only to the signed-in caller; they are not platform totals.</p><div className="push-kpi-grid"><KPITile label="Sent" value={row?.total_sent} loading={query.isPending}/><KPITile label="Failed" value={row?.total_failed} tone="bad" loading={query.isPending}/><KPITile label="Pending" value={row?.total_pending} loading={query.isPending}/></div></section><section><h2>Your devices</h2><div className="push-kpi-grid push-kpi-grid-two"><KPITile label="Registered devices" value={row?.registered_devices} loading={query.isPending}/><KPITile label="Enabled devices" value={row?.enabled_devices} loading={query.isPending}/></div></section>{query.error&&<div className="form-alert">Caller push statistics are unavailable.</div>}</div>;}
20
+ export function PushMetricsPanel(){return <div className="panel panel-pad"><p className="eyebrow">Global metrics account</p><MetricsChart title="Push delivery activity" slugs={['push_sent','push_failed']} seriesLabels={{push_sent:'Sent',push_failed:'Failed'}} account="global" defaultRange="7d" defaultGranularity="hours" defaultType="line"/></div>;}
21
+
22
+ const deviceColumns:Column<PushDeviceRow>[]=[{key:'last_seen',label:'Last seen',sortable:true,hideable:false,render:r=>fmt.datetime(r.last_seen)},{key:'device_name',label:'Device',sortable:true,render:r=>r.device_name||r.device_id},{key:'user',label:'User',render:r=>relationLabel(r.user)},{key:'platform',label:'Platform',sortable:true,render:r=><Badge tone="info">{r.platform}</Badge>},{key:'app_version',label:'App version',render:r=>r.app_version||'—'},{key:'push_enabled',label:'Push',sortable:true,render:r=><Badge tone={r.push_enabled?'success':'muted'}>{r.push_enabled?'enabled':'disabled'}</Badge>}];
23
+ const deviceFilters:FilterDef[]=[{key:'platform',label:'Platform',type:'select',options:['ios','android','web'].map(value=>({value,label:value}))},{key:'push_enabled',label:'Push enabled',type:'boolean'},{key:'is_active',label:'Active',type:'boolean'}];
24
+ function DeviceDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PushDeviceModel.useOne(id);if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading registered device…':'Registered device unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;return <DetailView title={row.device_name||row.device_id} subtitle={`${row.platform} · ${relationLabel(row.user)}`} icon="bi-phone" onClose={onClose} sections={[{key:'device',label:'Device',icon:'bi-device-ssd',render:()=><div className="detail-section"><FlatRow label="Device id"><code>{row.device_id}</code></FlatRow><FlatRow label="Platform">{row.platform}</FlatRow><FlatRow label="OS version">{row.os_version||'—'}</FlatRow><FlatRow label="App version">{row.app_version||'—'}</FlatRow><FlatRow label="Push enabled">{row.push_enabled?'Yes':'No'}</FlatRow><FlatRow label="Last seen">{fmt.datetime(row.last_seen)}</FlatRow></div>},{key:'preferences',label:'Preferences',icon:'bi-sliders',render:()=><div className="detail-section">{Object.keys(row.push_preferences).length?Object.entries(row.push_preferences).map(([key,value])=><FlatRow key={key} label={key}>{value?'Enabled':'Disabled'}</FlatRow>):<p className="dim">No category overrides.</p>}<p className="dim">Device tokens are sensitive and never enter this client.</p></div>}]} />;}
25
+ export function PushDevicesPage(){return <ModelTable model={PushDeviceModel} eyebrow="Communications · Push" title="Registered Devices" columns={deviceColumns} filters={deviceFilters} defaultSort="-last_seen" searchable searchPlaceholder="Search device name or device id" columnChooser persistState persistKey="admin:push:devices" onRowClick={row=>void modal.detail(close=><DeviceDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
26
+
27
+ const deliveryColumns:Column<PushDeliveryRow>[]=[{key:'created',label:'Created',sortable:true,hideable:false,render:r=>fmt.datetime(r.created)},{key:'title',label:'Title',render:r=>r.title||`[${r.category} data]`},{key:'user',label:'User',render:r=>relationLabel(r.user)},{key:'device',label:'Device',render:r=>relationLabel(r.device)},{key:'category',label:'Category',sortable:true},{key:'status',label:'Status',sortable:true,render:r=><Badge tone={statusTone(r.status)}>{r.status}</Badge>}];
28
+ const deliveryFilters:FilterDef[]=[{key:'status',label:'Status',type:'select',options:['pending','sent','delivered','failed'].map(value=>({value,label:value}))},{key:'category',label:'Category',type:'text'},{key:'created',label:'Created',type:'daterange'}];
29
+ function DeliveryDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PushDeliveryModel.useOne(id);if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading delivery…':'Delivery unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;return <DetailView title={row.title||`[${row.category} data]`} subtitle={`${row.status} · ${relationLabel(row.device)}`} icon="bi-bell" chips={[{text:row.status,tone:statusTone(row.status)}]} onClose={onClose} sections={[{key:'preview',label:'Notification',icon:'bi-app-indicator',render:()=><div className="detail-section"><div className="push-preview"><strong>{row.title||'Data-only notification'}</strong><p>{row.body||'No visible message body.'}</p></div><FlatRow label="Category">{row.category}</FlatRow><FlatRow label="Action URL"><code>{row.action_url||'—'}</code></FlatRow></div>},{key:'delivery',label:'Delivery',icon:'bi-truck',render:()=><div className="detail-section"><FlatRow label="User">{relationLabel(row.user)}</FlatRow><FlatRow label="Device">{relationLabel(row.device)}</FlatRow><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow><FlatRow label="Sent">{row.sent_at?fmt.datetime(row.sent_at):'—'}</FlatRow><FlatRow label="Delivered">{row.delivered_at?fmt.datetime(row.delivered_at):'—'}</FlatRow><FlatRow label="Error">{row.error_message||'—'}</FlatRow><p className="dim">Platform responses and raw payloads are deliberately excluded. Deliveries cannot be retried or deleted here.</p></div>}]} />;}
30
+ export function PushDeliveriesPage(){return <ModelTable model={PushDeliveryModel} eyebrow="Communications · Push" title="Deliveries" columns={deliveryColumns} filters={deliveryFilters} defaultSort="-created" searchable searchPlaceholder="Search title or category" autoRefresh={20} columnChooser persistState persistKey="admin:push:deliveries" onRowClick={row=>void modal.detail(close=><DeliveryDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
31
+
32
+ function jsonText(value:Record<string,unknown>):string{return Object.keys(value).length?JSON.stringify(value,null,2):'';}
33
+ function parseJsonObject(value:string,label:string):Record<string,unknown>{if(!value.trim())return {};let parsed:unknown;try{parsed=JSON.parse(value);}catch{throw new Error(`${label} must be valid JSON.`);}if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw new Error(`${label} must be a JSON object.`);return parsed as Record<string,unknown>;}
34
+ function TemplateEditor({row,close}:{row?:PushTemplateRow;close:(value:PushTemplateRow|null)=>void}){const save=PushTemplateModel.useSave();const canGroups=useCan(PUSH_GROUP_DIRECTORY_PERMISSIONS).can;const groups=useQuery({queryKey:['push','group-choices'],queryFn:fetchPushGroupChoices,enabled:canGroups,staleTime:60_000});const [name,setName]=useState(row?.name??'');const [category,setCategory]=useState(row?.category??'general');const [priority,setPriority]=useState<PushTemplateRow['priority']>(row?.priority??'normal');const [group,setGroup]=useState(row?.group?String(row.group.id):'');const [title,setTitle]=useState(row?.title_template??'');const [body,setBody]=useState(row?.body_template??'');const [actionUrl,setActionUrl]=useState(row?.action_url??'');const [data,setData]=useState(jsonText(row?.data_template??{}));const [variables,setVariables]=useState(jsonText(row?.variables??{}));const [active,setActive]=useState(row?.is_active??true);const [busy,setBusy]=useState(false);const [error,setError]=useState('');const submit=async()=>{setBusy(true);setError('');try{const dataTemplate=parseJsonObject(data,'Data template');const variableDocs=parseJsonObject(variables,'Variables');if(!title.trim()&&!body.trim()&&!Object.keys(dataTemplate).length)throw new Error('Provide a title, body, or data template.');const saved=await save.mutateAsync({id:row?.id??null,changes:{name:name.trim(),category:category.trim()||'general',priority,group:group?Number(group):null,title_template:title||null,body_template:body||null,action_url:actionUrl||null,data_template:dataTemplate,variables:variableDocs,is_active:active}});toast.success(row?'Push template updated':'Push template created');close(saved);}catch(reason){setError(reason instanceof Error?reason.message:'Template could not be saved.');}finally{setBusy(false);}};return <div className="modal-pad push-editor"><h2 className="modal-title">{row?'Edit':'Create'} push template</h2>{error&&<div className="form-alert" role="alert">{error}</div>}<div className="form-grid"><label className="field col-6"><span className="field-label">Name</span><input className="input" value={name} onChange={e=>setName(e.target.value)}/></label><label className="field col-3"><span className="field-label">Category</span><input className="input" value={category} onChange={e=>setCategory(e.target.value)}/></label><label className="field col-3"><span className="field-label">Priority</span><select className="input" value={priority} onChange={e=>setPriority(e.target.value as PushTemplateRow['priority'])}><option value="low">Low</option><option value="normal">Normal</option><option value="high">High</option></select></label>{canGroups?<label className="field col-12"><span className="field-label">Group</span><select className="input" value={group} onChange={e=>setGroup(e.target.value)}><option value="">System template</option>{groups.data?.map(item=><option key={item.id} value={item.id}>{item.name}</option>)}</select></label>:<div className="field col-12"><span className="field-label">Group</span><div className="input" aria-readonly="true">{relationLabel(row?.group??null,'System template')}</div></div>}<label className="field col-12"><span className="field-label">Title template</span><input className="input" value={title} onChange={e=>setTitle(e.target.value)}/></label><label className="field col-12"><span className="field-label">Body template</span><textarea className="input" rows={4} value={body} onChange={e=>setBody(e.target.value)}/></label><label className="field col-12"><span className="field-label">Action URL template</span><input className="input" value={actionUrl} onChange={e=>setActionUrl(e.target.value)}/></label><label className="field col-6"><span className="field-label">Data template JSON</span><textarea className="input" rows={6} value={data} onChange={e=>setData(e.target.value)}/></label><label className="field col-6"><span className="field-label">Variable documentation JSON</span><textarea className="input" rows={6} value={variables} onChange={e=>setVariables(e.target.value)}/></label><label className="check col-4"><input type="checkbox" checked={active} onChange={e=>setActive(e.target.checked)}/><span>Active</span></label></div><div className="modal-actions"><button className="btn" disabled={busy} onClick={()=>close(null)}>Cancel</button><button className="btn btn-primary" disabled={busy||!name.trim()} onClick={()=>void submit()}>{busy?'Saving…':'Save template'}</button></div></div>;}
35
+ const templateColumns:Column<PushTemplateRow>[]=[{key:'name',label:'Template',sortable:true,hideable:false},{key:'category',label:'Category',sortable:true},{key:'group',label:'Scope',render:r=>relationLabel(r.group,'System')},{key:'priority',label:'Priority',sortable:true,render:r=><Badge tone={r.priority==='high'?'warning':'info'}>{r.priority}</Badge>},{key:'is_active',label:'Active',sortable:true,render:r=><Badge tone={r.is_active?'success':'muted'}>{r.is_active?'active':'inactive'}</Badge>}];
36
+ function TemplateDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PushTemplateModel.useOne(id);const canManage=useCan(PUSH_TEMPLATE_MANAGE_PERMISSIONS).can;if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading template…':'Template unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;const edit=()=>void modal.open(close=><TemplateEditor row={row} close={async saved=>{close(saved);if(saved)await query.refetch();}}/>,{size:'lg'});return <DetailView title={row.name} subtitle={`${row.category} · ${relationLabel(row.group,'System')}`} icon="bi-file-earmark-richtext" onClose={onClose} sections={[{key:'template',label:'Template',icon:'bi-card-text',render:()=><div className="detail-section">{canManage&&<button className="btn" onClick={edit}>Edit</button>}<FlatRow label="Title">{row.title_template||'—'}</FlatRow><div className="known-card"><h3>Body</h3><p className="pre-wrap">{row.body_template||'—'}</p></div><FlatRow label="Action URL"><code>{row.action_url||'—'}</code></FlatRow><FlatRow label="Data keys">{Object.keys(row.data_template).join(', ')||'—'}</FlatRow><FlatRow label="Variables">{Object.keys(row.variables).join(', ')||'—'}</FlatRow></div>}]} />;}
37
+ export function PushTemplatesPage(){const canManage=useCan(PUSH_TEMPLATE_MANAGE_PERMISSIONS).can;return <ModelTable model={PushTemplateModel} eyebrow="Communications · Push" title="Templates" columns={templateColumns} defaultSort="name" searchable searchPlaceholder="Search name or category" columnChooser persistState persistKey="admin:push:templates" addLabel="Create template" onAdd={canManage?()=>void modal.open(close=><TemplateEditor close={close}/>,{size:'lg'}):undefined} onRowClick={row=>void modal.detail(close=><TemplateDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
38
+
39
+ function ConfigEditor({row,close}:{row?:PushConfigRow;close:(value:PushConfigRow|null)=>void}){const qc=useQueryClient();const canGroups=useCan(PUSH_GROUP_DIRECTORY_PERMISSIONS).can;const groups=useQuery({queryKey:['push','group-choices'],queryFn:fetchPushGroupChoices,enabled:canGroups,staleTime:60_000});const credentialRef=useRef<HTMLTextAreaElement>(null);const [clearCredential,setClearCredential]=useState(false);const [name,setName]=useState(row?.name??'');const [group,setGroup]=useState(row?.group?String(row.group.id):'');const [sound,setSound]=useState(row?.default_sound??'default');const [active,setActive]=useState(row?.is_active??true);const [testMode,setTestMode]=useState(row?.test_mode??false);const [busy,setBusy]=useState(false);const [error,setError]=useState('');const toggleClear=()=>{if(clearCredential){setClearCredential(false);return;}if(window.confirm('Clear the stored FCM service account on save?'))setClearCredential(true);};const submit=async()=>{setBusy(true);setError('');let credential:FcmCredentialEdit={mode:'untouched'};try{const source=credentialRef.current?.value??'';if(clearCredential)credential={mode:'clear',confirmed:true};else if(source.trim())credential={mode:'replace',value:parseFcmServiceAccount(source)};const saved=await savePushConfigImperative(qc,row?.id??null,{name:name.trim(),group:group?Number(group):null,default_sound:sound.trim()||'default',is_active:active,test_mode:testMode},credential);toast.success(row?'Push configuration updated':'Push configuration created');close(saved);}catch(reason){setError(reason instanceof Error?reason.message:'Configuration could not be saved.');}finally{if(credentialRef.current)credentialRef.current.value='';credential={mode:'untouched'};setBusy(false);}};return <div className="modal-pad push-editor"><h2 className="modal-title">{row?'Edit':'Create'} push configuration</h2><p className="dim">The FCM service account is write-only and never prefills. Leave blank to keep it, enter a validated service-account JSON object to replace it, or explicitly confirm clear.</p>{error&&<div className="form-alert" role="alert">{error}</div>}<div className="form-grid"><label className="field col-8"><span className="field-label">Name</span><input className="input" value={name} onChange={e=>setName(e.target.value)}/></label><label className="field col-4"><span className="field-label">Default sound</span><input className="input" value={sound} onChange={e=>setSound(e.target.value)}/></label>{canGroups?<label className="field col-12"><span className="field-label">Group</span><select className="input" value={group} onChange={e=>setGroup(e.target.value)}><option value="">System default</option>{groups.data?.map(item=><option key={item.id} value={item.id}>{item.name}</option>)}</select></label>:<div className="field col-12"><span className="field-label">Group</span><div className="input" aria-readonly="true">{relationLabel(row?.group??null,'System default')}</div></div>}<label className="field col-12"><span className="field-label">FCM service account JSON</span><textarea ref={credentialRef} className="input" rows={9} autoComplete="off" spellCheck={false} disabled={clearCredential}/><span className="field-help">Required fields: type=service_account, project_id, client_email, and a PEM private_key.</span></label><div className="col-12"><button type="button" className={`btn${clearCredential?' btn-danger':''}`} onClick={toggleClear}>{clearCredential?'Keep stored credential':'Clear stored credential…'}</button></div><label className="check col-4"><input type="checkbox" checked={active} onChange={e=>setActive(e.target.checked)}/><span>Active</span></label><label className="check col-4"><input type="checkbox" checked={testMode} onChange={e=>setTestMode(e.target.checked)}/><span>Test mode</span></label></div><div className="modal-actions"><button className="btn" disabled={busy} onClick={()=>close(null)}>Cancel</button><button className="btn btn-primary" disabled={busy||!name.trim()} onClick={()=>void submit()}>{busy?'Saving…':'Save configuration'}</button></div></div>;}
40
+ const configColumns:Column<PushConfigRow>[]=[{key:'name',label:'Configuration',sortable:true,hideable:false},{key:'group',label:'Scope',render:r=>relationLabel(r.group,'System default')},{key:'fcm_project_id',label:'FCM project',render:r=>r.fcm_project_id||'Not reported'},{key:'test_mode',label:'Test mode',sortable:true,render:r=><Badge tone={r.test_mode?'warning':'muted'}>{r.test_mode?'yes':'no'}</Badge>},{key:'is_active',label:'Active',sortable:true,render:r=><Badge tone={r.is_active?'success':'muted'}>{r.is_active?'active':'inactive'}</Badge>}];
41
+ function ConfigDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PushConfigModel.useOne(id);const canManage=useCan(PUSH_CONFIG_MANAGE_PERMISSIONS).can;const canTest=useCan(PUSH_CONFIG_TEST_PERMISSIONS).can;if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading configuration…':'Configuration unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;const edit=()=>void modal.open(close=><ConfigEditor row={row} close={async saved=>{close(saved);if(saved)await query.refetch();}}/>,{size:'lg'});const test=async()=>{try{const result=await testPushConfigConnection(id);toast.success(result.message);}catch{toast.error('FCM connection test failed. Review the stored service account and server logs.');}};return <DetailView title={row.name} subtitle={`${relationLabel(row.group,'System default')} · ${row.fcm_project_id||'FCM project unavailable'}`} icon="bi-broadcast-pin" onClose={onClose} sections={[{key:'configuration',label:'Configuration',icon:'bi-sliders',render:()=><div className="detail-section"><div className="storage-action-row">{canManage&&<button className="btn" onClick={edit}>Edit</button>}{canTest&&<button className="btn" onClick={()=>void test()}>Test stored credentials</button>}</div><FlatRow label="FCM project">{row.fcm_project_id||'—'}</FlatRow><FlatRow label="Default sound">{row.default_sound}</FlatRow><FlatRow label="Active">{row.is_active?'Yes':'No'}</FlatRow><FlatRow label="Test mode">{row.test_mode?'Yes — sends are faked':'No'}</FlatRow><p className="dim">The connection test uses the backend's dummy registration token to validate credentials. This portal never accepts a device token and never sends a caller test notification.</p></div>}]} />;}
42
+ export function PushConfigsPage(){const canManage=useCan(PUSH_CONFIG_MANAGE_PERMISSIONS).can;return <ModelTable model={PushConfigModel} eyebrow="Communications · Push" title="Configuration" columns={configColumns} defaultSort="name" searchable searchPlaceholder="Search configuration names" columnChooser persistState persistKey="admin:push:configs" addLabel="Create configuration" onAdd={canManage?()=>void modal.open(close=><ConfigEditor close={close}/>,{size:'lg'}):undefined} onRowClick={row=>void modal.detail(close=><ConfigDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
43
+
44
+ export function PushPage(){const [searchParams,setSearchParams]=useSearchParams();const devices=useCan(PUSH_DEVICE_VIEW_PERMISSIONS);const deliveries=useCan(PUSH_DELIVERY_VIEW_PERMISSIONS);const templates=useCan(PUSH_TEMPLATE_VIEW_PERMISSIONS);const config=useCan(PUSH_CONFIG_VIEW_PERMISSIONS);const metrics=useCan(PUSH_METRICS_PERMISSIONS);if(devices.isLoading||deliveries.isLoading||templates.isLoading||config.isLoading||metrics.isLoading)return <div className="panel panel-pad dim">Loading Push permissions…</div>;const items=[{key:'stats',label:'Your stats',panel:<PushStatsPanel/>},...(metrics.can?[{key:'metrics',label:'Global metrics',panel:<PushMetricsPanel/>}]:[]),...(devices.can?[{key:'devices',label:'Devices',panel:<PushDevicesPage/>}]:[]),...(deliveries.can?[{key:'deliveries',label:'Deliveries',panel:<PushDeliveriesPage/>}]:[]),...(templates.can?[{key:'templates',label:'Templates',panel:<PushTemplatesPage/>}]:[]),...(config.can?[{key:'config',label:'Config',panel:<PushConfigsPage/>}]:[])];const requested=searchParams.get('push_surface')??'stats';const select=(key:string)=>setSearchParams({push_surface:key},{replace:true});return <div className="push-page"><div className="push-heading"><p className="eyebrow">Communications</p><h1>Push Notifications</h1><p className="dim">One global, no-group control plane. Each tab has an independent backend-derived gate; only the active authorized tab mounts and fetches.</p></div><Tabs ariaLabel="Push administration areas" variant="underline-all" items={items} activeKey={requested} onActiveKeyChange={select}/></div>;}
@@ -0,0 +1,17 @@
1
+ import type { QueryClient } from '@tanstack/react-query';
2
+ import { mojoCall, mojoList } from '../../../client';
3
+ import { PushConfigModel, sanitizePushConfigRow, type PushConfigRow, type PushRelation } from './models';
4
+
5
+ export interface PushStats { total_sent:number; total_failed:number; total_pending:number; registered_devices:number; enabled_devices:number }
6
+ export const PUSH_GROUP_CHOICE_LIMIT=100;
7
+ export type FcmCredentialEdit={mode:'untouched'}|{mode:'replace';value:Record<string,unknown>}|{mode:'clear';confirmed:true};
8
+
9
+ export function sanitizePushStats(value:unknown):PushStats{const row=value&&typeof value==='object'?value as Record<string,unknown>:{};const count=(key:string)=>Math.max(0,Number.isFinite(Number(row[key]))?Number(row[key]):0);return {total_sent:count('total_sent'),total_failed:count('total_failed'),total_pending:count('total_pending'),registered_devices:count('registered_devices'),enabled_devices:count('enabled_devices')};}
10
+ export async function fetchPushStats():Promise<PushStats>{const response=await mojoCall('/api/account/devices/push/stats');return sanitizePushStats(response.data);}
11
+
12
+ export function validateFcmServiceAccount(value:unknown):Record<string,unknown>{if(!value||typeof value!=='object'||Array.isArray(value))throw new Error('Service account must be a JSON object.');const row=value as Record<string,unknown>;if(row.type!=='service_account')throw new Error('Service account type must be "service_account".');for(const key of ['project_id','client_email','private_key'])if(typeof row[key]!=='string'||!row[key])throw new Error(`Service account is missing ${key}.`);if(!(row.private_key as string).includes('BEGIN PRIVATE KEY'))throw new Error('Service account private_key is not a PEM private key.');return {...row};}
13
+ export function parseFcmServiceAccount(source:string):Record<string,unknown>{let value:unknown;try{value=JSON.parse(source);}catch{throw new Error('Service account must be valid JSON.');}return validateFcmServiceAccount(value);}
14
+ export function buildPushConfigPayload(scalars:Record<string,unknown>,credential:FcmCredentialEdit):Record<string,unknown>{const body={...scalars};if(credential.mode==='clear')body.fcm_service_account=null;if(credential.mode==='replace')body.fcm_service_account=validateFcmServiceAccount(credential.value);return body;}
15
+ export async function savePushConfigImperative(qc:QueryClient,id:number|null,scalars:Record<string,unknown>,credential:FcmCredentialEdit):Promise<PushConfigRow>{const payload=buildPushConfigPayload(scalars,credential);const path=id==null?PushConfigModel.endpoint:`${PushConfigModel.endpoint}/${id}`;try{const response=await mojoCall(path,{method:'POST',body:payload});const row=sanitizePushConfigRow(response.data as PushConfigRow);qc.setQueryData(PushConfigModel.keys.one(row.id),row);await qc.invalidateQueries({queryKey:PushConfigModel.keys.root});return row;}finally{delete payload.fcm_service_account;}}
16
+ export async function testPushConfigConnection(id:number):Promise<{success:boolean;message:string;test_mode:boolean;note:string|null;fcm_version:string|null}>{const response=await mojoCall(`${PushConfigModel.endpoint}/${id}/test`,{method:'POST',body:{}});const row=(response.data??response) as Record<string,unknown>;return {success:row.success!==false,message:typeof row.message==='string'?row.message.slice(0,500):'FCM connection test completed.',test_mode:Boolean(row.test_mode),note:typeof row.note==='string'?row.note.slice(0,500):null,fcm_version:typeof row.fcm_version==='string'?row.fcm_version.slice(0,30):null};}
17
+ export async function fetchPushGroupChoices():Promise<PushRelation[]>{const response=await mojoList<Record<string,unknown>>('/api/group',{graph:'basic',sort:'name',start:0,size:PUSH_GROUP_CHOICE_LIMIT});return response.rows.flatMap(row=>{const id=Number(row.id);return Number.isFinite(id)&&typeof row.name==='string'?[{id,name:row.name.slice(0,120)}]:[];});}
@@ -0,0 +1,9 @@
1
+ import type { AdminSection } from '../../index';
2
+ import { PushPage } from './PushPage';
3
+ import { PUSH_ADMIN_PERMISSIONS } from './models';
4
+
5
+ export * from './models';
6
+ export * from './api';
7
+ export * from './PushPage';
8
+
9
+ export const PUSH_ADMIN_SECTION:AdminSection={id:'push',basePath:'push',title:'Push Notifications',icon:'bi-bell',navigationGroup:'communications',permissions:PUSH_ADMIN_PERMISSIONS,routes:[{path:'',label:'Push Notifications',component:PushPage,permissions:PUSH_ADMIN_PERMISSIONS}]};
@@ -0,0 +1,43 @@
1
+ import { defineModel, registerNonFilterParams, type Params } from '../../../client';
2
+
3
+ registerNonFilterParams('push_surface');
4
+
5
+ export const PUSH_DEVICE_VIEW_PERMISSIONS = ['sys.view_devices', 'sys.manage_devices', 'sys.comms', 'sys.owner', 'sys.manage_users'];
6
+ export const PUSH_DEVICE_MANAGE_PERMISSIONS = ['sys.manage_devices', 'sys.comms', 'sys.owner'];
7
+ export const PUSH_DELIVERY_VIEW_PERMISSIONS = ['sys.view_notifications', 'sys.manage_notifications', 'sys.comms', 'sys.owner', 'sys.manage_users'];
8
+ export const PUSH_DELIVERY_MANAGE_PERMISSIONS = ['sys.manage_notifications', 'sys.comms'];
9
+ export const PUSH_TEMPLATE_VIEW_PERMISSIONS = ['sys.manage_notifications', 'sys.manage_groups', 'sys.comms', 'sys.owner', 'sys.manage_users'];
10
+ export const PUSH_TEMPLATE_MANAGE_PERMISSIONS = ['sys.manage_notifications', 'sys.manage_groups', 'sys.comms'];
11
+ export const PUSH_CONFIG_VIEW_PERMISSIONS = ['sys.manage_push_config', 'sys.manage_groups', 'sys.comms'];
12
+ export const PUSH_CONFIG_MANAGE_PERMISSIONS = PUSH_CONFIG_VIEW_PERMISSIONS;
13
+ export const PUSH_CONFIG_TEST_PERMISSIONS = ['sys.manage_push_config', 'sys.comms'];
14
+ export const PUSH_METRICS_PERMISSIONS = ['sys.view_metrics', 'sys.metrics'];
15
+ export const PUSH_GROUP_DIRECTORY_PERMISSIONS = ['sys.view_groups', 'sys.manage_groups', 'sys.manage_group', 'sys.groups'];
16
+ export const PUSH_ADMIN_PERMISSIONS = [...new Set([
17
+ ...PUSH_DEVICE_VIEW_PERMISSIONS, ...PUSH_DELIVERY_VIEW_PERMISSIONS,
18
+ ...PUSH_TEMPLATE_VIEW_PERMISSIONS, ...PUSH_CONFIG_VIEW_PERMISSIONS,
19
+ ...PUSH_METRICS_PERMISSIONS,
20
+ ])];
21
+
22
+ export interface PushRelation { id:number; name?:string; display_name?:string }
23
+ export interface PushDeviceRow { id:number; device_id:string; platform:'ios'|'android'|'web'|string; device_name:string; app_version:string; os_version:string; push_enabled:boolean; push_preferences:Record<string,boolean>; last_seen:number; user:PushRelation|null }
24
+ export interface PushDeliveryRow { id:number; created:number; title:string|null; body:string|null; category:string; action_url:string|null; status:'pending'|'sent'|'delivered'|'failed'|string; sent_at:number|null; delivered_at:number|null; error_message:string|null; user:PushRelation|null; device:PushRelation|null }
25
+ export interface PushTemplateRow { id:number; name:string; title_template:string|null; body_template:string|null; action_url:string|null; data_template:Record<string,unknown>; category:string; priority:'low'|'normal'|'high'; variables:Record<string,unknown>; is_active:boolean; group:PushRelation|null }
26
+ export interface PushConfigRow { id:number; created:number; modified:number; name:string; test_mode:boolean; default_sound:string; is_active:boolean; fcm_project_id:string|null; group:PushRelation|null }
27
+
28
+ const asText=(value:unknown,max=2000):string|null=>typeof value==='string'?value.slice(0,max):null;
29
+ const asEpoch=(value:unknown):number|null=>typeof value==='number'&&Number.isFinite(value)?value:null;
30
+ function relation(value:unknown):PushRelation|null { if(!value||typeof value!=='object')return null;const row=value as Record<string,unknown>;const id=Number(row.id);if(!Number.isFinite(id))return null;return {id,...(typeof row.name==='string'?{name:row.name.slice(0,120)}:{}),...(typeof row.display_name==='string'?{display_name:row.display_name.slice(0,120)}:{})}; }
31
+ function safeObject(value:unknown,limit=50):Record<string,unknown>{if(!value||typeof value!=='object'||Array.isArray(value))return {};const out:Record<string,unknown>={};for(const [key,item] of Object.entries(value).slice(0,limit)){if(typeof item==='string')out[key.slice(0,100)]=item.slice(0,2000);else if(typeof item==='number'||typeof item==='boolean'||item==null)out[key.slice(0,100)]=item;}return out;}
32
+ export function sanitizePushDeviceRow(input:PushDeviceRow):PushDeviceRow {const row=input as unknown as Record<string,unknown>;const preferences=safeObject(row.push_preferences);return {id:Number(row.id),device_id:String(row.device_id??'').slice(0,255),platform:String(row.platform??''),device_name:String(row.device_name??'').slice(0,100),app_version:String(row.app_version??'').slice(0,50),os_version:String(row.os_version??'').slice(0,50),push_enabled:Boolean(row.push_enabled),push_preferences:Object.fromEntries(Object.entries(preferences).filter((entry):entry is [string,boolean]=>typeof entry[1]==='boolean')),last_seen:asEpoch(row.last_seen)??0,user:relation(row.user)};}
33
+ export function sanitizePushDeliveryRow(input:PushDeliveryRow):PushDeliveryRow {const row=input as unknown as Record<string,unknown>;return {id:Number(row.id),created:asEpoch(row.created)??0,title:asText(row.title,200),body:asText(row.body,10_000),category:String(row.category??'').slice(0,50),action_url:asText(row.action_url,2000),status:String(row.status??''),sent_at:asEpoch(row.sent_at),delivered_at:asEpoch(row.delivered_at),error_message:asText(row.error_message,2000),user:relation(row.user),device:relation(row.device)};}
34
+ export function sanitizePushTemplateRow(input:PushTemplateRow):PushTemplateRow {const row=input as unknown as Record<string,unknown>;const priority=['low','normal','high'].includes(String(row.priority))?String(row.priority) as PushTemplateRow['priority']:'normal';return {id:Number(row.id),name:String(row.name??'').slice(0,100),title_template:asText(row.title_template,200),body_template:asText(row.body_template,10_000),action_url:asText(row.action_url,2000),data_template:safeObject(row.data_template),category:String(row.category??'general').slice(0,50),priority,variables:safeObject(row.variables),is_active:Boolean(row.is_active),group:relation(row.group)};}
35
+ export function sanitizePushConfigRow(input:PushConfigRow):PushConfigRow {const row=input as unknown as Record<string,unknown>;return {id:Number(row.id),created:asEpoch(row.created)??0,modified:asEpoch(row.modified)??0,name:String(row.name??'').slice(0,100),test_mode:Boolean(row.test_mode),default_sound:String(row.default_sound??'default').slice(0,50),is_active:Boolean(row.is_active),fcm_project_id:asText(row.fcm_project_id,200),group:relation(row.group)};}
36
+
37
+ const BASE_KEYS=new Set(['start','size','search']);
38
+ function listParams(params:Params,graph:'basic'|'default',filters:readonly string[],sorts:readonly string[],defaultSort:string,dateRange=false):Params{const out:Params={graph,start:params.start??0,size:params.size??25,sort:defaultSort};for(const key of BASE_KEYS)if(params[key]!=null&&params[key]!=='')out[key]=params[key];for(const key of filters)if(params[key]!=null&&params[key]!=='')out[key]=params[key];const sort=typeof params.sort==='string'?params.sort:'';if(sorts.includes(sort.replace(/^-/,'')))out.sort=sort;if(dateRange&&(params.dr_start||params.dr_end)){out.dr_field='created';if(params.dr_start)out.dr_start=params.dr_start;if(params.dr_end)out.dr_end=params.dr_end;}return out;}
39
+
40
+ export const PushDeviceModel=defineModel<PushDeviceRow>({name:'push-device',endpoint:'/api/account/devices/push',permissions:{view:PUSH_DEVICE_VIEW_PERMISSIONS,manage:PUSH_DEVICE_MANAGE_PERMISSIONS},normalizeListParams:p=>listParams(p,'default',['platform','push_enabled','is_active','user'],['last_seen','platform','device_name','push_enabled'],'-last_seen'),sanitizeRow:sanitizePushDeviceRow});
41
+ export const PushDeliveryModel=defineModel<PushDeliveryRow>({name:'push-delivery',endpoint:'/api/account/devices/push/deliveries',permissions:{view:PUSH_DELIVERY_VIEW_PERMISSIONS,manage:PUSH_DELIVERY_MANAGE_PERMISSIONS},normalizeListParams:p=>listParams(p,'default',['status','category','user','device'],['created','status','category','sent_at','delivered_at'],'-created',true),sanitizeRow:sanitizePushDeliveryRow});
42
+ export const PushTemplateModel=defineModel<PushTemplateRow>({name:'push-template',endpoint:'/api/account/devices/push/templates',permissions:{view:PUSH_TEMPLATE_VIEW_PERMISSIONS,manage:PUSH_TEMPLATE_MANAGE_PERMISSIONS},normalizeListParams:p=>listParams(p,'default',['category','priority','is_active','group'],['name','category','priority','is_active'],'name'),sanitizeRow:sanitizePushTemplateRow});
43
+ export const PushConfigModel=defineModel<PushConfigRow>({name:'push-config',endpoint:'/api/account/devices/push/config',permissions:{view:PUSH_CONFIG_VIEW_PERMISSIONS,manage:PUSH_CONFIG_MANAGE_PERMISSIONS},normalizeListParams:p=>listParams(p,'default',['test_mode','is_active','group'],['name','test_mode','is_active','created','modified'],'name'),sanitizeRow:sanitizePushConfigRow});
@@ -0,0 +1,44 @@
1
+ import type { DomainActionResult, DomainAuditResult, EmailDomainRow, EmailTemplateRow, MailboxRow, PublicMessageRow, SentMessageRow } from './models';
2
+
3
+ const MAX_TEXT = 4000;
4
+ const object = (value: unknown): Record<string, unknown> => value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
5
+ const text = (value: unknown, max = MAX_TEXT): string => typeof value === 'string' ? value.slice(0, max) : '';
6
+ const nullableText = (value: unknown, max = MAX_TEXT): string | null => value == null ? null : text(value, max);
7
+ const number = (value: unknown): number => Number.isFinite(Number(value)) ? Number(value) : 0;
8
+ const relation = (value: unknown) => {
9
+ if (typeof value === 'number') return value;
10
+ const row = object(value); if (!Number.isInteger(row.id)) return null;
11
+ return { id: Number(row.id), ...(typeof row.name === 'string' ? { name: text(row.name, 200) } : {}), ...(typeof row.email === 'string' ? { email: text(row.email, 320) } : {}) };
12
+ };
13
+ const strings = (value: unknown, maxItems = 50, max = 1000): string[] => Array.isArray(value) ? value.slice(0, maxItems).map((item) => text(item, max)).filter(Boolean) : [];
14
+ const redactedOptionalText=(value:unknown,max=MAX_TEXT):string=>{const projected=text(value,max);return projected?redactMessagingError(projected):'';};
15
+
16
+ function safeMask(value:unknown):string|null{const masked=nullableText(value,64);if(!masked)return null;return /^(?:\*{1,60}|•{1,60})[^*•\s]{0,4}$/.test(masked)?masked:'Configured';}
17
+ export function sanitizeEmailDomainRow(value: EmailDomainRow): EmailDomainRow { const raw = object(value); const status=String(raw.status), dnsMode=String(raw.dns_mode); return { id: number(raw.id), created: number(raw.created), modified: number(raw.modified), name: text(raw.name, 253), region: text(raw.region, 64), status: (['pending','verified','ready','missing'].includes(status)?status:'unknown') as EmailDomainRow['status'], receiving_enabled: Boolean(raw.receiving_enabled), s3_inbound_bucket: nullableText(raw.s3_inbound_bucket, 255), s3_inbound_prefix: text(raw.s3_inbound_prefix, 255), dns_mode: (['manual','route53','godaddy'].includes(dnsMode)?dnsMode:'unknown') as EmailDomainRow['dns_mode'], aws_key_masked: safeMask(raw.aws_key_masked), aws_secret_masked: safeMask(raw.aws_secret_masked), sns_topic_bounce_arn: nullableText(raw.sns_topic_bounce_arn, 512), sns_topic_complaint_arn: nullableText(raw.sns_topic_complaint_arn, 512), sns_topic_delivery_arn: nullableText(raw.sns_topic_delivery_arn, 512), sns_topic_inbound_arn: nullableText(raw.sns_topic_inbound_arn, 512) }; }
18
+ export function sanitizeMailboxRow(value: MailboxRow): MailboxRow { const raw = object(value); return { id: number(raw.id), created: number(raw.created), modified: number(raw.modified), email: text(raw.email, 320), domain: relation(raw.domain), allow_inbound: Boolean(raw.allow_inbound), allow_outbound: Boolean(raw.allow_outbound), async_handler: nullableText(raw.async_handler, 255), is_system_default: Boolean(raw.is_system_default), is_domain_default: Boolean(raw.is_domain_default) }; }
19
+ export function sanitizeSentMessageRow(value: SentMessageRow): SentMessageRow { const raw = object(value); const reason=nullableText(raw.status_reason,1000); return { id: number(raw.id), created: number(raw.created), modified: number(raw.modified), mailbox: relation(raw.mailbox), to_addresses: strings(raw.to_addresses, 50, 320), cc_addresses: strings(raw.cc_addresses, 50, 320), bcc_addresses: strings(raw.bcc_addresses, 50, 320), subject: text(raw.subject, 998), status: (['queued','sending','delivered','bounced','complained','failed','unknown'].includes(String(raw.status)) ? String(raw.status) : 'unknown') as SentMessageRow['status'], ses_message_id: nullableText(raw.ses_message_id, 255), status_reason: reason?redactMessagingError(reason):null, body_text: nullableText(raw.body_text, 100_000), body_html: nullableText(raw.body_html, 200_000) }; }
20
+ export function sanitizeTemplateRow(value: EmailTemplateRow): EmailTemplateRow { const raw = object(value); return { id: number(raw.id), created: number(raw.created), modified: number(raw.modified), name: text(raw.name, 255), subject_template: text(raw.subject_template, 998), html_template: nullableText(raw.html_template, 200_000), text_template: nullableText(raw.text_template, 100_000) }; }
21
+ const PUBLIC_META = new Set(['company','category','severity','referrer','landing_page','utm_source','utm_medium','utm_campaign','utm_term','utm_content']);
22
+ export function sanitizePublicMessageRow(value: PublicMessageRow): PublicMessageRow { const raw = object(value); const source = object(raw.metadata); const metadata: Record<string,string> = {}; for (const [key, val] of Object.entries(source)) if (PUBLIC_META.has(key) && typeof val === 'string') metadata[key] = text(val, 500); return { id: number(raw.id), created: number(raw.created), modified: number(raw.modified), kind: text(raw.kind, 40), status: text(raw.status, 40), name: text(raw.name, 255), email: text(raw.email, 320), subject: text(raw.subject, 998), group: relation(raw.group), ...(typeof raw.message === 'string' ? { message: text(raw.message, 50_000) } : {}), ...(Object.keys(metadata).length ? { metadata } : {}), metadata_withheld: Object.keys(source).some((key) => !PUBLIC_META.has(key)), ip_address: nullableText(raw.ip_address, 64), user_agent: nullableText(raw.user_agent, 1000) }; }
23
+
24
+ const CHECKS = new Set(['ses_production_access','ses_verified','dkim_verified','notification_topics_ok','receiving_rule_s3_ok','receiving_rule_sns_ok','s3_bucket_exists','sns_topics_exist','sns_subscriptions_confirmed']);
25
+ export function sanitizeAuditResult(value: unknown): DomainAuditResult { const raw = object(value); const checks: Record<string,boolean> = {}; for (const [key,val] of Object.entries(object(raw.checks))) if (CHECKS.has(key) && typeof val === 'boolean') checks[key] = val; const items = Array.isArray(raw.items) ? raw.items.slice(0, 50).map((v) => object(v)).map((v) => ({ resource: redactedOptionalText(v.resource,100), status: redactedOptionalText(v.status,100) })).filter((v) => v.resource || v.status) : []; const recommendations=Array.isArray(raw.recommendations)?raw.recommendations.slice(0,25).map((entry)=>object(entry)).map((entry)=>({resource:redactedOptionalText(entry.resource,100),severity:redactedOptionalText(entry.severity,40),action:redactedOptionalText(entry.action,500),explanation:redactedOptionalText(entry.explanation,1000)})).filter(entry=>entry.resource&&entry.severity&&entry.action&&entry.explanation):[]; return { domain: text(raw.domain, 253), region: text(raw.region, 64), status: text(raw.status, 40), audit_pass: Boolean(raw.audit_pass), checks, items, recommendations }; }
26
+ function records(value: unknown) { return Array.isArray(value) ? value.slice(0, 100).flatMap((v) => { const r=object(v); const type=text(r.type,16), name=text(r.name,253); const values = Array.isArray(r.record_values) ? strings(r.record_values, 20, 4000) : [text(r.value,4000)].filter(Boolean); return values.map((item) => ({ type, name, value:item, ttl: Math.min(86400, Math.max(1,number(r.ttl)||300)) })); }) : []; }
27
+ export function sanitizeDomainActionResult(value: unknown): DomainActionResult { const raw=object(value); const appliedRaw=object(raw.applied); const applied = Number.isInteger(raw.applied) ? Number(raw.applied) : Number.isInteger(appliedRaw.applied) ? Number(appliedRaw.applied) : undefined; const topic_arns: Record<string,string> = {}; for (const key of ['bounce','complaint','delivery','inbound']) { const val=object(raw.topic_arns)[key]; if (typeof val === 'string') topic_arns[key]=text(val,512); } return { domain:text(raw.domain,253), region:text(raw.region,64), ...(typeof raw.status==='string'?{status:text(raw.status,40)}:{}), dns_records:records(raw.dns_records), dkim_tokens:strings(raw.dkim_tokens,20,255), notes:strings(raw.notes,20,500), ...(typeof raw.provider==='string'?{provider:text(raw.provider,64)}:{}), ...(applied!==undefined?{applied}:{}), ...(Object.keys(topic_arns).length?{topic_arns}:{}), ...(typeof raw.receipt_rule==='string'?{receipt_rule:text(raw.receipt_rule,255)}:{}), ...(typeof raw.rule_set==='string'?{rule_set:text(raw.rule_set,255)}:{}) }; }
28
+
29
+ const SECRET_KEY='(?:aws[_-]?(?:secret[_-]?access[_-]?key|access[_-]?key(?:[_-]?id)?)|x-amz-security-token|godaddy[_-]?(?:key|secret)|api[_-]?key|access[_-]?token|refresh[_-]?token|secret|token|password|credential)';
30
+ export function redactMessagingError(error: unknown): string {
31
+ const source=error instanceof Error?error.message:typeof error==='string'?error:'Operation failed';
32
+ const keyed=new RegExp(`(["']?${SECRET_KEY}["']?\\s*[:=]\\s*)(?:"[^"]*"|'[^']*'|[^\\s,;}&]+)`,'gi');
33
+ const query=new RegExp(`([?&])(${SECRET_KEY})=[^&#\\s]*`,'gi');
34
+ const clean=source
35
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi,'$1[redacted]@')
36
+ .replace(/\b(authorization|proxy-authorization)\s*:\s*(?:bearer|basic)\s+[^\s,;]+/gi,'$1: [redacted]')
37
+ .replace(/\bbearer\s+[A-Za-z0-9._~+/=-]+/gi,'Bearer [redacted]')
38
+ .replace(query,'$1$2=[redacted]')
39
+ .replace(keyed,'$1[redacted]')
40
+ .replace(/(?:AKIA|ASIA)[0-9A-Z]{16}/g,'[redacted]');
41
+ return clean.slice(0,1000)||'Operation failed';
42
+ }
43
+ export function isOneMailbox(value: string): boolean { return /^[^\s@,;&#?]+@[^\s@,;&#?]+\.[^\s@,;&#?]+$/.test(value.trim()); }
44
+ export function publicMessageMailto(row: PublicMessageRow): string | null { if (!isOneMailbox(row.email)) return null; const params = new URLSearchParams({ subject: `Re: ${row.subject}` }); return `mailto:${encodeURIComponent(row.email.trim())}?${params.toString()}`; }