@rapidmx/web-client 0.10.0 → 0.11.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 (310) hide show
  1. package/README.md +183 -2
  2. package/apps/admin/_layout.tsx +8 -2
  3. package/apps/admin/index.tsx +7 -1
  4. package/apps/admin/ingest-queue/index.tsx +2 -1
  5. package/apps/admin/mailboxes/[uid].tsx +23 -10
  6. package/apps/admin/quarantine/index.tsx +2 -1
  7. package/apps/admin/signing-certificates/index.tsx +343 -0
  8. package/apps/escrow/_layout.tsx +8 -2
  9. package/apps/shared/appearance/AppearanceForm.tsx +392 -0
  10. package/apps/shared/appearance/AppearanceHead.tsx +49 -0
  11. package/apps/shared/appearance/AppearanceProvider.tsx +470 -0
  12. package/apps/shared/appearance/ColorField.tsx +107 -0
  13. package/apps/shared/appearance/appearanceCache.ts +106 -0
  14. package/apps/shared/appearance/appearanceContext.ts +77 -0
  15. package/apps/shared/appearance/bootScript.ts +35 -0
  16. package/apps/shared/appearance/color.ts +169 -0
  17. package/apps/shared/appearance/photo.ts +88 -0
  18. package/apps/shared/appearance/resolvedTheme.ts +67 -0
  19. package/apps/shared/appearance/theme.ts +355 -0
  20. package/apps/shared/components/admin/layout/AdminShell.tsx +22 -10
  21. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +107 -60
  22. package/apps/shared/components/admin/settings/BrandingForm.tsx +24 -0
  23. package/apps/shared/components/admin/settings/PluginsManager.tsx +228 -25
  24. package/apps/shared/components/admin/setup/SetupWizard.tsx +2 -1
  25. package/apps/shared/components/calendar/CalendarListSidebar.tsx +4 -5
  26. package/apps/shared/components/contacts/ContactForm.tsx +11 -5
  27. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -3
  28. package/apps/shared/components/contacts/ContactsToolbar.tsx +25 -52
  29. package/apps/shared/components/contacts/layout/ContactsShell.tsx +10 -5
  30. package/apps/shared/components/escrow/layout/EscrowShell.tsx +13 -6
  31. package/apps/shared/components/layout/AppShell.tsx +98 -32
  32. package/apps/shared/components/layout/BrandingChrome.tsx +200 -13
  33. package/apps/shared/components/layout/RailIcon.tsx +100 -0
  34. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -0
  35. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -0
  36. package/apps/shared/components/layout/UserMenu.tsx +75 -6
  37. package/apps/shared/components/mail/ConversationList.tsx +4 -2
  38. package/apps/shared/components/mail/ConversationThreadPane.tsx +487 -475
  39. package/apps/shared/components/mail/LazyReadingPane.tsx +14 -16
  40. package/apps/shared/components/mail/MailSelectionBar.tsx +2 -10
  41. package/apps/shared/components/mail/MessageDetailPane.tsx +366 -287
  42. package/apps/shared/components/mail/OutboxBadge.tsx +44 -0
  43. package/apps/shared/components/mail/OutboxRowStatus.tsx +31 -0
  44. package/apps/shared/components/mail/compose/ComposeContext.tsx +35 -2
  45. package/apps/shared/components/mail/compose/ComposeWindow.tsx +239 -437
  46. package/apps/shared/components/mail/compose/encryptionRequirement.ts +86 -0
  47. package/apps/shared/components/mail/layout/MailShell.tsx +39 -25
  48. package/apps/shared/components/mail/reading/EncryptedBody.tsx +65 -0
  49. package/apps/shared/components/mail/reading/EncryptedPreview.tsx +35 -0
  50. package/apps/shared/components/mail/reading/MessageBody.tsx +240 -0
  51. package/apps/shared/components/mail/reading/MessageCard.tsx +174 -0
  52. package/apps/shared/components/mail/reading/bodyContent.ts +100 -0
  53. package/apps/shared/components/mail/reading/bodyHtml.ts +298 -0
  54. package/apps/shared/components/mail/reading/color.ts +245 -0
  55. package/apps/shared/components/mail/reading/frameControl.ts +142 -0
  56. package/apps/shared/components/mail/reading/frameDocument.ts +123 -0
  57. package/apps/shared/components/mail/reading/safeDocument.ts +24 -0
  58. package/apps/shared/components/mail/reading/themeAdaptation.ts +221 -0
  59. package/apps/shared/components/mail/reading/themeSurface.ts +90 -0
  60. package/apps/shared/components/mail/reading/viewOriginal.ts +38 -0
  61. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -0
  62. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  63. package/apps/shared/components/sharing/PrincipalPicker.tsx +145 -0
  64. package/apps/shared/components/tasks/TasksSidebar.tsx +5 -3
  65. package/apps/shared/components/tasks/layout/TasksShell.tsx +9 -5
  66. package/apps/shared/mail/folderCounts.ts +9 -3
  67. package/apps/shared/mail/folderTree.ts +143 -0
  68. package/apps/shared/mail/outbox/UnlockBridge.tsx +23 -0
  69. package/apps/shared/mail/outbox/composeBridge.ts +69 -0
  70. package/apps/shared/mail/outbox/outboxState.ts +88 -0
  71. package/apps/shared/mail/outbox/pendingSends.ts +146 -0
  72. package/apps/shared/mail/outbox/sendDecision.ts +157 -0
  73. package/apps/shared/mail/outbox/sendJob.ts +427 -0
  74. package/apps/shared/mail/outbox/sendOutcomes.ts +146 -0
  75. package/apps/shared/mail/outbox/sendState.ts +35 -0
  76. package/apps/shared/mail/outbox/useOutboxStatus.ts +61 -0
  77. package/apps/shared/mail/useMailConnection.ts +81 -28
  78. package/apps/shared/mail/useMailLiveUpdates.ts +55 -10
  79. package/apps/shared/mail/useNewMailNotifications.ts +47 -32
  80. package/apps/shared/navigation/AppRouter.tsx +2 -2
  81. package/apps/shared/notifications/NotificationCenter.tsx +215 -0
  82. package/apps/shared/notifications/NotificationHistoryDialog.tsx +90 -0
  83. package/apps/shared/notifications/apiErrors.ts +98 -0
  84. package/apps/shared/notifications/headerOffset.ts +35 -0
  85. package/apps/shared/notifications/pushStatus.ts +56 -0
  86. package/apps/shared/notifications/store.ts +549 -0
  87. package/apps/shared/notifications/systemErrors.ts +63 -0
  88. package/apps/shared/notifications/useNotifications.ts +41 -0
  89. package/apps/shared/signing/enrollmentStorage.ts +33 -0
  90. package/apps/shared/signing/enrollmentTracker.ts +385 -0
  91. package/apps/shared/signing/enrollmentView.ts +251 -0
  92. package/apps/shared/signing/signingInfo.ts +58 -0
  93. package/apps/shared/signing/useNow.ts +19 -0
  94. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -0
  95. package/apps/shared/styles/app.css +80 -0
  96. package/apps/www/_layout.tsx +8 -2
  97. package/apps/www/_routes.ts +1 -0
  98. package/apps/www/calendar/index.tsx +9 -14
  99. package/apps/www/contacts/[uid].tsx +5 -8
  100. package/apps/www/contacts/index.tsx +587 -584
  101. package/apps/www/index.tsx +38 -27
  102. package/apps/www/messages/[uid].tsx +3 -1
  103. package/apps/www/settings/appearance/index.tsx +21 -0
  104. package/apps/www/settings/auto-reply/index.tsx +3 -7
  105. package/apps/www/settings/encryption/index.tsx +151 -116
  106. package/apps/www/settings/filters/[uid].tsx +3 -1
  107. package/apps/www/settings/filters/new/index.tsx +3 -1
  108. package/apps/www/settings/privacy/index.tsx +3 -8
  109. package/apps/www/settings/read-receipts/index.tsx +3 -7
  110. package/apps/www/settings/sharing/index.tsx +44 -65
  111. package/apps/www/settings/signatures/[uid].tsx +3 -1
  112. package/apps/www/settings/signatures/new/index.tsx +3 -2
  113. package/apps/www/tasks/index.tsx +11 -16
  114. package/dist/apps/admin/_layout.d.ts +5 -1
  115. package/dist/apps/admin/_layout.js +3 -2
  116. package/dist/apps/admin/index.js +3 -2
  117. package/dist/apps/admin/ingest-queue/index.js +2 -1
  118. package/dist/apps/admin/mailboxes/[uid].js +9 -6
  119. package/dist/apps/admin/quarantine/index.js +2 -1
  120. package/dist/apps/admin/signing-certificates/index.d.ts +3 -0
  121. package/dist/apps/admin/signing-certificates/index.js +161 -0
  122. package/dist/apps/escrow/_layout.d.ts +5 -1
  123. package/dist/apps/escrow/_layout.js +3 -2
  124. package/dist/apps/shared/appearance/AppearanceForm.d.ts +7 -0
  125. package/dist/apps/shared/appearance/AppearanceForm.js +131 -0
  126. package/dist/apps/shared/appearance/AppearanceHead.d.ts +15 -0
  127. package/dist/apps/shared/appearance/AppearanceHead.js +31 -0
  128. package/dist/apps/shared/appearance/AppearanceProvider.d.ts +32 -0
  129. package/dist/apps/shared/appearance/AppearanceProvider.js +390 -0
  130. package/dist/apps/shared/appearance/ColorField.d.ts +22 -0
  131. package/dist/apps/shared/appearance/ColorField.js +40 -0
  132. package/dist/apps/shared/appearance/appearanceCache.d.ts +45 -0
  133. package/dist/apps/shared/appearance/appearanceCache.js +80 -0
  134. package/dist/apps/shared/appearance/appearanceContext.d.ts +45 -0
  135. package/dist/apps/shared/appearance/appearanceContext.js +29 -0
  136. package/dist/apps/shared/appearance/bootScript.d.ts +16 -0
  137. package/dist/apps/shared/appearance/bootScript.js +33 -0
  138. package/dist/apps/shared/appearance/color.d.ts +65 -0
  139. package/dist/apps/shared/appearance/color.js +132 -0
  140. package/dist/apps/shared/appearance/photo.d.ts +17 -0
  141. package/dist/apps/shared/appearance/photo.js +79 -0
  142. package/dist/apps/shared/appearance/resolvedTheme.d.ts +21 -0
  143. package/dist/apps/shared/appearance/resolvedTheme.js +55 -0
  144. package/dist/apps/shared/appearance/theme.d.ts +111 -0
  145. package/dist/apps/shared/appearance/theme.js +251 -0
  146. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  147. package/dist/apps/shared/components/admin/layout/AdminShell.js +16 -6
  148. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +10 -5
  149. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +49 -34
  150. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +2 -0
  151. package/dist/apps/shared/components/admin/settings/BrandingForm.js +7 -1
  152. package/dist/apps/shared/components/admin/settings/PluginsManager.js +106 -20
  153. package/dist/apps/shared/components/admin/setup/SetupWizard.js +2 -1
  154. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +8 -8
  155. package/dist/apps/shared/components/contacts/ContactForm.js +1 -1
  156. package/dist/apps/shared/components/contacts/ContactsSidebar.js +4 -2
  157. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  158. package/dist/apps/shared/components/contacts/ContactsToolbar.js +24 -9
  159. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -1
  160. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +7 -4
  161. package/dist/apps/shared/components/layout/AppShell.d.ts +6 -1
  162. package/dist/apps/shared/components/layout/AppShell.js +59 -13
  163. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +69 -10
  164. package/dist/apps/shared/components/layout/BrandingChrome.js +140 -12
  165. package/dist/apps/shared/components/layout/RailIcon.d.ts +21 -0
  166. package/dist/apps/shared/components/layout/RailIcon.js +85 -0
  167. package/dist/apps/shared/components/layout/ResponsiveToolbar.d.ts +55 -0
  168. package/dist/apps/shared/components/layout/ResponsiveToolbar.js +199 -0
  169. package/dist/apps/shared/components/layout/ThemeSwitch.d.ts +9 -0
  170. package/dist/apps/shared/components/layout/ThemeSwitch.js +54 -0
  171. package/dist/apps/shared/components/layout/UserMenu.d.ts +7 -1
  172. package/dist/apps/shared/components/layout/UserMenu.js +41 -7
  173. package/dist/apps/shared/components/mail/ConversationList.js +3 -2
  174. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +2 -1
  175. package/dist/apps/shared/components/mail/ConversationThreadPane.js +42 -20
  176. package/dist/apps/shared/components/mail/LazyReadingPane.js +9 -8
  177. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +3 -4
  178. package/dist/apps/shared/components/mail/MailSelectionBar.js +2 -3
  179. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +18 -7
  180. package/dist/apps/shared/components/mail/MessageDetailPane.js +86 -41
  181. package/dist/apps/shared/components/mail/OutboxBadge.d.ts +17 -0
  182. package/dist/apps/shared/components/mail/OutboxBadge.js +18 -0
  183. package/dist/apps/shared/components/mail/OutboxRowStatus.d.ts +10 -0
  184. package/dist/apps/shared/components/mail/OutboxRowStatus.js +19 -0
  185. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +8 -0
  186. package/dist/apps/shared/components/mail/compose/ComposeContext.js +28 -3
  187. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +2 -1
  188. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +229 -328
  189. package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +49 -0
  190. package/dist/apps/shared/components/mail/compose/encryptionRequirement.js +35 -0
  191. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +2 -0
  192. package/dist/apps/shared/components/mail/layout/MailShell.js +20 -14
  193. package/dist/apps/shared/components/mail/reading/EncryptedBody.d.ts +21 -0
  194. package/dist/apps/shared/components/mail/reading/EncryptedBody.js +22 -0
  195. package/dist/apps/shared/components/mail/reading/EncryptedPreview.d.ts +18 -0
  196. package/dist/apps/shared/components/mail/reading/EncryptedPreview.js +22 -0
  197. package/dist/apps/shared/components/mail/reading/MessageBody.d.ts +32 -0
  198. package/dist/apps/shared/components/mail/reading/MessageBody.js +136 -0
  199. package/dist/apps/shared/components/mail/reading/MessageCard.d.ts +62 -0
  200. package/dist/apps/shared/components/mail/reading/MessageCard.js +58 -0
  201. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +38 -0
  202. package/dist/apps/shared/components/mail/reading/bodyContent.js +84 -0
  203. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +47 -0
  204. package/dist/apps/shared/components/mail/reading/bodyHtml.js +267 -0
  205. package/dist/apps/shared/components/mail/reading/color.d.ts +63 -0
  206. package/dist/apps/shared/components/mail/reading/color.js +201 -0
  207. package/dist/apps/shared/components/mail/reading/frameControl.d.ts +33 -0
  208. package/dist/apps/shared/components/mail/reading/frameControl.js +120 -0
  209. package/dist/apps/shared/components/mail/reading/frameDocument.d.ts +52 -0
  210. package/dist/apps/shared/components/mail/reading/frameDocument.js +98 -0
  211. package/dist/apps/shared/components/mail/reading/safeDocument.d.ts +3 -0
  212. package/dist/apps/shared/components/mail/reading/safeDocument.js +12 -0
  213. package/dist/apps/shared/components/mail/reading/themeAdaptation.d.ts +56 -0
  214. package/dist/apps/shared/components/mail/reading/themeAdaptation.js +144 -0
  215. package/dist/apps/shared/components/mail/reading/themeSurface.d.ts +17 -0
  216. package/dist/apps/shared/components/mail/reading/themeSurface.js +83 -0
  217. package/dist/apps/shared/components/mail/reading/viewOriginal.d.ts +9 -0
  218. package/dist/apps/shared/components/mail/reading/viewOriginal.js +32 -0
  219. package/dist/apps/shared/components/settings/SigningCertificateCard.d.ts +32 -0
  220. package/dist/apps/shared/components/settings/SigningCertificateCard.js +98 -0
  221. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  222. package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +26 -0
  223. package/dist/apps/shared/components/sharing/PrincipalPicker.js +77 -0
  224. package/dist/apps/shared/components/tasks/TasksSidebar.js +4 -2
  225. package/dist/apps/shared/components/tasks/layout/TasksShell.js +3 -1
  226. package/dist/apps/shared/mail/folderCounts.d.ts +1 -1
  227. package/dist/apps/shared/mail/folderCounts.js +9 -3
  228. package/dist/apps/shared/mail/folderTree.d.ts +55 -0
  229. package/dist/apps/shared/mail/folderTree.js +116 -0
  230. package/dist/apps/shared/mail/outbox/UnlockBridge.d.ts +5 -0
  231. package/dist/apps/shared/mail/outbox/UnlockBridge.js +18 -0
  232. package/dist/apps/shared/mail/outbox/composeBridge.d.ts +34 -0
  233. package/dist/apps/shared/mail/outbox/composeBridge.js +32 -0
  234. package/dist/apps/shared/mail/outbox/outboxState.d.ts +41 -0
  235. package/dist/apps/shared/mail/outbox/outboxState.js +42 -0
  236. package/dist/apps/shared/mail/outbox/pendingSends.d.ts +39 -0
  237. package/dist/apps/shared/mail/outbox/pendingSends.js +109 -0
  238. package/dist/apps/shared/mail/outbox/sendDecision.d.ts +80 -0
  239. package/dist/apps/shared/mail/outbox/sendDecision.js +94 -0
  240. package/dist/apps/shared/mail/outbox/sendJob.d.ts +68 -0
  241. package/dist/apps/shared/mail/outbox/sendJob.js +319 -0
  242. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +3 -0
  243. package/dist/apps/shared/mail/outbox/sendOutcomes.js +141 -0
  244. package/dist/apps/shared/mail/outbox/sendState.d.ts +25 -0
  245. package/dist/apps/shared/mail/outbox/sendState.js +8 -0
  246. package/dist/apps/shared/mail/outbox/useOutboxStatus.d.ts +11 -0
  247. package/dist/apps/shared/mail/outbox/useOutboxStatus.js +49 -0
  248. package/dist/apps/shared/mail/useMailConnection.d.ts +9 -8
  249. package/dist/apps/shared/mail/useMailConnection.js +64 -22
  250. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +17 -4
  251. package/dist/apps/shared/mail/useMailLiveUpdates.js +36 -10
  252. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +4 -10
  253. package/dist/apps/shared/mail/useNewMailNotifications.js +43 -23
  254. package/dist/apps/shared/navigation/AppRouter.js +2 -2
  255. package/dist/apps/shared/notifications/NotificationCenter.d.ts +19 -0
  256. package/dist/apps/shared/notifications/NotificationCenter.js +90 -0
  257. package/dist/apps/shared/notifications/NotificationHistoryDialog.d.ts +10 -0
  258. package/dist/apps/shared/notifications/NotificationHistoryDialog.js +36 -0
  259. package/dist/apps/shared/notifications/apiErrors.d.ts +19 -0
  260. package/dist/apps/shared/notifications/apiErrors.js +86 -0
  261. package/dist/apps/shared/notifications/headerOffset.d.ts +8 -0
  262. package/dist/apps/shared/notifications/headerOffset.js +33 -0
  263. package/dist/apps/shared/notifications/pushStatus.d.ts +9 -0
  264. package/dist/apps/shared/notifications/pushStatus.js +54 -0
  265. package/dist/apps/shared/notifications/store.d.ts +136 -0
  266. package/dist/apps/shared/notifications/store.js +416 -0
  267. package/dist/apps/shared/notifications/systemErrors.d.ts +9 -0
  268. package/dist/apps/shared/notifications/systemErrors.js +58 -0
  269. package/dist/apps/shared/notifications/useNotifications.d.ts +17 -0
  270. package/dist/apps/shared/notifications/useNotifications.js +18 -0
  271. package/dist/apps/shared/signing/enrollmentStorage.d.ts +10 -0
  272. package/dist/apps/shared/signing/enrollmentStorage.js +33 -0
  273. package/dist/apps/shared/signing/enrollmentTracker.d.ts +73 -0
  274. package/dist/apps/shared/signing/enrollmentTracker.js +307 -0
  275. package/dist/apps/shared/signing/enrollmentView.d.ts +57 -0
  276. package/dist/apps/shared/signing/enrollmentView.js +214 -0
  277. package/dist/apps/shared/signing/signingInfo.d.ts +11 -0
  278. package/dist/apps/shared/signing/signingInfo.js +48 -0
  279. package/dist/apps/shared/signing/useNow.d.ts +2 -0
  280. package/dist/apps/shared/signing/useNow.js +18 -0
  281. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.d.ts +16 -0
  282. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.js +87 -0
  283. package/dist/apps/shared/styles/app.css +80 -0
  284. package/dist/apps/www/_layout.d.ts +5 -1
  285. package/dist/apps/www/_layout.js +3 -2
  286. package/dist/apps/www/_routes.js +1 -0
  287. package/dist/apps/www/calendar/index.js +3 -4
  288. package/dist/apps/www/contacts/[uid].js +5 -7
  289. package/dist/apps/www/contacts/index.js +14 -21
  290. package/dist/apps/www/index.js +37 -19
  291. package/dist/apps/www/messages/[uid].js +3 -1
  292. package/dist/apps/www/settings/appearance/index.d.ts +4 -0
  293. package/dist/apps/www/settings/appearance/index.js +13 -0
  294. package/dist/apps/www/settings/auto-reply/index.js +3 -6
  295. package/dist/apps/www/settings/encryption/index.js +103 -95
  296. package/dist/apps/www/settings/filters/[uid].js +3 -1
  297. package/dist/apps/www/settings/filters/new/index.js +3 -1
  298. package/dist/apps/www/settings/privacy/index.js +5 -8
  299. package/dist/apps/www/settings/read-receipts/index.js +3 -6
  300. package/dist/apps/www/settings/sharing/index.js +19 -33
  301. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  302. package/dist/apps/www/settings/signatures/new/index.js +3 -2
  303. package/dist/apps/www/tasks/index.js +8 -14
  304. package/package.json +2 -2
  305. package/apps/shared/components/mail/NewMailToasts.tsx +0 -141
  306. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +0 -48
  307. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +0 -19
  308. package/dist/apps/shared/components/mail/NewMailToasts.js +0 -55
  309. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +0 -14
  310. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +0 -11
@@ -0,0 +1,174 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import ContactAvatar from "@rapidmx/react-shared/components/avatar/ContactAvatar.js";
7
+ import Skeleton from "@rapidmx/react-shared/components/feedback/Skeleton.js";
8
+ import type { MailAddressLike } from "@rapidmx/react-shared/mail/mailAddress.js";
9
+ import MailAddress from "../MailAddress.js";
10
+ import { UnreadBar, UnreadLabel } from "../unreadStyle.js";
11
+
12
+ /**
13
+ * The pieces the reading pane is drawn from: a header card that carries the subject, and one card per message. Built from the app's own
14
+ * tokens only (`bg-surface`, `text-text`, `border-border`), so they follow the theme, a branding palette and the Appearance colours; the
15
+ * body area inside a card paints its own opaque surface (`MessageBody`), so a translucent card over a background photo stays legible.
16
+ */
17
+
18
+ /** The header-style card at the top of the pane: the conversation's (or message's) subject, and what goes with it. */
19
+ export function SubjectCard({
20
+ subject,
21
+ meta,
22
+ children,
23
+ }: {
24
+ subject: string;
25
+ /** A line under the subject, such as "3 messages". */
26
+ meta?: React.ReactNode;
27
+ children?: React.ReactNode;
28
+ }) {
29
+ return (
30
+ <header className="shrink-0 mx-2 mt-2 sm:mx-3 sm:mt-3 rounded-lg border border-border bg-surface backdrop-blur-sm shadow-sm px-4 py-2.5">
31
+ <h1 className="text-base sm:text-lg font-bold tracking-tight line-clamp-2 break-words">{subject}</h1>
32
+ {meta && <p className="text-xs sm:text-sm text-text-muted mt-0.5">{meta}</p>}
33
+ {children}
34
+ </header>
35
+ );
36
+ }
37
+
38
+ /** One message's card: rounded, bordered, lifted a little, its content clipped to the corners. The accent bar of an unread message runs down its left edge. */
39
+ export function CardShell({
40
+ unread = false,
41
+ className = "",
42
+ children,
43
+ }: {
44
+ unread?: boolean;
45
+ className?: string;
46
+ children: React.ReactNode;
47
+ }) {
48
+ return (
49
+ <div className={["relative rounded-lg border border-border bg-surface backdrop-blur-sm shadow-sm overflow-hidden", className].join(" ")}>
50
+ <UnreadBar unread={unread} />
51
+ {children}
52
+ </div>
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Headings carry the display typeface globally (`app.css`, unlayered - so no utility class can beat it); a sender line that happens to be a
58
+ * heading is body text and keeps the body's, by inline style.
59
+ */
60
+ export const BODY_FONT_STYLE: React.CSSProperties = { fontFamily: "var(--rr-font-family)" };
61
+
62
+ /** A sender's avatar in the card header. There are no photos in the data, so this is the initials disc every contact gets. */
63
+ export function SenderAvatar({ from }: { from: MailAddressLike }) {
64
+ return <ContactAvatar displayName={from.displayName || from.name || from.address} size={40} />;
65
+ }
66
+
67
+ /**
68
+ * A collapsed message: its card with the sender, the date and the first line of the body, all of it the one button that expands it.
69
+ * Shares the header's look with an expanded card, so expanding does not change how the message begins.
70
+ */
71
+ export function CollapsedCard({
72
+ from,
73
+ date,
74
+ preview,
75
+ unread,
76
+ buttonProps,
77
+ buttonRef,
78
+ senderClassName,
79
+ dateClassName,
80
+ }: {
81
+ from: MailAddressLike;
82
+ date: string;
83
+ preview: React.ReactNode;
84
+ unread: boolean;
85
+ buttonProps: React.ButtonHTMLAttributes<HTMLButtonElement>;
86
+ buttonRef: (node: HTMLButtonElement | null) => void;
87
+ senderClassName: string;
88
+ dateClassName: string;
89
+ }) {
90
+ return (
91
+ <CardShell unread={unread}>
92
+ <h2 style={BODY_FONT_STYLE}>
93
+ <button
94
+ type="button"
95
+ ref={buttonRef}
96
+ {...buttonProps}
97
+ className={[
98
+ "w-full text-left flex items-center gap-3 px-4 py-3",
99
+ unread ? "bg-primary/[0.07] hover:bg-primary/10" : "hover:bg-surface-alt",
100
+ "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary",
101
+ ].join(" ")}
102
+ >
103
+ <UnreadLabel unread={unread} />
104
+ <SenderAvatar from={from} />
105
+ <span className="flex-1 min-w-0">
106
+ {/* On a phone the date drops under the sender rather than squeezing the name to nothing. */}
107
+ <span className="flex flex-wrap items-baseline justify-between gap-x-2 text-sm">
108
+ <MailAddress recipient={from} className={`${senderClassName} min-w-[13rem]! flex-1`} />
109
+ <span className={["text-xs shrink-0", dateClassName].join(" ")}>{date}</span>
110
+ </span>
111
+ <span className="block text-xs text-text-muted truncate font-normal">{preview}</span>
112
+ </span>
113
+ </button>
114
+ </h2>
115
+ </CardShell>
116
+ );
117
+ }
118
+
119
+ /** The card's place-holder while its message is still loading: an avatar, two header lines and a few lines of body. */
120
+ export function CardSkeleton() {
121
+ return (
122
+ <div aria-hidden="true" className="rounded-lg border border-border bg-surface shadow-sm overflow-hidden">
123
+ <div className="flex items-center gap-3 px-4 py-3">
124
+ <Skeleton width="w-10" height="h-10" className="rounded-full shrink-0" />
125
+ <div className="flex-1 flex flex-col gap-2">
126
+ <Skeleton width="w-1/3" />
127
+ <Skeleton width="w-1/2" height="h-3" />
128
+ </div>
129
+ <Skeleton width="w-16" height="h-3" />
130
+ </div>
131
+ <div className="flex flex-col gap-2.5 px-4 pb-4">
132
+ <Skeleton width="w-11/12" />
133
+ <Skeleton width="w-4/5" />
134
+ <Skeleton width="w-9/12" />
135
+ </div>
136
+ </div>
137
+ );
138
+ }
139
+
140
+ /**
141
+ * What the pane shows while it is getting ready - its code loading, a conversation's messages arriving: the subject card as soon as the
142
+ * subject is known (`subject` is `undefined` when it is not, as on the mobile page before its message arrived) and a skeleton card per
143
+ * message, so the frame of the pane is on screen at once and the messages only fill in. One live region for the whole thing.
144
+ */
145
+ export function ReadingPaneSkeleton({ subject, messageCount = 1 }: { subject?: string; messageCount?: number }) {
146
+ return (
147
+ <div className="flex-1 min-w-0 min-h-0 flex flex-col">
148
+ {subject !== undefined ? (
149
+ <SubjectCard subject={subject || "(no subject)"} meta={messageCount > 1 ? `${messageCount} messages` : undefined} />
150
+ ) : (
151
+ <div aria-hidden="true" className="shrink-0 mx-2 mt-2 sm:mx-3 sm:mt-3 rounded-lg border border-border bg-surface shadow-sm px-4 py-3">
152
+ <Skeleton width="w-2/3" height="h-6" />
153
+ </div>
154
+ )}
155
+ <SkeletonCards messageCount={messageCount} />
156
+ </div>
157
+ );
158
+ }
159
+
160
+ /**
161
+ * The skeleton cards themselves - up to three, one live region ("Loading the message") for all of them - in the place the message cards take,
162
+ * so a pane that already shows its subject card (a thread whose messages are still coming) swaps them for the real ones without the header
163
+ * card being drawn again.
164
+ */
165
+ export function SkeletonCards({ messageCount = 1 }: { messageCount?: number }) {
166
+ return (
167
+ <div role="status" aria-busy="true" className="flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3">
168
+ <span className="sr-only">Loading the message</span>
169
+ {Array.from({ length: Math.min(3, Math.max(1, messageCount)) }, (_, index) => (
170
+ <CardSkeleton key={index} />
171
+ ))}
172
+ </div>
173
+ );
174
+ }
@@ -0,0 +1,100 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { ApiRequestError, apiUrl } from "@rapidmx/react-shared/util/api.js";
6
+ import { Attachment, attachmentContentUrl } from "@rapidmx/react-shared/mail/mailApi.js";
7
+ import type { MimeAttachment } from "@rapidmx/react-shared/crypto/mime.js";
8
+
9
+ /** What a message's body is: server-sanitized HTML, or plain text (a message with no HTML part, or the server's plain-text fallback). */
10
+ export type BodyContent = { kind: "html"; html: string } | { kind: "text"; text: string };
11
+
12
+ /** How many bodies are remembered for the session - a thread that is expanded, collapsed and expanded again does not ask the server twice. */
13
+ const CACHE_LIMIT = 40;
14
+ /** What the pane asks `/content` for. */
15
+ export const ACCEPT = "text/html, text/plain;q=0.8";
16
+ const cache = new Map<string, BodyContent>();
17
+
18
+ /** Forgets every remembered body (sign-out, and the tests). */
19
+ export function clearBodyContentCache(): void {
20
+ cache.clear();
21
+ }
22
+
23
+ /** The remembered body for a message at a version, if any. */
24
+ export function cachedBodyContent(uid: string, version: number): BodyContent | undefined {
25
+ return cache.get(`${uid}:${version}`);
26
+ }
27
+
28
+ /**
29
+ * The message's body from the server's `GET /mail/messages/:id/content`: the HTML the server sanitized when it ingested the message
30
+ * (`sanitizedHtmlBlobKey`), or - for a message with none - the plain-text preview. A request that fails throws an `ApiRequestError`.
31
+ * Successful answers are remembered per uid and version.
32
+ */
33
+ export async function fetchBodyContent(uid: string, version: number, signal?: AbortSignal): Promise<BodyContent> {
34
+ const cached = cachedBodyContent(uid, version);
35
+ if (cached) {
36
+ return cached;
37
+ }
38
+ // Says what it can show, as a document request would: the server answers with the sanitized HTML, or its plain-text fallback.
39
+ const res = await fetch(apiUrl(`/mail/messages/${encodeURIComponent(uid)}/content`), {
40
+ credentials: "include",
41
+ headers: { Accept: ACCEPT },
42
+ signal,
43
+ });
44
+ if (!res.ok) {
45
+ const contentType = res.headers.get("content-type") ?? "";
46
+ const body = contentType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
47
+ throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Could not load this message.", res.status, body?.code, body);
48
+ }
49
+ const text = await res.text();
50
+ const content: BodyContent = (res.headers.get("content-type") ?? "").toLowerCase().includes("text/html")
51
+ ? { kind: "html", html: text }
52
+ : { kind: "text", text };
53
+ if (cache.size >= CACHE_LIMIT) {
54
+ cache.delete(cache.keys().next().value as string);
55
+ }
56
+ cache.set(`${uid}:${version}`, content);
57
+ return content;
58
+ }
59
+
60
+ /** The largest inline image embedded into a body from a signed or encrypted message. */
61
+ export const MAX_INLINE_IMAGE_BYTES = 5_000_000;
62
+ const INLINE_IMAGE_TYPES = /^image\/(?:png|jpe?g|gif|webp|avif|bmp|svg\+xml)$/;
63
+
64
+ function base64(bytes: Uint8Array): string {
65
+ let binary = "";
66
+ for (let i = 0; i < bytes.length; i += 0x8000) {
67
+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
68
+ }
69
+ return btoa(binary);
70
+ }
71
+
72
+ /** A server attachment record as it may really arrive: the model carries the part's `Content-ID`, though the client type doesn't list it. */
73
+ export type InlineCapable = Attachment & { contentId?: string };
74
+
75
+ /** The absolute URL of an attachment - the frame is a document of its own, where a relative URL would not mean what it does here. */
76
+ export function absoluteAttachmentUrl(uid: string): string {
77
+ return new URL(attachmentContentUrl(uid), window.location.href).href;
78
+ }
79
+
80
+ /** The CSP source that allows exactly this server's attachment URLs and nothing else. */
81
+ export function attachmentSource(): string {
82
+ return new URL(apiUrl("/mail/attachments/"), window.location.href).href;
83
+ }
84
+
85
+ /**
86
+ * How an inline `cid:` image is resolved to something the frame may load: for a message shown from the server, the attachment whose
87
+ * `Content-ID` matches (its URL on this server); for a decrypted or verified one, the part inside the signed/encrypted entity, embedded
88
+ * as a `data:` URI (raster and SVG images up to `MAX_INLINE_IMAGE_BYTES`). Anything else has no source, so the image is not shown.
89
+ */
90
+ export function makeCidResolver(attachments: InlineCapable[] | undefined, parts: MimeAttachment[] | undefined): (contentId: string) => string | undefined {
91
+ return (contentId) => {
92
+ if (parts) {
93
+ const part = parts.find((candidate) => candidate.contentId === contentId);
94
+ const bytes = part && INLINE_IMAGE_TYPES.test(part.contentType) ? part.decode() : undefined;
95
+ return part && bytes && bytes.length <= MAX_INLINE_IMAGE_BYTES ? `data:${part.contentType};base64,${base64(bytes)}` : undefined;
96
+ }
97
+ const attachment = attachments?.find((candidate) => candidate.contentId === contentId);
98
+ return attachment ? absoluteAttachmentUrl(attachment.uid) : undefined;
99
+ };
100
+ }
@@ -0,0 +1,298 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { sanitizeMessageBodyHtml } from "@rapidmx/react-shared/mail/messageBodySanitizer.js";
6
+ import { parseColour } from "./color.js";
7
+
8
+ /**
9
+ * Turning a message's HTML - which is hostile until proven otherwise - into the fragment that goes into its display frame.
10
+ *
11
+ * Four independent layers stand between a mail and script execution, and this file is two of them:
12
+ *
13
+ * 1. the server's own allow-list sanitizer (restapi's `ScanPipeline`), before the body is stored;
14
+ * 2. **this file**: DOMPurify (react-shared's `sanitizeMessageBodyHtml()` - scripts, `on*` handlers, `javascript:` URLs, and every
15
+ * remote resource reference removed) followed by a second, structural pass of our own that removes what DOMPurify's defaults
16
+ * allow but a mail body never needs (forms, frames, media, SVG's active elements, every non-`http(s)`/`mailto`/`tel` link);
17
+ * 3. the frame's Content-Security-Policy (`frameDocument.ts`): no script, no object, no frame, no form, nothing fetched;
18
+ * 4. the frame's `sandbox` attribute, which never has `allow-scripts`, so nothing executes even if all of the above failed.
19
+ *
20
+ * Nothing here parses into the app's own document: every parse is a `DOMParser` document, which has no browsing context, runs no
21
+ * script and fetches nothing.
22
+ */
23
+
24
+ /** A body longer than this many characters is not sanitized or shown inline - DOMPurify on megabytes of markup would freeze the tab. */
25
+ export const MAX_BODY_HTML_LENGTH = 1_500_000;
26
+ /** A body with more elements than this is not shown inline either: it cannot be laid out and adapted in a reasonable time. */
27
+ export const MAX_BODY_ELEMENTS = 20_000;
28
+
29
+ export interface PreparedBody {
30
+ /** `too_large` means `html` is empty and the caller should offer the message on its own page instead. */
31
+ status: "ok" | "too_large";
32
+ /** The sanitized fragment: one wrapper `div.rr-msg` holding the message's own `<style>` elements and body. */
33
+ html: string;
34
+ /** The mail carries its own dark styles: a `color-scheme` meta or property, or a `prefers-color-scheme: dark` block. */
35
+ declaresDarkSupport: boolean;
36
+ /** How many `<img>`s point at an inline part (`cid:`) - the frame's CSP only needs an image source for the attachments then. */
37
+ inlineImages: number;
38
+ }
39
+
40
+ export interface PrepareOptions {
41
+ /** The URL an inline image's `cid:` reference stands for, or `undefined` when there is no such part. Only `data:` URIs of an
42
+ * image type and `http(s)` URLs are accepted; anything else is treated as unresolved. */
43
+ resolveCid?: (contentId: string) => string | undefined;
44
+ }
45
+
46
+ /** What a stylesheet or a `style` attribute may not contain, whatever the sanitizers before it did. */
47
+ const STYLE_HAZARDS: [RegExp, string][] = [
48
+ // Fixed and sticky positioning could cover the frame (or, with a viewport-sized box, all of it) with a fake dialog.
49
+ [/position\s*:\s*(?:fixed|sticky)/gi, "position:relative"],
50
+ // A length in viewport-height units is the frame's own height, which is the message's height: `min-height: 100vh` would make the frame grow
51
+ // by what its content adds on every measurement. As plain pixels, nothing in the message depends on the size of the frame it is in.
52
+ [/(\d(?:\.\d+)?)\s*(?:[sld]?v(?:h|min|max|b))\b/gi, "$1px"],
53
+ [/expression\s*\(/gi, "none("],
54
+ [/-moz-binding\s*:/gi, "-x-binding:"],
55
+ [/behavior\s*:/gi, "x-behavior:"],
56
+ [/@import[^;]*;?/gi, ""],
57
+ ];
58
+
59
+ /** CSS with the constructs above neutralised. */
60
+ export function sanitizeCss(css: string): string {
61
+ return STYLE_HAZARDS.reduce((text, [pattern, replacement]) => text.replace(pattern, replacement), css);
62
+ }
63
+
64
+ /** Elements removed together with everything inside them. */
65
+ const REMOVED_ELEMENTS = new Set([
66
+ "script",
67
+ "noscript",
68
+ "object",
69
+ "embed",
70
+ "applet",
71
+ "iframe",
72
+ "frame",
73
+ "frameset",
74
+ "noembed",
75
+ "noframes",
76
+ "audio",
77
+ "video",
78
+ "source",
79
+ "track",
80
+ "canvas",
81
+ "dialog",
82
+ "template",
83
+ "slot",
84
+ "portal",
85
+ "bgsound",
86
+ "xml",
87
+ "base",
88
+ "meta",
89
+ "link",
90
+ "title",
91
+ "head",
92
+ "input",
93
+ "select",
94
+ "textarea",
95
+ "datalist",
96
+ "option",
97
+ "optgroup",
98
+ "math",
99
+ // SVG's active or reference-following elements: a script, a foreign document, a use of another element, and animation
100
+ // (which can set an `href` or an event handler's target).
101
+ "foreignobject",
102
+ "use",
103
+ "set",
104
+ "animate",
105
+ "animatemotion",
106
+ "animatetransform",
107
+ ]);
108
+
109
+ /** Elements dropped but not their content: a message wrapped in an old ASP.NET `<form>` is still a message. */
110
+ const UNWRAPPED_ELEMENTS = new Set(["form", "fieldset", "legend", "label", "button", "output", "marquee", "blink"]);
111
+
112
+ /** Attributes no element of a mail may keep, on top of every `on*` handler. */
113
+ const REMOVED_ATTRIBUTES = new Set([
114
+ "name",
115
+ "srcdoc",
116
+ "formaction",
117
+ "form",
118
+ "ping",
119
+ "nonce",
120
+ "is",
121
+ "slot",
122
+ "contenteditable",
123
+ "accesskey",
124
+ "tabindex",
125
+ "autofocus",
126
+ "draggable",
127
+ "srcset",
128
+ "crossorigin",
129
+ "integrity",
130
+ "referrerpolicy",
131
+ "http-equiv",
132
+ "xlink:href",
133
+ ]);
134
+
135
+ /** The `href`s of a link a reader can follow: nothing that runs, nothing relative to this app, nothing that reads a local file. */
136
+ const LINK_URL = /^(?:https?:\/\/|mailto:|tel:)/i;
137
+ /** Inline images: raster types and SVG (an image context cannot run script or load anything), embedded as data. */
138
+ const IMAGE_DATA_URI = /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp|svg\+xml)[;,]/i;
139
+ const CSS_ESCAPE = /\\([0-9a-f]{1,6})\s?|\\(.)/gi;
140
+
141
+ /** Decodes CSS escapes, so a check for `color-scheme` cannot be dodged with `col\6fr-scheme`. */
142
+ function unescapeCss(css: string): string {
143
+ return css.replace(CSS_ESCAPE, (_match, hex: string | undefined, other: string | undefined) =>
144
+ hex ? String.fromCodePoint(Math.min(parseInt(hex, 16), 0x10ffff)) : (other as string),
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Whether a mail's own markup declares that it supports a dark scheme: `<meta name="color-scheme">` naming dark,
150
+ * a `color-scheme` property naming dark in a stylesheet or a `style` attribute, or an `@media (prefers-color-scheme: dark)` block. Such a mail
151
+ * brings its own dark styles, which the display honours instead of adapting the mail's colours.
152
+ */
153
+ export function declaresDarkSupport(doc: Document): boolean {
154
+ const meta = doc.querySelector('meta[name="color-scheme" i]');
155
+ if (/\bdark\b/i.test(meta?.getAttribute("content") ?? "")) {
156
+ return true;
157
+ }
158
+ // An element's text is never null, and a `[style]` element always has the attribute.
159
+ const sheets = Array.from(doc.querySelectorAll("style"), (style) => style.textContent);
160
+ const inline = Array.from(doc.querySelectorAll("[style]"), (element) => element.getAttribute("style")!);
161
+ return [...sheets, ...inline].some((css) => {
162
+ const text = unescapeCss(css);
163
+ return /color-scheme\s*:[^;}]*\bdark\b/i.test(text) || /prefers-color-scheme\s*:\s*dark/i.test(text);
164
+ });
165
+ }
166
+
167
+ /** A `bgcolor`/`text`/`link` attribute's value as an `rgb()` colour if we can read it - nothing else is ever copied into a style.
168
+ * `bgcolor="ffffff"` (no hash) is what old templates write and what a browser accepts. */
169
+ function attributeColour(value: string | null): string | undefined {
170
+ const text = (value ?? "").trim();
171
+ const colour = parseColour(/^[0-9a-f]{6}$/i.test(text) ? `#${text}` : text);
172
+ return colour && colour.a > 0 ? `rgb(${Math.round(colour.r)}, ${Math.round(colour.g)}, ${Math.round(colour.b)})` : undefined;
173
+ }
174
+
175
+ /** The wrapper `div.rr-msg`: the message's head `<style>`s first (DOMPurify drops a leading one as head content), then its body, with
176
+ * what a `<body bgcolor text link>` said carried over as the styles those attributes stand for. */
177
+ function wrapMessage(doc: Document): string {
178
+ const wrapper = doc.createElement("div");
179
+ wrapper.className = "rr-msg";
180
+ for (const style of Array.from(doc.head.querySelectorAll("style"))) {
181
+ wrapper.appendChild(style.cloneNode(true));
182
+ }
183
+ const [background, text, link] = ["bgcolor", "text", "link"].map((name) => attributeColour(doc.body.getAttribute(name)));
184
+ // What the `<body>` said about itself moves to the wrapper, which stands in for it: its presentational colours first (a `style` beats
185
+ // them), then its own `style`, its classes (a template's stylesheet may select on them) and its direction.
186
+ const declarations = [background && `background-color:${background}`, text && `color:${text}`, doc.body.getAttribute("style")].filter(Boolean);
187
+ if (declarations.length > 0) {
188
+ wrapper.setAttribute("style", declarations.join(";"));
189
+ }
190
+ const bodyClass = doc.body.getAttribute("class");
191
+ if (bodyClass) {
192
+ wrapper.className += ` ${bodyClass}`;
193
+ }
194
+ const direction = doc.body.getAttribute("dir");
195
+ if (direction) {
196
+ wrapper.setAttribute("dir", direction);
197
+ }
198
+ if (link) {
199
+ const rule = doc.createElement("style");
200
+ rule.textContent = `:where(.rr-msg a:link){color:${link}}`;
201
+ wrapper.appendChild(rule);
202
+ }
203
+ wrapper.append(...Array.from(doc.body.childNodes));
204
+ return wrapper.outerHTML;
205
+ }
206
+
207
+ /** Replaces `element` with its children. */
208
+ function unwrap(element: Element): void {
209
+ element.replaceWith(...Array.from(element.childNodes));
210
+ }
211
+
212
+ function hardenLink(element: Element): void {
213
+ const href = element.getAttribute("href")?.trim() ?? "";
214
+ if (LINK_URL.test(href)) {
215
+ element.setAttribute("href", href);
216
+ element.setAttribute("target", "_blank");
217
+ element.setAttribute("rel", "noopener noreferrer nofollow");
218
+ } else {
219
+ // A link that goes nowhere a reader could safely follow stays as text, without the address it hid.
220
+ element.removeAttribute("href");
221
+ element.removeAttribute("target");
222
+ }
223
+ }
224
+
225
+ function hardenImage(element: Element, options: PrepareOptions): void {
226
+ const src = (element.getAttribute("src") ?? "").trim();
227
+ const inline = /^cid:/i.test(src);
228
+ const target = inline ? options.resolveCid?.(src.slice(4).replace(/^<|>$/g, "")) : src;
229
+ // Remote images are never fetched (they would tell the sender the message was read); an inline image resolves to an embedded
230
+ // image or to this server's own attachment URL, and one that doesn't resolve has nothing to show.
231
+ if (target !== undefined && (IMAGE_DATA_URI.test(target) || (inline && /^https?:\/\//i.test(target)))) {
232
+ element.setAttribute("src", target);
233
+ } else {
234
+ element.removeAttribute("src");
235
+ }
236
+ }
237
+
238
+ /**
239
+ * The second, structural pass over already-sanitized markup: everything a mail body has no use for is removed or unwrapped, every
240
+ * link is forced to open in a new tab without an opener, and hazards in stylesheets are neutralised. Runs on a detached document.
241
+ */
242
+ function harden(body: HTMLElement, options: PrepareOptions): { elements: number; inlineImages: number } {
243
+ let inlineImages = 0;
244
+ const all = Array.from(body.querySelectorAll("*"));
245
+ for (const element of all) {
246
+ const tag = element.localName.toLowerCase();
247
+ if (REMOVED_ELEMENTS.has(tag)) {
248
+ element.remove();
249
+ continue;
250
+ }
251
+ if (UNWRAPPED_ELEMENTS.has(tag)) {
252
+ unwrap(element);
253
+ continue;
254
+ }
255
+ for (const attribute of Array.from(element.attributes)) {
256
+ const name = attribute.name.toLowerCase();
257
+ if (name.startsWith("on") || REMOVED_ATTRIBUTES.has(name)) {
258
+ element.removeAttribute(attribute.name);
259
+ } else if (name === "style") {
260
+ element.setAttribute("style", sanitizeCss(attribute.value));
261
+ }
262
+ }
263
+ if (tag === "style") {
264
+ element.textContent = sanitizeCss(element.textContent);
265
+ } else if (tag === "a" || tag === "area") {
266
+ hardenLink(element);
267
+ } else if (tag === "img") {
268
+ if (/^cid:/i.test(element.getAttribute("src") ?? "")) {
269
+ inlineImages++;
270
+ }
271
+ hardenImage(element, options);
272
+ }
273
+ }
274
+ return { elements: all.length, inlineImages };
275
+ }
276
+
277
+ /**
278
+ * The display fragment for a message's HTML - see the file's header. `too_large` (and an empty `html`) for a body over
279
+ * `MAX_BODY_HTML_LENGTH` characters or `MAX_BODY_ELEMENTS` elements; an empty fragment where there is no DOM to parse with.
280
+ */
281
+ export function prepareBodyHtml(raw: string, options: PrepareOptions = {}): PreparedBody {
282
+ const empty = { html: "", declaresDarkSupport: false, inlineImages: 0 };
283
+ if (raw.length > MAX_BODY_HTML_LENGTH) {
284
+ return { status: "too_large", ...empty };
285
+ }
286
+ if (typeof DOMParser === "undefined") {
287
+ return { status: "ok", ...empty };
288
+ }
289
+ const parsed = new DOMParser().parseFromString(raw, "text/html");
290
+ const darkSupport = declaresDarkSupport(parsed);
291
+ const sanitized = sanitizeMessageBodyHtml(wrapMessage(parsed));
292
+ const cleaned = new DOMParser().parseFromString(`<!doctype html><body>${sanitized}`, "text/html").body;
293
+ const { elements, inlineImages } = harden(cleaned, options);
294
+ if (elements > MAX_BODY_ELEMENTS) {
295
+ return { status: "too_large", ...empty };
296
+ }
297
+ return { status: "ok", html: cleaned.innerHTML, declaresDarkSupport: darkSupport, inlineImages };
298
+ }