@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,86 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { RecipientEncryptionStatus, decideMessageEncryption } from "@rapidmx/react-shared/crypto/composeSecurity.js";
6
+ import type { EncryptionPolicy } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
7
+
8
+ /**
9
+ * Whether a message must be treated as encrypted - the one place that decides it, as a pure function of what is *known*.
10
+ *
11
+ * **The rule is fail open.** A message is plain, its draft is saved, and nothing about encryption is ever shown, unless encryption is
12
+ * actually in play. Encryption is in play only when
13
+ *
14
+ * 1. the user asked for it ("Encrypt this message", or a reply to encrypted mail, whose quote may carry decrypted text) - that is local
15
+ * knowledge and needs no request to have succeeded; or
16
+ * 2. the system's encryption policy **was loaded successfully**, this sender has an encryption key (enrolled on their mailbox, or unlocked
17
+ * in this tab), the message has recipients, **every** recipient's key lookup succeeded, and the policy auto-applies encryption to all
18
+ * of them (`decideMessageEncryption().autoEncrypt`: policy `automatic` for their tier, both sides preferring "mutual", a key found).
19
+ *
20
+ * Anything that failed to load, is still loading or is slow (the policy, the mailbox, a recipient's lookup) is **unknown**, and unknown
21
+ * counts as "not encrypted": that is what stops a gateway that flaps, a slow request, or a user who has never set encryption up from
22
+ * ever blocking a draft, blocking Close, blocking Send or showing an "encryption can't be checked" message. What was unknown is
23
+ * reported in `unknown`, so a caller that is about to send can give the loads a moment - and only a caller that finds out later that the
24
+ * message *does* need encryption acts on it (see `ComposeWindow`'s transition rules).
25
+ *
26
+ * Why this is safe enough: the requirement only ever protects *this client's* plaintext drafts and sends from being unencrypted when the
27
+ * server's policy says they should be; it is not the enforcement point (the keys are the user's, the server never sees them). A missing
28
+ * policy therefore does not mean "encrypt or refuse" - it means the client cannot tell, and the honest default is the one that works.
29
+ */
30
+ export interface EncryptionFacts {
31
+ /** The user turned encryption on, or this compose is a reply to / forward of an encrypted message. */
32
+ encryptRequested: boolean;
33
+ /** The policy, once it has loaded; `undefined` while loading, failed or slow. */
34
+ policy: EncryptionPolicy | undefined;
35
+ /** The sender's mailbox has loaded (its enrolled keys are known). */
36
+ mailboxLoaded: boolean;
37
+ /** The sender has an encryption key to encrypt with: enrolled on the mailbox, unlocked in this tab, or offered earlier this session. */
38
+ hasEncryptionKey: boolean;
39
+ /** One entry per current recipient (To, Cc and Bcc): the resolved status, or `undefined` while that recipient's lookup is pending or failed. */
40
+ recipients: (RecipientEncryptionStatus | undefined)[];
41
+ }
42
+
43
+ export type EncryptionReason = "requested" | "policy";
44
+
45
+ /** What was not known, and could still change the answer. */
46
+ export type UnknownFact = "mailbox" | "policy" | "recipients";
47
+
48
+ export interface EncryptionRequirement {
49
+ /** The message must be encrypted: it is never stored as a plaintext draft and never sent in the clear. */
50
+ required: boolean;
51
+ reason: EncryptionReason | null;
52
+ /** What was unknown (and so assumed "no encryption"). Empty when the answer cannot change with more loading. Never shown to the user. */
53
+ unknown: UnknownFact[];
54
+ }
55
+
56
+ /** Any tier of `policy` auto-encrypts - only then can what is still unknown about the recipients turn a message into an encrypted one. */
57
+ export function policyCanAutoEncrypt(policy: EncryptionPolicy): boolean {
58
+ return [policy.encryptSameOrg, policy.encryptFederated, policy.encryptExternal].includes("automatic");
59
+ }
60
+
61
+ export function evaluateEncryptionRequirement(facts: EncryptionFacts): EncryptionRequirement {
62
+ if (facts.encryptRequested) {
63
+ return { required: true, reason: "requested", unknown: [] };
64
+ }
65
+ const unknown: UnknownFact[] = [];
66
+ if (!facts.hasEncryptionKey) {
67
+ // Without a key nothing can be encrypted: a user who never set encryption up sees nothing at all. Whether the mailbox has one is
68
+ // only *unknown* while it has not loaded.
69
+ return { required: false, reason: null, unknown: facts.mailboxLoaded ? [] : ["mailbox"] };
70
+ }
71
+ if (!facts.policy) {
72
+ return { required: false, reason: null, unknown: ["policy"] };
73
+ }
74
+ if (!policyCanAutoEncrypt(facts.policy)) {
75
+ return { required: false, reason: null, unknown };
76
+ }
77
+ if (facts.recipients.length === 0) {
78
+ // Nobody to decide by yet: plain until a recipient is added.
79
+ return { required: false, reason: null, unknown };
80
+ }
81
+ const statuses = facts.recipients.filter((status): status is RecipientEncryptionStatus => !!status);
82
+ if (statuses.length < facts.recipients.length) {
83
+ return { required: false, reason: null, unknown: ["recipients"] };
84
+ }
85
+ return decideMessageEncryption(statuses).autoEncrypt ? { required: true, reason: "policy", unknown } : { required: false, reason: null, unknown };
86
+ }
@@ -15,7 +15,8 @@ import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
15
15
  import { prefetchComposeWindow, useCompose } from "../compose/ComposeContext.js";
16
16
  import LocalIndexLifecycle from "../../../search/LocalIndexLifecycle.js";
17
17
  import { LiveUpdates, NO_LIVE_UPDATES } from "../../../mail/useMailLiveUpdates.js";
18
- import { FOLDER_ORDER, MAILBOX_LIST_LIMIT, MailConnectionContext, useMailConnection } from "../../../mail/useMailConnection.js";
18
+ import { MAILBOX_LIST_LIMIT, MailConnectionContext, useMailConnection } from "../../../mail/useMailConnection.js";
19
+ import { folderRows } from "../../../mail/folderTree.js";
19
20
  import {
20
21
  CountTracker,
21
22
  FolderBadge,
@@ -26,7 +27,8 @@ import {
26
27
  inboxUnreadTotal,
27
28
  } from "../../../mail/folderCounts.js";
28
29
  import { useUnreadTitle } from "../../../mail/useUnreadTitle.js";
29
- import NewMailToasts from "../NewMailToasts.js";
30
+ import { pendingCountFor, usePendingSends } from "../../../mail/outbox/pendingSends.js";
31
+ import OutboxBadge from "../OutboxBadge.js";
30
32
  import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
31
33
  import { SHORTCUTS } from "../../../keyboard/keymap.js";
32
34
  import { useKeyEnvironment } from "../../../keyboard/ShortcutProvider.js";
@@ -79,6 +81,8 @@ export interface MailShellContextValue {
79
81
  * A no-op on the default context value, which is only ever read outside a real shell.
80
82
  */
81
83
  onFolderCreated: (folder: Folder) => void;
84
+ /** Says which folders the messages on screen live in: one the sidebar does not know is looked for (see `MailConnection.noteFolderUids`). A no-op on the default context value. */
85
+ noteFolderUids: (folderUids: Iterable<string>) => void;
82
86
  /**
83
87
  * Bumped whenever new mail (or another change to a message) may have arrived - a push event, a reconnect or the safety-net
84
88
  * poll - so the list on screen can quietly refetch its first page. See `useMailLiveUpdates()`. Never changes on the default
@@ -100,6 +104,7 @@ const MailShellContext = createContext<MailShellContextValue>({
100
104
  mailboxes: [],
101
105
  mailboxFolders: [],
102
106
  onFolderCreated: () => undefined,
107
+ noteFolderUids: () => undefined,
103
108
  live: NO_LIVE_UPDATES,
104
109
  trackMessageChange: () => NO_TRACKER,
105
110
  });
@@ -119,15 +124,6 @@ const FOLDER_LABELS: Record<string, string> = {
119
124
  deleted_items: "Deleted Items",
120
125
  };
121
126
 
122
- function folderSortKey(folder: Folder): number {
123
- const idx = FOLDER_ORDER.indexOf(folder.type);
124
- return idx === -1 ? FOLDER_ORDER.length : idx;
125
- }
126
-
127
- function sortedFoldersOf(folders: Folder[]): Folder[] {
128
- return [...folders].sort((a, b) => folderSortKey(a) - folderSortKey(b) || a.name.localeCompare(b.name));
129
- }
130
-
131
127
  /** The badge an "All Mailboxes" entry shows: the folders of that type, summed across every mailbox, under the same rules as a single folder's. */
132
128
  function aggregateBadge(mailboxFolders: MailboxFolders[], type: AggregateFolderType, counts: Record<string, FolderCount>): FolderBadge | undefined {
133
129
  const sum = mailboxFolders.reduce(
@@ -234,7 +230,9 @@ export default function MailShell({
234
230
  const navigate = useNavigate();
235
231
  const hosted = useContext(MailConnectionContext);
236
232
  const own = useMailConnection({ userUid, enabled: !hosted, open: navigate });
237
- const { status, error, mailboxes, mailboxFolders, foldersLoading, onFolderCreated, live, folderCounts, notifications } = hosted ?? own;
233
+ const { status, error, mailboxes, mailboxFolders, foldersLoading, onFolderCreated, noteFolderUids, live, folderCounts, outbox: outboxStatuses } = hosted ?? own;
234
+ // The messages this tab is still handing to the server: the Outbox pill counts them at once.
235
+ const pendingSends = usePendingSends();
238
236
  const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
239
237
  const [requestedFolderUid, setRequestedFolderUid] = useState<string | null>(null);
240
238
  const [requestedAggregateType, setRequestedAggregateType] = useState<string | null>(null);
@@ -285,8 +283,8 @@ export default function MailShell({
285
283
  useUnreadTitle(inboxUnreadTotal(mailboxFolders, counts), { enabled: !hosted });
286
284
 
287
285
  const contextValue = useMemo<MailShellContextValue>(
288
- () => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live, trackMessageChange }),
289
- [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, live, trackMessageChange],
286
+ () => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange }),
287
+ [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange],
290
288
  );
291
289
 
292
290
  // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else for a
@@ -369,7 +367,23 @@ export default function MailShell({
369
367
  </div>
370
368
  )}
371
369
  <div className="flex flex-col gap-0.5">
372
- {sortedFoldersOf(folders).map((folder) => {
370
+ {folderRows(folders, pendingCountFor(mailbox.uid, pendingSends)).map((row) => {
371
+ if (row.kind === "placeholder") {
372
+ // A folder the server has not made (or told us about) yet - the Outbox and Sent Items are made with the first message
373
+ // sent - shown the moment a message is on its way. Nothing to open; the real folder replaces it when it arrives.
374
+ const sending = pendingCountFor(mailbox.uid, pendingSends);
375
+ return (
376
+ <div
377
+ key={`placeholder-${row.type}`}
378
+ data-folder-placeholder={row.type}
379
+ className="flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5 text-text-muted"
380
+ >
381
+ <span>{FOLDER_LABELS[row.type]}</span>
382
+ {row.type === "outbox" && <OutboxBadge total={sending} pendingHere={sending} />}
383
+ </div>
384
+ );
385
+ }
386
+ const folder = row.folder;
373
387
  const badge = badgeFor(folder.type, countOfFolder(folder, counts));
374
388
  return (
375
389
  <a
@@ -385,7 +399,16 @@ export default function MailShell({
385
399
  <span className={badge?.kind === "unread" ? "font-semibold" : undefined}>
386
400
  {FOLDER_LABELS[folder.type] ?? folder.name}
387
401
  </span>
388
- {badge && <FolderBadgeChip badge={badge} />}
402
+ {badge &&
403
+ (folder.type === "outbox" ? (
404
+ <OutboxBadge
405
+ total={badge.value}
406
+ status={outboxStatuses[folder.uid]}
407
+ pendingHere={pendingCountFor(mailbox.uid, pendingSends)}
408
+ />
409
+ ) : (
410
+ <FolderBadgeChip badge={badge} />
411
+ ))}
389
412
  </a>
390
413
  );
391
414
  })}
@@ -415,15 +438,6 @@ export default function MailShell({
415
438
  </button>
416
439
  <MailShellContext.Provider value={contextValue}>{children}</MailShellContext.Provider>
417
440
  </main>
418
- {!hosted && (
419
- <NewMailToasts
420
- toasts={notifications.toasts}
421
- onDismiss={notifications.dismiss}
422
- offerDesktop={notifications.offerDesktop}
423
- onEnableDesktop={() => void notifications.enableDesktop()}
424
- onDeclineDesktop={notifications.declineDesktop}
425
- />
426
- )}
427
441
  </>
428
442
  );
429
443
  }
@@ -0,0 +1,65 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { HiOutlineExclamationTriangle, HiOutlineLockClosed } from "react-icons/hi2";
7
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
8
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
9
+ import { useResolvedTheme } from "../../../appearance/resolvedTheme.js";
10
+ import { formatColour } from "./color.js";
11
+ import { useThemeSurface } from "./themeSurface.js";
12
+
13
+ export interface EncryptedBodyProps {
14
+ /** This device has no unlocked key session for the message's mailbox: unlocking could help. */
15
+ locked: boolean;
16
+ /** Why the message couldn't be decrypted when the keys were available (shown for the failed state). */
17
+ reason?: string;
18
+ /** Opens the app's unlock prompt (`useUnlockPrompt().requestUnlock`). Only used - and only offered - when `locked`. */
19
+ onUnlock: () => void;
20
+ /** An unlock is being asked for: the button waits. */
21
+ unlocking?: boolean;
22
+ }
23
+
24
+ /**
25
+ * What an encrypted message's body is until (and unless) it can be read, drawn as card content in the app's own tokens: on the theme's opaque surface, so
26
+ * it stays legible over a translucent card and a background photo.
27
+ *
28
+ * **Locked** - the keys are not unlocked: a lock, "This message is encrypted", a hint and a primary Unlock button, which opens the same prompt compose's
29
+ * "Unlock to sign or encrypt" does. When it succeeds the pane decrypts and shows the message in place.
30
+ * **Cannot be decrypted** - the keys are unlocked and it still can't be opened (no key for this mailbox, not a recipient, damaged, unsupported): the reason,
31
+ * and no button - unlocking again wouldn't change it.
32
+ */
33
+ export default function EncryptedBody({ locked, reason, onUnlock, unlocking = false }: EncryptedBodyProps) {
34
+ const theme = useResolvedTheme();
35
+ const surface = useThemeSurface(theme);
36
+ const Icon = locked ? HiOutlineLockClosed : HiOutlineExclamationTriangle;
37
+ return (
38
+ <div
39
+ className="flex flex-col items-center gap-2 rounded-md border border-border px-4 py-8 text-center"
40
+ style={{ backgroundColor: formatColour(surface.background) }}
41
+ >
42
+ <span
43
+ aria-hidden="true"
44
+ className={["inline-flex h-12 w-12 items-center justify-center rounded-full", locked ? "bg-primary/10 text-primary-dark" : "bg-warning/15 text-text"].join(" ")}
45
+ >
46
+ <Icon size={24} />
47
+ </span>
48
+ <p className="text-base font-semibold text-text">{locked ? "This message is encrypted" : "This message can’t be decrypted"}</p>
49
+ {locked ? (
50
+ <>
51
+ <p className="text-sm text-text-muted">Unlock your keys to read it</p>
52
+ <Button type="button" className="!w-auto mt-1" aria-label="Unlock to view this message" loading={unlocking} disabled={unlocking} onClick={onUnlock}>
53
+ Unlock
54
+ </Button>
55
+ </>
56
+ ) : (
57
+ reason && (
58
+ <div className="mt-1 max-w-prose text-left">
59
+ <Alert>{reason}</Alert>
60
+ </div>
61
+ )
62
+ )}
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,35 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { HiOutlineLockClosed } from "react-icons/hi2";
7
+
8
+ /** The subject an encrypted message carries on the server (RFC 9788's placeholder): the real one is inside the ciphertext. */
9
+ export const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
10
+
11
+ /**
12
+ * What a list row's preview line says for an encrypted message nothing has been decrypted of yet: a small lock and "Encrypted message". The server never
13
+ * derives a preview for encrypted mail (it has no plaintext), so the line would otherwise be empty.
14
+ */
15
+ export function EncryptedPreview() {
16
+ return (
17
+ <span className="inline-flex items-center gap-1">
18
+ <HiOutlineLockClosed size={12} aria-hidden="true" className="shrink-0" />
19
+ Encrypted message
20
+ </span>
21
+ );
22
+ }
23
+
24
+ /**
25
+ * Whether a conversation row stands for an encrypted latest message. A conversation summary carries no flag for it, but an encrypted message has no preview
26
+ * and the placeholder subject - which a plaintext message with an empty body does not.
27
+ */
28
+ export function conversationLooksEncrypted(conversation: { subject: string; latestPreview: string }): boolean {
29
+ return conversation.latestPreview === "" && conversation.subject === ENCRYPTED_SUBJECT_PLACEHOLDER;
30
+ }
31
+
32
+ /** The subject to show for a message's own: what an encrypted message says is the placeholder, which nobody should have to read as a title. */
33
+ export function displaySubject(subject: string): string {
34
+ return subject === ENCRYPTED_SUBJECT_PLACEHOLDER ? "Encrypted message" : subject;
35
+ }
@@ -0,0 +1,240 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useMemo, useRef, useState } from "react";
6
+ import { ApiRequestError, apiUrl } from "@rapidmx/react-shared/util/api.js";
7
+ import type { Attachment } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import type { MimeAttachment } from "@rapidmx/react-shared/crypto/mime.js";
9
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
+ import Skeleton from "@rapidmx/react-shared/components/feedback/Skeleton.js";
12
+ import { useResolvedTheme } from "../../../appearance/resolvedTheme.js";
13
+ import { BodyContent, attachmentSource, cachedBodyContent, fetchBodyContent, makeCidResolver } from "./bodyContent.js";
14
+ import { prepareBodyHtml } from "./bodyHtml.js";
15
+ import { formatColour } from "./color.js";
16
+ import { controlFrame } from "./frameControl.js";
17
+ import { FRAME_SANDBOX, FrameMode, buildFrameDocument } from "./frameDocument.js";
18
+ import { surfaceKey, useThemeSurface } from "./themeSurface.js";
19
+
20
+ export interface MessageBodyProps {
21
+ messageUid: string;
22
+ /** Part of what a remembered body is keyed on, so an edited message is fetched again. */
23
+ messageVersion: number;
24
+ /** The accessible name of the body. */
25
+ title: string;
26
+ /** A body the caller already has - decrypted or verified content. Shown instead of asking the server. */
27
+ content?: BodyContent;
28
+ /** The message's server-side attachments, for resolving its inline `cid:` images. */
29
+ attachments?: Attachment[];
30
+ /** The parts inside a signed or encrypted message, for resolving its inline images from the decrypted content. */
31
+ inlineParts?: MimeAttachment[];
32
+ /** Show the message as its author wrote it: nothing adapted to the theme. */
33
+ original?: boolean;
34
+ /** Says whether "view original" would change anything, once that is known: never for plain text, a message with its own dark styles, or a
35
+ * message the theme leaves as it is. */
36
+ onAdaptable?: (adaptable: boolean) => void;
37
+ }
38
+
39
+ type Remote = { status: "loading" } | { status: "ready"; content: BodyContent } | { status: "error"; message: string };
40
+
41
+ /** Placeholder lines shown where the body will be, in the body's own place, until it has arrived and been laid out. */
42
+ export function BodySkeleton() {
43
+ return (
44
+ <div role="status" aria-busy="true" className="flex flex-col gap-2.5 py-1">
45
+ <span className="sr-only">Loading the message</span>
46
+ <Skeleton width="w-11/12" />
47
+ <Skeleton width="w-4/5" />
48
+ <Skeleton width="w-9/12" />
49
+ <Skeleton width="w-1/2" />
50
+ </div>
51
+ );
52
+ }
53
+
54
+ /** A short, stable fingerprint of a document's text (djb2), to tell one loaded document from the next. */
55
+ function fingerprint(text: string): string {
56
+ let hash = 5381;
57
+ for (let i = 0; i < text.length; i++) {
58
+ hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
59
+ }
60
+ return (hash >>> 0).toString(36);
61
+ }
62
+
63
+ /** The background of a message shown as authored: white, the canvas an HTML message is written for. */
64
+ const AUTHORED_CANVAS = "rgb(255, 255, 255)";
65
+
66
+ /**
67
+ * The body of one message, in the card's own place: the sanitized HTML in a frame that is exactly as tall as its content (following it as
68
+ * images load and the width changes), or plain text. See `frameDocument.ts` for the isolation and `themeAdaptation.ts` for how a message
69
+ * takes the app's theme.
70
+ *
71
+ * Nothing waits on the network to lay out: the skeleton is here at once, and the frame's height is only ever the height of what is in it.
72
+ */
73
+ export default function MessageBody({ messageUid, messageVersion, title, content, attachments, inlineParts, original = false, onAdaptable }: MessageBodyProps) {
74
+ const [remote, setRemote] = useState<Remote>(() => {
75
+ const cached = cachedBodyContent(messageUid, messageVersion);
76
+ return cached ? { status: "ready", content: cached } : { status: "loading" };
77
+ });
78
+ const [attempt, setAttempt] = useState(0);
79
+ const supplied = content !== undefined;
80
+
81
+ useEffect(() => {
82
+ if (supplied) {
83
+ return;
84
+ }
85
+ const cached = cachedBodyContent(messageUid, messageVersion);
86
+ if (cached) {
87
+ setRemote({ status: "ready", content: cached });
88
+ return;
89
+ }
90
+ const controller = new AbortController();
91
+ setRemote({ status: "loading" });
92
+ fetchBodyContent(messageUid, messageVersion, controller.signal).then(
93
+ (loaded) => setRemote({ status: "ready", content: loaded }),
94
+ (err) => {
95
+ if (!controller.signal.aborted) {
96
+ setRemote({ status: "error", message: err instanceof ApiRequestError ? err.message : "Could not load this message." });
97
+ }
98
+ },
99
+ );
100
+ return () => controller.abort();
101
+ }, [messageUid, messageVersion, supplied, attempt]);
102
+
103
+ const shown: Remote = content ? { status: "ready", content } : remote;
104
+ if (shown.status === "loading") {
105
+ return <BodySkeleton />;
106
+ }
107
+ if (shown.status === "error") {
108
+ return (
109
+ <div className="flex flex-col items-start gap-2">
110
+ <Alert>{shown.message}</Alert>
111
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setAttempt((n) => n + 1)}>
112
+ Try again
113
+ </Button>
114
+ </div>
115
+ );
116
+ }
117
+ if (shown.content.kind === "text") {
118
+ // Rendered as text - React escapes it - never as markup, in the theme's own colours.
119
+ return <pre aria-label={title} className="m-0 text-sm font-sans whitespace-pre-wrap break-words text-text">{shown.content.text}</pre>;
120
+ }
121
+ return (
122
+ <HtmlBody
123
+ key={messageUid}
124
+ messageUid={messageUid}
125
+ title={title}
126
+ html={shown.content.html}
127
+ remoteAvailable={!supplied}
128
+ attachments={attachments}
129
+ inlineParts={inlineParts}
130
+ original={original}
131
+ onAdaptable={onAdaptable}
132
+ />
133
+ );
134
+ }
135
+
136
+ function HtmlBody({
137
+ messageUid,
138
+ title,
139
+ html,
140
+ remoteAvailable,
141
+ attachments,
142
+ inlineParts,
143
+ original,
144
+ onAdaptable,
145
+ }: {
146
+ messageUid: string;
147
+ title: string;
148
+ html: string;
149
+ /** The full message can be opened on its own from the server (it was not supplied by the caller). */
150
+ remoteAvailable: boolean;
151
+ attachments?: Attachment[];
152
+ inlineParts?: MimeAttachment[];
153
+ original: boolean;
154
+ onAdaptable?: (adaptable: boolean) => void;
155
+ }) {
156
+ const theme = useResolvedTheme();
157
+ const surface = useThemeSurface(theme);
158
+ // Keyed on what the resolver reads rather than on the arrays, which a caller may rebuild on every render.
159
+ const inlineKey = [...(attachments ?? []).map((a) => `${a.uid}:${(a as { contentId?: string }).contentId ?? ""}`), inlineParts ? `parts:${inlineParts.length}` : ""].join("|");
160
+ const resolveCid = useMemo(() => makeCidResolver(attachments, inlineParts), [inlineKey]);
161
+ const prepared = useMemo(() => prepareBodyHtml(html, { resolveCid }), [html, resolveCid]);
162
+ const mode: FrameMode = prepared.declaresDarkSupport ? "native" : original ? "original" : "adapt";
163
+ const surfaceCss = formatColour(surface.background);
164
+ const srcDoc = useMemo(() => {
165
+ // The frame may load images from this server's attachment URLs only when an inline image was actually resolved to one.
166
+ const source = prepared.inlineImages > 0 ? attachmentSource() : undefined;
167
+ return buildFrameDocument(prepared.html, {
168
+ mode,
169
+ scheme: theme,
170
+ surface: surfaceCss,
171
+ text: formatColour(surface.text),
172
+ attachmentSource: source && prepared.html.includes(source) ? source : undefined,
173
+ });
174
+ }, [prepared, mode, theme, surfaceCss, surface.text]);
175
+ // A different body, surface or mode is a different document: the adaptation pass edits the document it ran on, so it is run on a fresh
176
+ // one, and the old one's readiness is not the new one's.
177
+ const frameKey = useMemo(() => `${mode}:${surfaceKey(surface)}:${fingerprint(srcDoc)}`, [mode, surface, srcDoc]);
178
+
179
+ const [height, setHeight] = useState<number | undefined>(undefined);
180
+ const [readyFor, setReadyFor] = useState<string | null>(null);
181
+ const [canvasAuthored, setCanvasAuthored] = useState(false);
182
+ const dispose = useRef<(() => void) | undefined>(undefined);
183
+ useEffect(() => () => dispose.current?.(), []);
184
+ const reportAdaptable = useRef(onAdaptable);
185
+ reportAdaptable.current = onAdaptable;
186
+ useEffect(() => {
187
+ if (mode === "native") {
188
+ reportAdaptable.current?.(false);
189
+ }
190
+ }, [mode]);
191
+
192
+ if (prepared.status === "too_large") {
193
+ return (
194
+ <Alert>
195
+ This message is too large to show here.{" "}
196
+ {remoteAvailable && (
197
+ <a href={apiUrl(`/mail/messages/${encodeURIComponent(messageUid)}/content`)} target="_blank" rel="noopener noreferrer" className="font-medium underline">
198
+ Open it in its own tab
199
+ </a>
200
+ )}
201
+ </Alert>
202
+ );
203
+ }
204
+
205
+ const ready = readyFor === frameKey;
206
+ const canvas = mode === "original" ? AUTHORED_CANVAS : surfaceCss;
207
+ const framed = mode !== "adapt" || canvasAuthored;
208
+ return (
209
+ <div className="relative rounded-md overflow-hidden" style={{ backgroundColor: canvas }}>
210
+ {!ready && <BodySkeleton />}
211
+ <iframe
212
+ key={frameKey}
213
+ title={title}
214
+ srcDoc={srcDoc}
215
+ sandbox={FRAME_SANDBOX}
216
+ scrolling="no"
217
+ onLoad={(event) => {
218
+ dispose.current?.();
219
+ dispose.current = controlFrame(event.currentTarget, {
220
+ surface: mode === "adapt" ? surface : undefined,
221
+ onHeight: setHeight,
222
+ onReady: (adaptation) => {
223
+ setReadyFor(frameKey);
224
+ if (adaptation) {
225
+ setCanvasAuthored(adaptation.authoredCanvas);
226
+ reportAdaptable.current?.(adaptation.material);
227
+ }
228
+ },
229
+ });
230
+ }}
231
+ className={[
232
+ "block w-full border-0 rounded-md",
233
+ ready ? "" : "absolute inset-x-0 top-0 invisible",
234
+ framed && ready ? "ring-1 ring-border" : "",
235
+ ].join(" ")}
236
+ style={{ height: height ?? 0 }}
237
+ />
238
+ </div>
239
+ );
240
+ }