@rapidmx/web-client 0.9.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 (443) hide show
  1. package/README.md +373 -88
  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/auth/accountUrl.ts +9 -0
  21. package/apps/shared/auth/adminAccess.ts +99 -0
  22. package/apps/shared/components/admin/layout/AdminShell.tsx +22 -10
  23. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +107 -60
  24. package/apps/shared/components/admin/settings/BrandingForm.tsx +24 -0
  25. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +2 -1
  26. package/apps/shared/components/admin/settings/PluginsManager.tsx +228 -25
  27. package/apps/shared/components/admin/setup/SetupWizard.tsx +2 -1
  28. package/apps/shared/components/calendar/CalendarListSidebar.tsx +4 -5
  29. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  30. package/apps/shared/components/contacts/ContactForm.tsx +11 -5
  31. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -3
  32. package/apps/shared/components/contacts/ContactsToolbar.tsx +98 -115
  33. package/apps/shared/components/contacts/layout/ContactsShell.tsx +25 -8
  34. package/apps/shared/components/escrow/layout/EscrowShell.tsx +13 -6
  35. package/apps/shared/components/layout/AppShell.tsx +477 -288
  36. package/apps/shared/components/layout/BrandingChrome.tsx +200 -13
  37. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  38. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
  39. package/apps/shared/components/layout/RailIcon.tsx +100 -0
  40. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -0
  41. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -0
  42. package/apps/shared/components/layout/UserMenu.tsx +372 -185
  43. package/apps/shared/components/mail/ConversationList.tsx +271 -262
  44. package/apps/shared/components/mail/ConversationThreadPane.tsx +147 -94
  45. package/apps/shared/components/mail/LazyReadingPane.tsx +113 -0
  46. package/apps/shared/components/mail/MailSelectionBar.tsx +227 -222
  47. package/apps/shared/components/mail/MessageDetailPane.tsx +1537 -1387
  48. package/apps/shared/components/mail/OutboxBadge.tsx +44 -0
  49. package/apps/shared/components/mail/OutboxRowStatus.tsx +31 -0
  50. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -160
  51. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  52. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1644 -1736
  53. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  54. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  55. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  56. package/apps/shared/components/mail/compose/encryptionRequirement.ts +86 -0
  57. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  58. package/apps/shared/components/mail/layout/MailShell.tsx +490 -472
  59. package/apps/shared/components/mail/reading/EncryptedBody.tsx +65 -0
  60. package/apps/shared/components/mail/reading/EncryptedPreview.tsx +35 -0
  61. package/apps/shared/components/mail/reading/MessageBody.tsx +240 -0
  62. package/apps/shared/components/mail/reading/MessageCard.tsx +174 -0
  63. package/apps/shared/components/mail/reading/bodyContent.ts +100 -0
  64. package/apps/shared/components/mail/reading/bodyHtml.ts +298 -0
  65. package/apps/shared/components/mail/reading/color.ts +245 -0
  66. package/apps/shared/components/mail/reading/frameControl.ts +142 -0
  67. package/apps/shared/components/mail/reading/frameDocument.ts +123 -0
  68. package/apps/shared/components/mail/reading/safeDocument.ts +24 -0
  69. package/apps/shared/components/mail/reading/themeAdaptation.ts +221 -0
  70. package/apps/shared/components/mail/reading/themeSurface.ts +90 -0
  71. package/apps/shared/components/mail/reading/viewOriginal.ts +38 -0
  72. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  73. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -0
  74. package/apps/shared/components/settings/layout/SettingsShell.tsx +253 -240
  75. package/apps/shared/components/sharing/PrincipalPicker.tsx +145 -0
  76. package/apps/shared/components/tasks/TasksSidebar.tsx +5 -3
  77. package/apps/shared/components/tasks/layout/TasksShell.tsx +24 -8
  78. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  79. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  80. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  81. package/apps/shared/keyboard/dispatch.ts +124 -0
  82. package/apps/shared/keyboard/format.ts +89 -0
  83. package/apps/shared/keyboard/keymap.ts +114 -0
  84. package/apps/shared/keyboard/match.ts +44 -0
  85. package/apps/shared/keyboard/parse.ts +136 -0
  86. package/apps/shared/keyboard/platform.ts +34 -0
  87. package/apps/shared/keyboard/registry.ts +65 -0
  88. package/apps/shared/keyboard/targets.ts +79 -0
  89. package/apps/shared/keyboard/useShortcut.ts +50 -0
  90. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  91. package/apps/shared/mail/folderCounts.ts +308 -0
  92. package/apps/shared/mail/folderTree.ts +143 -0
  93. package/apps/shared/mail/listSnapshots.ts +87 -0
  94. package/apps/shared/mail/messageReadState.ts +85 -0
  95. package/apps/shared/mail/newMailNotifications.ts +183 -0
  96. package/apps/shared/mail/outbox/UnlockBridge.tsx +23 -0
  97. package/apps/shared/mail/outbox/composeBridge.ts +69 -0
  98. package/apps/shared/mail/outbox/outboxState.ts +88 -0
  99. package/apps/shared/mail/outbox/pendingSends.ts +146 -0
  100. package/apps/shared/mail/outbox/sendDecision.ts +157 -0
  101. package/apps/shared/mail/outbox/sendJob.ts +427 -0
  102. package/apps/shared/mail/outbox/sendOutcomes.ts +146 -0
  103. package/apps/shared/mail/outbox/sendState.ts +35 -0
  104. package/apps/shared/mail/outbox/useOutboxStatus.ts +61 -0
  105. package/apps/shared/mail/useMailConnection.ts +203 -0
  106. package/apps/shared/mail/useMailLiveUpdates.ts +277 -224
  107. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  108. package/apps/shared/mail/useNewMailNotifications.ts +178 -0
  109. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  110. package/apps/shared/navigation/AppRouter.tsx +300 -0
  111. package/apps/shared/navigation/appHrefs.ts +23 -0
  112. package/apps/shared/navigation/frameContext.tsx +35 -0
  113. package/apps/shared/navigation/idle.ts +45 -0
  114. package/apps/shared/navigation/routerContext.tsx +83 -0
  115. package/apps/shared/navigation/routes.ts +72 -0
  116. package/apps/shared/notifications/NotificationCenter.tsx +215 -0
  117. package/apps/shared/notifications/NotificationHistoryDialog.tsx +90 -0
  118. package/apps/shared/notifications/apiErrors.ts +98 -0
  119. package/apps/shared/notifications/headerOffset.ts +35 -0
  120. package/apps/shared/notifications/pushStatus.ts +56 -0
  121. package/apps/shared/notifications/store.ts +549 -0
  122. package/apps/shared/notifications/systemErrors.ts +63 -0
  123. package/apps/shared/notifications/useNotifications.ts +41 -0
  124. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  125. package/apps/shared/signing/enrollmentStorage.ts +33 -0
  126. package/apps/shared/signing/enrollmentTracker.ts +385 -0
  127. package/apps/shared/signing/enrollmentView.ts +251 -0
  128. package/apps/shared/signing/signingInfo.ts +58 -0
  129. package/apps/shared/signing/useNow.ts +19 -0
  130. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -0
  131. package/apps/shared/styles/app.css +108 -10
  132. package/apps/www/_layout.tsx +8 -2
  133. package/apps/www/_routedPage.tsx +24 -0
  134. package/apps/www/_routes.ts +36 -0
  135. package/apps/www/calendar/index.tsx +64 -33
  136. package/apps/www/contacts/[uid].tsx +109 -107
  137. package/apps/www/contacts/index.tsx +62 -42
  138. package/apps/www/index.tsx +2291 -1917
  139. package/apps/www/messages/[uid].tsx +108 -101
  140. package/apps/www/settings/appearance/index.tsx +21 -0
  141. package/apps/www/settings/auto-reply/index.tsx +7 -8
  142. package/apps/www/settings/encryption/index.tsx +1287 -1249
  143. package/apps/www/settings/filters/[uid].tsx +7 -2
  144. package/apps/www/settings/filters/index.tsx +102 -99
  145. package/apps/www/settings/filters/new/index.tsx +140 -133
  146. package/apps/www/settings/labels/index.tsx +204 -201
  147. package/apps/www/settings/privacy/index.tsx +7 -9
  148. package/apps/www/settings/read-receipts/index.tsx +7 -8
  149. package/apps/www/settings/sharing/index.tsx +256 -274
  150. package/apps/www/settings/signatures/[uid].tsx +172 -167
  151. package/apps/www/settings/signatures/index.tsx +88 -85
  152. package/apps/www/settings/signatures/new/index.tsx +135 -129
  153. package/apps/www/tasks/index.tsx +29 -18
  154. package/dist/apps/admin/_layout.d.ts +5 -1
  155. package/dist/apps/admin/_layout.js +3 -2
  156. package/dist/apps/admin/index.js +3 -2
  157. package/dist/apps/admin/ingest-queue/index.js +2 -1
  158. package/dist/apps/admin/mailboxes/[uid].js +9 -6
  159. package/dist/apps/admin/quarantine/index.js +2 -1
  160. package/dist/apps/admin/signing-certificates/index.d.ts +3 -0
  161. package/dist/apps/admin/signing-certificates/index.js +161 -0
  162. package/dist/apps/escrow/_layout.d.ts +5 -1
  163. package/dist/apps/escrow/_layout.js +3 -2
  164. package/dist/apps/shared/appearance/AppearanceForm.d.ts +7 -0
  165. package/dist/apps/shared/appearance/AppearanceForm.js +131 -0
  166. package/dist/apps/shared/appearance/AppearanceHead.d.ts +15 -0
  167. package/dist/apps/shared/appearance/AppearanceHead.js +31 -0
  168. package/dist/apps/shared/appearance/AppearanceProvider.d.ts +32 -0
  169. package/dist/apps/shared/appearance/AppearanceProvider.js +390 -0
  170. package/dist/apps/shared/appearance/ColorField.d.ts +22 -0
  171. package/dist/apps/shared/appearance/ColorField.js +40 -0
  172. package/dist/apps/shared/appearance/appearanceCache.d.ts +45 -0
  173. package/dist/apps/shared/appearance/appearanceCache.js +80 -0
  174. package/dist/apps/shared/appearance/appearanceContext.d.ts +45 -0
  175. package/dist/apps/shared/appearance/appearanceContext.js +29 -0
  176. package/dist/apps/shared/appearance/bootScript.d.ts +16 -0
  177. package/dist/apps/shared/appearance/bootScript.js +33 -0
  178. package/dist/apps/shared/appearance/color.d.ts +65 -0
  179. package/dist/apps/shared/appearance/color.js +132 -0
  180. package/dist/apps/shared/appearance/photo.d.ts +17 -0
  181. package/dist/apps/shared/appearance/photo.js +79 -0
  182. package/dist/apps/shared/appearance/resolvedTheme.d.ts +21 -0
  183. package/dist/apps/shared/appearance/resolvedTheme.js +55 -0
  184. package/dist/apps/shared/appearance/theme.d.ts +111 -0
  185. package/dist/apps/shared/appearance/theme.js +251 -0
  186. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  187. package/dist/apps/shared/auth/accountUrl.js +8 -0
  188. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  189. package/dist/apps/shared/auth/adminAccess.js +89 -0
  190. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  191. package/dist/apps/shared/components/admin/layout/AdminShell.js +16 -6
  192. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +10 -5
  193. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +49 -34
  194. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +2 -0
  195. package/dist/apps/shared/components/admin/settings/BrandingForm.js +7 -1
  196. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +1 -1
  197. package/dist/apps/shared/components/admin/settings/PluginsManager.js +106 -20
  198. package/dist/apps/shared/components/admin/setup/SetupWizard.js +2 -1
  199. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +8 -8
  200. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  201. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  202. package/dist/apps/shared/components/contacts/ContactForm.js +1 -1
  203. package/dist/apps/shared/components/contacts/ContactsSidebar.js +4 -2
  204. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +6 -2
  205. package/dist/apps/shared/components/contacts/ContactsToolbar.js +28 -10
  206. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  207. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +19 -6
  208. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +7 -4
  209. package/dist/apps/shared/components/layout/AppShell.d.ts +34 -4
  210. package/dist/apps/shared/components/layout/AppShell.js +122 -17
  211. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +69 -10
  212. package/dist/apps/shared/components/layout/BrandingChrome.js +140 -12
  213. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  214. package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
  215. package/dist/apps/shared/components/layout/RailIcon.d.ts +21 -0
  216. package/dist/apps/shared/components/layout/RailIcon.js +85 -0
  217. package/dist/apps/shared/components/layout/ResponsiveToolbar.d.ts +55 -0
  218. package/dist/apps/shared/components/layout/ResponsiveToolbar.js +199 -0
  219. package/dist/apps/shared/components/layout/ThemeSwitch.d.ts +9 -0
  220. package/dist/apps/shared/components/layout/ThemeSwitch.js +54 -0
  221. package/dist/apps/shared/components/layout/UserMenu.d.ts +25 -3
  222. package/dist/apps/shared/components/layout/UserMenu.js +88 -7
  223. package/dist/apps/shared/components/mail/ConversationList.js +5 -11
  224. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +6 -2
  225. package/dist/apps/shared/components/mail/ConversationThreadPane.js +87 -40
  226. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  227. package/dist/apps/shared/components/mail/LazyReadingPane.js +83 -0
  228. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -4
  229. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
  230. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +25 -8
  231. package/dist/apps/shared/components/mail/MessageDetailPane.js +167 -73
  232. package/dist/apps/shared/components/mail/OutboxBadge.d.ts +17 -0
  233. package/dist/apps/shared/components/mail/OutboxBadge.js +18 -0
  234. package/dist/apps/shared/components/mail/OutboxRowStatus.d.ts +10 -0
  235. package/dist/apps/shared/components/mail/OutboxRowStatus.js +19 -0
  236. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +38 -0
  237. package/dist/apps/shared/components/mail/compose/ComposeContext.js +97 -6
  238. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  239. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +3 -2
  240. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +316 -339
  241. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  242. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  243. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  244. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  245. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  246. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  247. package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +49 -0
  248. package/dist/apps/shared/components/mail/compose/encryptionRequirement.js +35 -0
  249. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  250. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  251. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +14 -4
  252. package/dist/apps/shared/components/mail/layout/MailShell.js +90 -104
  253. package/dist/apps/shared/components/mail/reading/EncryptedBody.d.ts +21 -0
  254. package/dist/apps/shared/components/mail/reading/EncryptedBody.js +22 -0
  255. package/dist/apps/shared/components/mail/reading/EncryptedPreview.d.ts +18 -0
  256. package/dist/apps/shared/components/mail/reading/EncryptedPreview.js +22 -0
  257. package/dist/apps/shared/components/mail/reading/MessageBody.d.ts +32 -0
  258. package/dist/apps/shared/components/mail/reading/MessageBody.js +136 -0
  259. package/dist/apps/shared/components/mail/reading/MessageCard.d.ts +62 -0
  260. package/dist/apps/shared/components/mail/reading/MessageCard.js +58 -0
  261. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +38 -0
  262. package/dist/apps/shared/components/mail/reading/bodyContent.js +84 -0
  263. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +47 -0
  264. package/dist/apps/shared/components/mail/reading/bodyHtml.js +267 -0
  265. package/dist/apps/shared/components/mail/reading/color.d.ts +63 -0
  266. package/dist/apps/shared/components/mail/reading/color.js +201 -0
  267. package/dist/apps/shared/components/mail/reading/frameControl.d.ts +33 -0
  268. package/dist/apps/shared/components/mail/reading/frameControl.js +120 -0
  269. package/dist/apps/shared/components/mail/reading/frameDocument.d.ts +52 -0
  270. package/dist/apps/shared/components/mail/reading/frameDocument.js +98 -0
  271. package/dist/apps/shared/components/mail/reading/safeDocument.d.ts +3 -0
  272. package/dist/apps/shared/components/mail/reading/safeDocument.js +12 -0
  273. package/dist/apps/shared/components/mail/reading/themeAdaptation.d.ts +56 -0
  274. package/dist/apps/shared/components/mail/reading/themeAdaptation.js +144 -0
  275. package/dist/apps/shared/components/mail/reading/themeSurface.d.ts +17 -0
  276. package/dist/apps/shared/components/mail/reading/themeSurface.js +83 -0
  277. package/dist/apps/shared/components/mail/reading/viewOriginal.d.ts +9 -0
  278. package/dist/apps/shared/components/mail/reading/viewOriginal.js +32 -0
  279. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  280. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  281. package/dist/apps/shared/components/settings/SigningCertificateCard.d.ts +32 -0
  282. package/dist/apps/shared/components/settings/SigningCertificateCard.js +98 -0
  283. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  284. package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -5
  285. package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +26 -0
  286. package/dist/apps/shared/components/sharing/PrincipalPicker.js +77 -0
  287. package/dist/apps/shared/components/tasks/TasksSidebar.js +4 -2
  288. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  289. package/dist/apps/shared/components/tasks/layout/TasksShell.js +18 -6
  290. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  291. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  292. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  293. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  294. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  295. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  296. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  297. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  298. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  299. package/dist/apps/shared/keyboard/format.js +74 -0
  300. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  301. package/dist/apps/shared/keyboard/keymap.js +84 -0
  302. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  303. package/dist/apps/shared/keyboard/match.js +31 -0
  304. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  305. package/dist/apps/shared/keyboard/parse.js +107 -0
  306. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  307. package/dist/apps/shared/keyboard/platform.js +22 -0
  308. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  309. package/dist/apps/shared/keyboard/registry.js +33 -0
  310. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  311. package/dist/apps/shared/keyboard/targets.js +67 -0
  312. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  313. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  314. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  315. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  316. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  317. package/dist/apps/shared/mail/folderCounts.js +218 -0
  318. package/dist/apps/shared/mail/folderTree.d.ts +55 -0
  319. package/dist/apps/shared/mail/folderTree.js +116 -0
  320. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  321. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  322. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  323. package/dist/apps/shared/mail/messageReadState.js +63 -0
  324. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  325. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  326. package/dist/apps/shared/mail/outbox/UnlockBridge.d.ts +5 -0
  327. package/dist/apps/shared/mail/outbox/UnlockBridge.js +18 -0
  328. package/dist/apps/shared/mail/outbox/composeBridge.d.ts +34 -0
  329. package/dist/apps/shared/mail/outbox/composeBridge.js +32 -0
  330. package/dist/apps/shared/mail/outbox/outboxState.d.ts +41 -0
  331. package/dist/apps/shared/mail/outbox/outboxState.js +42 -0
  332. package/dist/apps/shared/mail/outbox/pendingSends.d.ts +39 -0
  333. package/dist/apps/shared/mail/outbox/pendingSends.js +109 -0
  334. package/dist/apps/shared/mail/outbox/sendDecision.d.ts +80 -0
  335. package/dist/apps/shared/mail/outbox/sendDecision.js +94 -0
  336. package/dist/apps/shared/mail/outbox/sendJob.d.ts +68 -0
  337. package/dist/apps/shared/mail/outbox/sendJob.js +319 -0
  338. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +3 -0
  339. package/dist/apps/shared/mail/outbox/sendOutcomes.js +141 -0
  340. package/dist/apps/shared/mail/outbox/sendState.d.ts +25 -0
  341. package/dist/apps/shared/mail/outbox/sendState.js +8 -0
  342. package/dist/apps/shared/mail/outbox/useOutboxStatus.d.ts +11 -0
  343. package/dist/apps/shared/mail/outbox/useOutboxStatus.js +49 -0
  344. package/dist/apps/shared/mail/useMailConnection.d.ts +56 -0
  345. package/dist/apps/shared/mail/useMailConnection.js +138 -0
  346. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +29 -9
  347. package/dist/apps/shared/mail/useMailLiveUpdates.js +60 -32
  348. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  349. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  350. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +35 -0
  351. package/dist/apps/shared/mail/useNewMailNotifications.js +130 -0
  352. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  353. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  354. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  355. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  356. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  357. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  358. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  359. package/dist/apps/shared/navigation/frameContext.js +24 -0
  360. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  361. package/dist/apps/shared/navigation/idle.js +43 -0
  362. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  363. package/dist/apps/shared/navigation/routerContext.js +56 -0
  364. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  365. package/dist/apps/shared/navigation/routes.js +37 -0
  366. package/dist/apps/shared/notifications/NotificationCenter.d.ts +19 -0
  367. package/dist/apps/shared/notifications/NotificationCenter.js +90 -0
  368. package/dist/apps/shared/notifications/NotificationHistoryDialog.d.ts +10 -0
  369. package/dist/apps/shared/notifications/NotificationHistoryDialog.js +36 -0
  370. package/dist/apps/shared/notifications/apiErrors.d.ts +19 -0
  371. package/dist/apps/shared/notifications/apiErrors.js +86 -0
  372. package/dist/apps/shared/notifications/headerOffset.d.ts +8 -0
  373. package/dist/apps/shared/notifications/headerOffset.js +33 -0
  374. package/dist/apps/shared/notifications/pushStatus.d.ts +9 -0
  375. package/dist/apps/shared/notifications/pushStatus.js +54 -0
  376. package/dist/apps/shared/notifications/store.d.ts +136 -0
  377. package/dist/apps/shared/notifications/store.js +416 -0
  378. package/dist/apps/shared/notifications/systemErrors.d.ts +9 -0
  379. package/dist/apps/shared/notifications/systemErrors.js +58 -0
  380. package/dist/apps/shared/notifications/useNotifications.d.ts +17 -0
  381. package/dist/apps/shared/notifications/useNotifications.js +18 -0
  382. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  383. package/dist/apps/shared/signing/enrollmentStorage.d.ts +10 -0
  384. package/dist/apps/shared/signing/enrollmentStorage.js +33 -0
  385. package/dist/apps/shared/signing/enrollmentTracker.d.ts +73 -0
  386. package/dist/apps/shared/signing/enrollmentTracker.js +307 -0
  387. package/dist/apps/shared/signing/enrollmentView.d.ts +57 -0
  388. package/dist/apps/shared/signing/enrollmentView.js +214 -0
  389. package/dist/apps/shared/signing/signingInfo.d.ts +11 -0
  390. package/dist/apps/shared/signing/signingInfo.js +48 -0
  391. package/dist/apps/shared/signing/useNow.d.ts +2 -0
  392. package/dist/apps/shared/signing/useNow.js +18 -0
  393. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.d.ts +16 -0
  394. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.js +87 -0
  395. package/dist/apps/shared/styles/app.css +108 -10
  396. package/dist/apps/www/_layout.d.ts +5 -1
  397. package/dist/apps/www/_layout.js +3 -2
  398. package/dist/apps/www/_routedPage.d.ts +12 -0
  399. package/dist/apps/www/_routedPage.js +19 -0
  400. package/dist/apps/www/_routes.d.ts +11 -0
  401. package/dist/apps/www/_routes.js +30 -0
  402. package/dist/apps/www/calendar/index.d.ts +2 -2
  403. package/dist/apps/www/calendar/index.js +41 -11
  404. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  405. package/dist/apps/www/contacts/[uid].js +11 -9
  406. package/dist/apps/www/contacts/index.d.ts +2 -2
  407. package/dist/apps/www/contacts/index.js +30 -24
  408. package/dist/apps/www/index.d.ts +2 -2
  409. package/dist/apps/www/index.js +332 -48
  410. package/dist/apps/www/messages/[uid].d.ts +3 -7
  411. package/dist/apps/www/messages/[uid].js +10 -5
  412. package/dist/apps/www/settings/appearance/index.d.ts +4 -0
  413. package/dist/apps/www/settings/appearance/index.js +13 -0
  414. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  415. package/dist/apps/www/settings/auto-reply/index.js +6 -7
  416. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  417. package/dist/apps/www/settings/encryption/index.js +106 -96
  418. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  419. package/dist/apps/www/settings/filters/[uid].js +6 -2
  420. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  421. package/dist/apps/www/settings/filters/index.js +3 -1
  422. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  423. package/dist/apps/www/settings/filters/new/index.js +9 -3
  424. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  425. package/dist/apps/www/settings/labels/index.js +3 -1
  426. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  427. package/dist/apps/www/settings/privacy/index.js +8 -9
  428. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  429. package/dist/apps/www/settings/read-receipts/index.js +6 -7
  430. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  431. package/dist/apps/www/settings/sharing/index.js +22 -34
  432. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  433. package/dist/apps/www/settings/signatures/[uid].js +6 -2
  434. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  435. package/dist/apps/www/settings/signatures/index.js +3 -1
  436. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  437. package/dist/apps/www/settings/signatures/new/index.js +9 -4
  438. package/dist/apps/www/tasks/index.d.ts +2 -2
  439. package/dist/apps/www/tasks/index.js +22 -16
  440. package/package.json +2 -2
  441. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +0 -48
  442. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +0 -14
  443. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +0 -11
@@ -0,0 +1,142 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import type { ThemeSurface } from "./color.js";
6
+ import { bodyOf, documentElementOf, viewOf } from "./safeDocument.js";
7
+ import { AdaptationResult, adaptDocument } from "./themeAdaptation.js";
8
+
9
+ /**
10
+ * The parent's side of a message's display frame: once its document has loaded, adapt its colours to the theme, then follow its height
11
+ * for as long as it lives. This is where the frame is read, which works because the frame is same-origin and has no script; every read
12
+ * is made through the DOM's own accessors on the frame's document (never by a name the document could have clobbered).
13
+ */
14
+
15
+ /** The tallest a message frame is allowed to grow, in CSS pixels: a hostile `height: 100000000px` must not build a layer the browser cannot paint. */
16
+ export const MAX_FRAME_HEIGHT = 200_000;
17
+ /** How many size changes one loaded document may report before the frame stops listening - a stylesheet that resizes itself in response to
18
+ * its own size (`height: 200vh`) would otherwise grow the frame on every observation, up to the cap. */
19
+ export const MAX_RESIZE_REPORTS = 200;
20
+
21
+ export interface FrameHandlers {
22
+ /** The document's content height in CSS pixels, whenever it changes (also once at load). */
23
+ onHeight: (height: number) => void;
24
+ /** The document is ready to show. `adaptation` is present when it was adapted to a theme. */
25
+ onReady: (adaptation?: AdaptationResult) => void;
26
+ /** Adapt to this surface before showing; absent for a document shown as it is. */
27
+ surface?: ThemeSurface;
28
+ }
29
+
30
+ function pixels(value: string): number {
31
+ const parsed = parseFloat(value);
32
+ return Number.isFinite(parsed) ? parsed : 0;
33
+ }
34
+
35
+ /**
36
+ * The height of what the document displays: the bottom of its wrapper plus the body's own trailing margin, padding and border. Not
37
+ * `scrollHeight`, which is never less than the frame's own height, so a message could never shrink.
38
+ */
39
+ export function measureFrameHeight(doc: Document): number {
40
+ const view = viewOf(doc);
41
+ const body = bodyOf(doc);
42
+ const wrapper = body?.firstElementChild;
43
+ if (!view || !body || !wrapper) {
44
+ return 0;
45
+ }
46
+ const style = view.getComputedStyle(body);
47
+ const trailing = pixels(style.marginBottom) + pixels(style.paddingBottom) + pixels(style.borderBottomWidth);
48
+ const bottom = wrapper.getBoundingClientRect().bottom + view.scrollY + trailing;
49
+ return Math.min(MAX_FRAME_HEIGHT, Math.max(0, Math.ceil(bottom)));
50
+ }
51
+
52
+ /** Re-sends a key pressed inside the frame to the app's own document, so the app's keyboard shortcuts work with focus in a message body
53
+ * (a frame keeps its own key events); if the app handled it, the frame's default action is prevented too. */
54
+ export function forwardKeyDown(event: KeyboardEvent, target: Document): void {
55
+ const copy = new KeyboardEvent("keydown", {
56
+ key: event.key,
57
+ code: event.code,
58
+ ctrlKey: event.ctrlKey,
59
+ shiftKey: event.shiftKey,
60
+ altKey: event.altKey,
61
+ metaKey: event.metaKey,
62
+ repeat: event.repeat,
63
+ bubbles: true,
64
+ cancelable: true,
65
+ });
66
+ if (!target.dispatchEvent(copy)) {
67
+ event.preventDefault();
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Starts controlling `iframe`'s freshly loaded document. Returns a function that stops everything (observers, listeners, an adaptation
73
+ * still in progress); call it before the frame reloads and when it goes away. Returns a no-op for a frame with no readable document.
74
+ */
75
+ export function controlFrame(iframe: HTMLIFrameElement, handlers: FrameHandlers): () => void {
76
+ const doc = iframe.contentDocument;
77
+ const view = doc ? viewOf(doc) : null;
78
+ const body = doc ? bodyOf(doc) : null;
79
+ const wrapper = body?.firstElementChild;
80
+ if (!doc || !view || !body || !wrapper) {
81
+ return () => undefined;
82
+ }
83
+ let disposed = false;
84
+ let reports = 0;
85
+ const measure = () => {
86
+ if (!disposed) {
87
+ handlers.onHeight(measureFrameHeight(doc));
88
+ }
89
+ };
90
+ const onKeyDown = (event: KeyboardEvent) => forwardKeyDown(event, iframe.ownerDocument);
91
+ doc.addEventListener("keydown", onKeyDown);
92
+
93
+ measure();
94
+ // The frame's own ResizeObserver where it has one (the observations then run in its own rendering steps), else this window's.
95
+ const Observer: typeof ResizeObserver | undefined = view.ResizeObserver ?? (typeof ResizeObserver === "undefined" ? undefined : ResizeObserver);
96
+ let observer: ResizeObserver | undefined;
97
+ const onWindowResize = () => measure();
98
+ if (Observer) {
99
+ observer = new Observer(() => {
100
+ if (++reports > MAX_RESIZE_REPORTS) {
101
+ observer?.disconnect();
102
+ return;
103
+ }
104
+ measure();
105
+ });
106
+ for (const element of [wrapper, body, documentElementOf(doc)]) {
107
+ observer.observe(element);
108
+ }
109
+ } else {
110
+ window.addEventListener("resize", onWindowResize);
111
+ }
112
+
113
+ // Paper is not a dark surface: while the page is being printed the message goes back to the colours it was written with, and is adapted
114
+ // again afterwards.
115
+ let revert: (() => void) | undefined;
116
+ let reapply: (() => void) | undefined;
117
+ const onBeforePrint = () => revert?.();
118
+ const onAfterPrint = () => reapply?.();
119
+ window.addEventListener("beforeprint", onBeforePrint);
120
+ window.addEventListener("afterprint", onAfterPrint);
121
+
122
+ if (handlers.surface) {
123
+ void adaptDocument(doc, handlers.surface, { isCancelled: () => disposed }).then((result) => {
124
+ if (!disposed) {
125
+ ({ revert, reapply } = result);
126
+ measure();
127
+ handlers.onReady(result);
128
+ }
129
+ });
130
+ } else {
131
+ handlers.onReady();
132
+ }
133
+
134
+ return () => {
135
+ disposed = true;
136
+ window.removeEventListener("beforeprint", onBeforePrint);
137
+ window.removeEventListener("afterprint", onAfterPrint);
138
+ observer?.disconnect();
139
+ window.removeEventListener("resize", onWindowResize);
140
+ doc.removeEventListener("keydown", onKeyDown);
141
+ };
142
+ }
@@ -0,0 +1,123 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ /**
7
+ * The document a message's body is displayed in: the `srcdoc` of a sandboxed iframe.
8
+ *
9
+ * **Why an iframe, and why `allow-same-origin`.** The body is untrusted HTML. It must never be inserted into the app's own DOM
10
+ * (a sanitizer bug there is script in the app's origin, next to the unlocked mail keys), so it lives in a frame. A frame with
11
+ * `sandbox=""` has an opaque origin and cannot be measured from outside, which is what made every message a full-height scrolling
12
+ * box. `allow-same-origin` (and nothing else that runs code - never `allow-scripts`) lets the *app* read the document's height and
13
+ * adapt its colours, and is safe because a document with no script can neither use the origin nor reach the app: there is no code in
14
+ * the frame to do it. That claim rests on four layers, each enough to stop a script on its own (server sanitizer, `bodyHtml.ts`,
15
+ * the CSP below, the missing `allow-scripts`), and is proven against a corpus of hostile mail in a real browser
16
+ * (`.claude/NOTES.md`, 2026-09-21).
17
+ *
18
+ * The rest of the sandbox: `allow-popups` and `allow-popups-to-escape-sandbox` only so a link the reader clicks can open in a new tab
19
+ * (every link is `target=_blank rel="noopener noreferrer nofollow"`, and no script can open one uninvited); no `allow-forms`,
20
+ * `allow-top-navigation`, `allow-modals`, `allow-downloads`, `allow-pointer-lock` or `allow-presentation`.
21
+ */
22
+ export const FRAME_SANDBOX = "allow-same-origin allow-popups allow-popups-to-escape-sandbox";
23
+
24
+ /**
25
+ * The frame's Content-Security-Policy. Nothing is loaded from anywhere: images are `data:` URIs and, for a message's inline parts,
26
+ * this server's own attachment URLs (`attachmentSource`); remote images, fonts, stylesheets, frames and every other fetch are
27
+ * refused, as they are today by the server's own header for `/content` (`default-src 'none'; img-src data: cid:; style-src
28
+ * 'unsafe-inline'; sandbox`). Inline styles are allowed because mail is styled that way; nothing else inline is (no script at all).
29
+ */
30
+ export function frameCsp(attachmentSource?: string): string {
31
+ // A source is a URL prefix (`https://host/api/mail/attachments/`); anything that is not one is left out rather than trusted, as this
32
+ // string is written into an attribute.
33
+ const source = attachmentSource && /^https?:\/\/[\w.:@-]+(?:\/[\w./%~-]*)?$/.test(attachmentSource) ? attachmentSource : undefined;
34
+ return [
35
+ "default-src 'none'",
36
+ "script-src 'none'",
37
+ "object-src 'none'",
38
+ "frame-src 'none'",
39
+ "form-action 'none'",
40
+ "base-uri 'none'",
41
+ "style-src 'unsafe-inline'",
42
+ `img-src data:${source ? ` ${source}` : ""}`,
43
+ "font-src data:",
44
+ ].join("; ");
45
+ }
46
+
47
+ /** How a message is being shown: as the theme dictates (`adapt`), as its author wrote it (`original`), or - a message that carries its
48
+ * own dark styles - as those styles say (`native`). */
49
+ export type FrameMode = "adapt" | "original" | "native";
50
+
51
+ export interface FrameOptions {
52
+ mode: FrameMode;
53
+ /** The app's scheme: what `prefers-color-scheme` means inside the frame (it would otherwise be the operating system's). */
54
+ scheme: "light" | "dark";
55
+ /** The theme's opaque body surface and text colour, as `rgb()` - the defaults a native-dark message starts from. */
56
+ surface: string;
57
+ text: string;
58
+ /** The CSP `img-src` source for this server's attachment URLs, when the message has inline images that point there. */
59
+ attachmentSource?: string;
60
+ }
61
+
62
+ /** The colour the adaptation pass reads to tell text nobody coloured from text somebody did (`themeAdaptation.ts`). */
63
+ export const TEXT_SENTINEL = "rgb(1, 2, 3)";
64
+ /** The same for a link nobody coloured. */
65
+ export const LINK_SENTINEL = "rgb(1, 2, 4)";
66
+
67
+ const ALWAYS_TRUE = "(min-width: 0px)";
68
+ const ALWAYS_FALSE = "(max-width: -1px)";
69
+
70
+ /**
71
+ * Makes `prefers-color-scheme` mean the app's scheme inside a message's stylesheets. A browser answers it with the operating
72
+ * system's preference, which is not what the reader chose in Appearance; each occurrence becomes a media condition that is always
73
+ * true or always false. Only `<style>` contents are rewritten.
74
+ */
75
+ export function rewriteColourSchemeQueries(html: string, scheme: "light" | "dark"): string {
76
+ return html.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_match, open: string, css: string, close: string) => {
77
+ const rewritten = css.replace(/\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)/gi, (_condition, wanted: string) =>
78
+ wanted.toLowerCase() === scheme ? ALWAYS_TRUE : ALWAYS_FALSE,
79
+ );
80
+ return open + rewritten + close;
81
+ });
82
+ }
83
+
84
+ /** Layout rules every mode shares: the document is exactly as tall as its content, never scrolls itself, and a wide table scrolls
85
+ * inside the message instead of widening the pane. */
86
+ const LAYOUT_CSS = `
87
+ html{height:auto!important;min-height:0!important;overflow:hidden!important;font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif}
88
+ body{height:auto!important;min-height:0!important}
89
+ #rr-body{box-sizing:border-box;position:relative;overflow-x:auto;overflow-y:hidden;overflow-wrap:break-word}
90
+ #rr-body img{max-width:100%;height:auto}
91
+ #rr-body pre{white-space:pre-wrap}
92
+ @media print{html{overflow:visible!important}}
93
+ `;
94
+
95
+ function modeCss(options: FrameOptions): string {
96
+ const { mode, scheme, surface, text } = options;
97
+ if (mode === "adapt") {
98
+ // The sentinels are what the adaptation pass reads to tell what a message's author coloured from what it inherited; the pass
99
+ // then replaces them, so they never show. Zero specificity: any rule of the message beats them.
100
+ return `:where(html){color-scheme:${scheme}}:where(body){margin:0}:where(:root){color:${TEXT_SENTINEL}}:where(a:link,a:visited){color:${LINK_SENTINEL}}`;
101
+ }
102
+ if (mode === "native") {
103
+ return `:where(html){color-scheme:${scheme};background-color:${surface};color:${text}}:where(body){margin:0}`;
104
+ }
105
+ // As its author wrote it: a light document, as a browser in a light scheme shows it - with the browser's own 8px page margin. Zero
106
+ // specificity, so the message's own rules win.
107
+ return ":where(html){color-scheme:light}:where(body){margin:8px}";
108
+ }
109
+
110
+ /**
111
+ * The complete document for a message: the CSP as the very first element of the head, the layout rules, the mode's defaults and the
112
+ * sanitized fragment inside `#rr-body`. `fragment` must come out of `prepareBodyHtml()`.
113
+ */
114
+ export function buildFrameDocument(fragment: string, options: FrameOptions): string {
115
+ const body = rewriteColourSchemeQueries(fragment, options.mode === "original" ? "light" : options.scheme);
116
+ return [
117
+ "<!doctype html>",
118
+ '<html lang="en"><head><meta charset="utf-8">',
119
+ `<meta http-equiv="Content-Security-Policy" content="${frameCsp(options.attachmentSource)}">`,
120
+ `<style>${LAYOUT_CSS}${modeCss(options)}</style></head>`,
121
+ `<body><div id="rr-body">${body}</div></body></html>`,
122
+ ].join("");
123
+ }
@@ -0,0 +1,24 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ /**
7
+ * Reads off a message's display document that a hostile message cannot redirect. On a `Document` the named properties of `<img name=...>`,
8
+ * `<form name=...>` and friends *override* the built-in ones (`<img name="body">` makes `document.body` that image), so anything reading
9
+ * `doc.body` from the app would be handed the message's element. Every such element and attribute is stripped before display
10
+ * (`bodyHtml.ts`), and these accessors - the platform's own getters, taken from this window's `Document.prototype` and applied to the
11
+ * frame's document - are the second guard: they answer with the real thing whatever the document contains.
12
+ */
13
+ type DocumentGetter = "documentElement" | "body" | "defaultView";
14
+
15
+ const getters: Partial<Record<DocumentGetter, () => unknown>> = {};
16
+
17
+ function read<T>(doc: Document, name: DocumentGetter): T {
18
+ getters[name] ??= Object.getOwnPropertyDescriptor(Document.prototype, name)!.get as () => unknown;
19
+ return getters[name].call(doc) as T;
20
+ }
21
+
22
+ export const documentElementOf = (doc: Document): HTMLElement => read<HTMLElement>(doc, "documentElement");
23
+ export const bodyOf = (doc: Document): HTMLElement | null => read<HTMLElement | null>(doc, "body");
24
+ export const viewOf = (doc: Document): (Window & typeof globalThis) | null => read<(Window & typeof globalThis) | null>(doc, "defaultView");
@@ -0,0 +1,221 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import {
6
+ MIN_TEXT_CONTRAST,
7
+ Rgb,
8
+ ThemeSurface,
9
+ adaptForeground,
10
+ blackOrWhite,
11
+ composite,
12
+ contrastRatio,
13
+ ensureContrast,
14
+ firstColourStop,
15
+ formatColour,
16
+ luminance,
17
+ parseColour,
18
+ sameColour,
19
+ } from "./color.js";
20
+ import { LINK_SENTINEL, TEXT_SENTINEL } from "./frameDocument.js";
21
+ import { documentElementOf, viewOf } from "./safeDocument.js";
22
+
23
+ /**
24
+ * Makes a message take the app's theme wherever its author left the colours to the reader, and leaves it as authored wherever the
25
+ * author chose them. Runs in the parent against the display frame's document (which is same-origin only because no script can run
26
+ * there - see `frameDocument.ts`); reads with `getComputedStyle` and writes nothing but `color` on individual elements, as inline
27
+ * `!important` declarations built from numbers, never from text of the message.
28
+ *
29
+ * **Authored or inherited, without parsing CSS.** The frame's base stylesheet gives the root a sentinel text colour, and unvisited/visited
30
+ * links another, at zero specificity, so any rule of the message beats them. An element whose computed `color` still equals the sentinel
31
+ * was never coloured by the message (its text is *unauthored*); any other value - black included - was chosen by somebody. A background
32
+ * is authored when its computed colour is not transparent or its image is not `none`.
33
+ *
34
+ * **What each text becomes**, from its effective background (the nearest authored background up the tree, composited over what is beneath
35
+ * it, else the theme's surface):
36
+ * - unauthored text on the theme surface: the theme's text colour; an unauthored link: the theme's link colour;
37
+ * - authored text on the theme surface: kept if it reads (4.5:1), else adapted (`adaptForeground()`: black becomes white, navy a lighter
38
+ * navy) - the symmetric case in a light theme included;
39
+ * - anything on an authored, opaque background (a white cell, a dark banner, a gradient): that region is shown as authored - its own text
40
+ * colours untouched - except unauthored text, which must not inherit the theme's colour (white on white) and gets black or white by
41
+ * contrast with that background (black when the background is a picture or a gradient with no first colour to judge by);
42
+ * - a semi-transparent authored background is composited over what is under it and treated like the theme surface, since how it looks
43
+ * depends on the theme.
44
+ *
45
+ * Every element ends with exactly its target colour: an element is written to only when inheritance would not give it that (which includes
46
+ * one that may carry a declaration of its own). Images, SVG and everything else that is not text are untouched.
47
+ */
48
+
49
+ export interface AdaptationResult {
50
+ /** Elements examined. */
51
+ elements: number;
52
+ /** Elements given an inline colour. */
53
+ written: number;
54
+ /** Whether adapting makes a visible difference from the message as authored: a text colour changed a lot, or the canvas is the theme's
55
+ * dark surface rather than the white a message is authored on. False for e.g. a bare message in a light theme. */
56
+ material: boolean;
57
+ /** `<html>` or `<body>` carries an authored background: the whole frame takes it. */
58
+ authoredCanvas: boolean;
59
+ /** Stopped because `isCancelled()` said the frame was gone. */
60
+ cancelled: boolean;
61
+ /** Puts every colour back as the message had it (paper is not a dark surface: the frame does this for printing). */
62
+ revert: () => void;
63
+ /** Puts the adapted colours back after `revert()`. */
64
+ reapply: () => void;
65
+ }
66
+
67
+ export interface AdaptOptions {
68
+ /** How long one slice of work may take before the pass gives the browser a turn. */
69
+ sliceMs?: number;
70
+ isCancelled?: () => boolean;
71
+ /** For tests. */
72
+ now?: () => number;
73
+ yieldToBrowser?: () => Promise<void>;
74
+ }
75
+
76
+ /** Not examined, nor are their children. `head` and its contents never render; `svg` is left as drawn. */
77
+ const SKIPPED = new Set(["head", "script", "style", "title", "meta", "link", "template", "svg", "img"]);
78
+
79
+ /** What travels down the tree: the effective background, whether it is an authored opaque one, and the colour the parent ends up with. */
80
+ interface Context {
81
+ background: Rgb;
82
+ authored: boolean;
83
+ unknown: boolean;
84
+ parentColour: Rgb;
85
+ }
86
+
87
+ /** Classic link blue - what an unauthored link looks like in an authored region, before contrast. */
88
+ const DEFAULT_LINK: Rgb = { r: 0, g: 0, b: 238 };
89
+ /** How much lighter or darker (in luminance) an adapted text colour must be than the original for the difference to be worth an opt-out. */
90
+ const MATERIAL_LUMINANCE_SHIFT = 0.3;
91
+ /** A surface at least this luminous is close enough to the white a message is authored on that it is not, by itself, an adaptation. */
92
+ const LIGHT_SURFACE = 0.85;
93
+
94
+ /** The elements whose background is the message's canvas: `<html>`, `<body>`, and the wrapper that stands in for the message's own `<body>`. */
95
+ function standsForCanvas(element: HTMLElement, root: HTMLElement): boolean {
96
+ return element === root || element.localName === "body" || element.parentElement?.id === "rr-body";
97
+ }
98
+
99
+ function noop(): void {
100
+ // Nothing was written, so there is nothing to put back.
101
+ }
102
+
103
+ function nextTurn(): Promise<void> {
104
+ return new Promise((resolve) => setTimeout(resolve, 0));
105
+ }
106
+
107
+ /** The colour a `color` value comes to over a background: opaque, alpha composited. */
108
+ function opaque(css: string, over: Rgb): Rgb | undefined {
109
+ const colour = parseColour(css);
110
+ return colour ? composite(colour, over) : undefined;
111
+ }
112
+
113
+ /**
114
+ * Adapts `doc` to `surface` - see the file's header. Time-sliced (`sliceMs`, then a turn for the browser) so a very large message never
115
+ * blocks the page; the frame stays hidden until this resolves, so nothing unreadable is ever shown.
116
+ */
117
+ export async function adaptDocument(doc: Document, surface: ThemeSurface, options: AdaptOptions = {}): Promise<AdaptationResult> {
118
+ const { sliceMs = 12, isCancelled = () => false, now = () => performance.now(), yieldToBrowser = nextTurn } = options;
119
+ const view = viewOf(doc);
120
+ const result: AdaptationResult = { elements: 0, written: 0, material: false, authoredCanvas: false, cancelled: false, revert: noop, reapply: noop };
121
+ if (!view) {
122
+ return result;
123
+ }
124
+ const root = documentElementOf(doc);
125
+ // Every read happens before the first write: a write changes what its descendants inherit, which would make every child of an element
126
+ // we coloured look authored.
127
+ const writes: { element: HTMLElement; colour: string }[] = [];
128
+ const stack: { element: HTMLElement; context: Context }[] = [
129
+ { element: root, context: { background: surface.background, authored: false, unknown: false, parentColour: surface.text } },
130
+ ];
131
+ let sliceStart = now();
132
+ while (stack.length > 0) {
133
+ if (now() - sliceStart > sliceMs) {
134
+ await yieldToBrowser();
135
+ if (isCancelled()) {
136
+ result.cancelled = true;
137
+ return result;
138
+ }
139
+ sliceStart = now();
140
+ }
141
+ const { element, context } = stack.pop()!;
142
+ if (SKIPPED.has(element.localName.toLowerCase())) {
143
+ continue;
144
+ }
145
+ result.elements++;
146
+ const style = view.getComputedStyle(element);
147
+ let { background, authored, unknown } = context;
148
+ const own = parseColour(style.backgroundColor);
149
+ const image = style.backgroundImage;
150
+ if (image && image !== "none") {
151
+ const stop = firstColourStop(image) ?? (own && own.a > 0 ? own : undefined);
152
+ authored = true;
153
+ unknown = !stop;
154
+ background = stop ? composite(stop, background) : background;
155
+ result.authoredCanvas ||= standsForCanvas(element, root);
156
+ } else if (own && own.a >= 0.98) {
157
+ authored = true;
158
+ unknown = false;
159
+ background = composite(own, background);
160
+ result.authoredCanvas ||= standsForCanvas(element, root);
161
+ } else if (own && own.a > 0) {
162
+ background = composite(own, background);
163
+ }
164
+
165
+ const declared = style.color;
166
+ const original = opaque(declared, background);
167
+ let target: { rgb: Rgb; css: string } | undefined;
168
+ if (declared === TEXT_SENTINEL) {
169
+ const rgb = authored ? (unknown ? { r: 0, g: 0, b: 0 } : blackOrWhite(background)) : ensureContrast(surface.text, background);
170
+ target = { rgb, css: formatColour(rgb) };
171
+ } else if (declared === LINK_SENTINEL) {
172
+ const rgb = authored ? (unknown ? DEFAULT_LINK : ensureContrast(DEFAULT_LINK, background)) : ensureContrast(surface.link, background);
173
+ target = { rgb, css: formatColour(rgb) };
174
+ } else if (original && !authored) {
175
+ // Authored text on the theme's surface: it stays if it reads, else it is adapted.
176
+ const readable = contrastRatio(original, background) >= MIN_TEXT_CONTRAST;
177
+ const rgb = readable ? original : adaptForeground(original, { ...surface, background });
178
+ target = { rgb, css: readable ? declared : formatColour(rgb) };
179
+ } else if (original) {
180
+ // Authored text on an authored background: as its author had it.
181
+ target = { rgb: original, css: declared };
182
+ }
183
+ let parentColour = context.parentColour;
184
+ if (target) {
185
+ // An element is written to unless inheritance already gives it its target: the parent must end with that colour, and an element
186
+ // that may carry a declaration of its own (an authored colour, a link - the sentinel is a rule on it - and the root) must not
187
+ // be declaring a different one. Text nobody coloured inherits and has nothing of its own to override.
188
+ const ownDeclaration = declared !== TEXT_SENTINEL || element === root;
189
+ if (!sameColour(target.rgb, context.parentColour) || (ownDeclaration && !sameColour(target.rgb, original ?? target.rgb))) {
190
+ writes.push({ element, colour: target.css });
191
+ }
192
+ if (original && Math.abs(luminance(target.rgb) - luminance(original)) > MATERIAL_LUMINANCE_SHIFT) {
193
+ result.material = true;
194
+ }
195
+ parentColour = target.rgb;
196
+ }
197
+ for (const child of Array.from(element.children).reverse()) {
198
+ stack.push({ element: child as HTMLElement, context: { background, authored, unknown, parentColour } });
199
+ }
200
+ }
201
+ // What each element had inline before, so the writes can be taken back (a message may carry its own inline colour).
202
+ const before = writes.map(({ element }) => ({ value: element.style.getPropertyValue("color"), priority: element.style.getPropertyPriority("color") }));
203
+ result.reapply = () => {
204
+ for (const { element, colour } of writes) {
205
+ element.style.setProperty("color", colour, "important");
206
+ }
207
+ };
208
+ result.revert = () => {
209
+ writes.forEach(({ element }, index) => {
210
+ if (before[index].value) {
211
+ element.style.setProperty("color", before[index].value, before[index].priority);
212
+ } else {
213
+ element.style.removeProperty("color");
214
+ }
215
+ });
216
+ };
217
+ result.reapply();
218
+ result.written = writes.length;
219
+ result.material ||= !result.authoredCanvas && luminance(surface.background) < LIGHT_SURFACE;
220
+ return result;
221
+ }
@@ -0,0 +1,90 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useState } from "react";
6
+ import type { ResolvedTheme } from "../../../appearance/resolvedTheme.js";
7
+ import { Rgb, Rgba, ThemeSurface, composite, formatColour, parseColour } from "./color.js";
8
+
9
+ /** The palette used where the page's own tokens cannot be read (no DOM, or a stylesheet without them): the app's built-in schemes. */
10
+ export const DEFAULT_SURFACES: Record<ResolvedTheme, ThemeSurface> = {
11
+ light: { background: { r: 255, g: 255, b: 255 }, text: { r: 28, g: 37, b: 38 }, link: { r: 20, g: 138, b: 128 }, dark: false },
12
+ dark: { background: { r: 27, g: 32, b: 34 }, text: { r: 238, g: 242, b: 243 }, link: { r: 45, g: 212, b: 191 }, dark: true },
13
+ };
14
+
15
+ /** The page's own CSS variables a message is adapted to. Read from the document, so a branding palette or a user's own Appearance colours are followed. */
16
+ const TOKENS = {
17
+ surface: "--rr-color-surface",
18
+ page: "--rr-color-bg",
19
+ text: "--rr-color-text",
20
+ link: "--rr-color-primary-dark",
21
+ } as const;
22
+
23
+ /** The colour a CSS custom property resolves to, by asking the browser what a probe element painted with it comes to (which also
24
+ * resolves `var()` chains and colour functions); `undefined` when it isn't defined or isn't a colour. */
25
+ function resolveToken(probe: HTMLElement, name: string): Rgba | undefined {
26
+ probe.style.color = `var(${name}, transparent)`;
27
+ const colour = parseColour(getComputedStyle(probe).color);
28
+ return colour && colour.a > 0 ? colour : undefined;
29
+ }
30
+
31
+ /**
32
+ * The theme a message is shown in, read from the page's own tokens: the surface behind the body composited to an opaque colour (a
33
+ * translucent surface over a photo would make text unreadable, so the body area is always painted with this opaque one), the text
34
+ * colour and the link colour. Falls back to the built-in scheme for anything the page doesn't define.
35
+ */
36
+ export function readThemeSurface(theme: ResolvedTheme): ThemeSurface {
37
+ const fallback = DEFAULT_SURFACES[theme];
38
+ if (typeof document === "undefined" || !document.body) {
39
+ return fallback;
40
+ }
41
+ const probe = document.createElement("span");
42
+ probe.style.display = "none";
43
+ document.body.appendChild(probe);
44
+ try {
45
+ const page = resolveToken(probe, TOKENS.page);
46
+ const surface = resolveToken(probe, TOKENS.surface);
47
+ const underlay: Rgb = page ? composite(page, fallback.background) : fallback.background;
48
+ const text = resolveToken(probe, TOKENS.text);
49
+ const link = resolveToken(probe, TOKENS.link);
50
+ return {
51
+ dark: fallback.dark,
52
+ background: surface ? composite(surface, underlay) : underlay,
53
+ text: text ? composite(text, underlay) : fallback.text,
54
+ link: link ? composite(link, underlay) : fallback.link,
55
+ };
56
+ } finally {
57
+ probe.remove();
58
+ }
59
+ }
60
+
61
+ /** A string that changes exactly when the surface does - what a frame is keyed on. */
62
+ export function surfaceKey(surface: ThemeSurface): string {
63
+ return [surface.background, surface.text, surface.link].map(formatColour).join("|") + (surface.dark ? "|dark" : "|light");
64
+ }
65
+
66
+ /**
67
+ * The theme surface for `theme`, kept current: re-read when the scheme changes and when the page's own colours are changed at runtime
68
+ * (Appearance sets `data-theme` on `<html>` and writes the user's colours into a `<style>` in the head). The identity only changes when a colour does.
69
+ */
70
+ export function useThemeSurface(theme: ResolvedTheme): ThemeSurface {
71
+ const [surface, setSurface] = useState(() => readThemeSurface(theme));
72
+ useEffect(() => {
73
+ const update = () =>
74
+ setSurface((previous) => {
75
+ const next = readThemeSurface(theme);
76
+ return surfaceKey(previous) === surfaceKey(next) ? previous : next;
77
+ });
78
+ update();
79
+ if (typeof MutationObserver === "undefined") {
80
+ return;
81
+ }
82
+ const observer = new MutationObserver(update);
83
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "class", "data-theme"] });
84
+ // The Appearance provider writes the user's own colours into a `<style>` element in the head, whose text is replaced when they change.
85
+ observer.observe(document.head, { childList: true, subtree: true, characterData: true, attributes: true });
86
+ return () => observer.disconnect();
87
+ }, [theme]);
88
+ // The render that first sees a new scheme must not use the previous one's colours (the effect above corrects the state after it).
89
+ return surface.dark === (theme === "dark") ? surface : readThemeSurface(theme);
90
+ }
@@ -0,0 +1,38 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useCallback, useState } from "react";
6
+
7
+ /** Messages the reader chose to see exactly as authored rather than adapted to the theme, for the life of the tab. */
8
+ const original = new Set<string>();
9
+
10
+ /** Whether the reader asked to see `uid` as authored. */
11
+ export function isViewedOriginal(uid: string): boolean {
12
+ return original.has(uid);
13
+ }
14
+
15
+ /** Forgets every choice (the tests). */
16
+ export function clearViewedOriginal(): void {
17
+ original.clear();
18
+ }
19
+
20
+ /**
21
+ * The per-message "View original" choice: `false` (follow the theme) until the reader flips it, then remembered for the session so
22
+ * collapsing and expanding a message, or coming back to it, keeps the choice.
23
+ */
24
+ export function useViewOriginal(uid: string): [boolean, (value: boolean) => void] {
25
+ const [value, setValue] = useState(() => isViewedOriginal(uid));
26
+ const update = useCallback(
27
+ (next: boolean) => {
28
+ if (next) {
29
+ original.add(uid);
30
+ } else {
31
+ original.delete(uid);
32
+ }
33
+ setValue(next);
34
+ },
35
+ [uid],
36
+ );
37
+ return [value, update];
38
+ }