@bobfrankston/rmfmail 1.0.467

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 (330) hide show
  1. package/README.md +429 -0
  2. package/bin/build-icon-ico.js +283 -0
  3. package/bin/lean-accounts.js +172 -0
  4. package/bin/mailx.js +1580 -0
  5. package/bin/mailx.js.map +1 -0
  6. package/bin/mailx.ts +1549 -0
  7. package/bin/postinstall.js +39 -0
  8. package/bin/postinstall.js.map +1 -0
  9. package/bin/postinstall.ts +41 -0
  10. package/bin/tsconfig.json +10 -0
  11. package/client/.gitattributes +10 -0
  12. package/client/.msger-window.json +1 -0
  13. package/client/android.html +226 -0
  14. package/client/app.js +3400 -0
  15. package/client/app.js.map +1 -0
  16. package/client/app.ts +3190 -0
  17. package/client/components/address-book.js +199 -0
  18. package/client/components/address-book.js.map +1 -0
  19. package/client/components/address-book.ts +204 -0
  20. package/client/components/alarms.js +299 -0
  21. package/client/components/alarms.js.map +1 -0
  22. package/client/components/alarms.ts +276 -0
  23. package/client/components/calendar-sidebar.js +508 -0
  24. package/client/components/calendar-sidebar.js.map +1 -0
  25. package/client/components/calendar-sidebar.ts +474 -0
  26. package/client/components/calendar.js +217 -0
  27. package/client/components/calendar.js.map +1 -0
  28. package/client/components/calendar.ts +211 -0
  29. package/client/components/context-menu.js +80 -0
  30. package/client/components/context-menu.js.map +1 -0
  31. package/client/components/context-menu.ts +95 -0
  32. package/client/components/folder-picker.js +119 -0
  33. package/client/components/folder-picker.js.map +1 -0
  34. package/client/components/folder-picker.ts +127 -0
  35. package/client/components/folder-tree.js +1093 -0
  36. package/client/components/folder-tree.js.map +1 -0
  37. package/client/components/folder-tree.ts +1069 -0
  38. package/client/components/message-list.js +1140 -0
  39. package/client/components/message-list.js.map +1 -0
  40. package/client/components/message-list.ts +1129 -0
  41. package/client/components/message-viewer.js +1291 -0
  42. package/client/components/message-viewer.js.map +1 -0
  43. package/client/components/message-viewer.ts +1257 -0
  44. package/client/components/outbox-view.js +104 -0
  45. package/client/components/outbox-view.js.map +1 -0
  46. package/client/components/outbox-view.ts +102 -0
  47. package/client/components/tasks.js +261 -0
  48. package/client/components/tasks.js.map +1 -0
  49. package/client/components/tasks.ts +234 -0
  50. package/client/compose/compose.css +560 -0
  51. package/client/compose/compose.html +52 -0
  52. package/client/compose/compose.js +1289 -0
  53. package/client/compose/compose.js.map +1 -0
  54. package/client/compose/compose.ts +1231 -0
  55. package/client/compose/editor.js +616 -0
  56. package/client/compose/editor.js.map +1 -0
  57. package/client/compose/editor.ts +599 -0
  58. package/client/compose/ghost-text.js +122 -0
  59. package/client/compose/ghost-text.js.map +1 -0
  60. package/client/compose/ghost-text.ts +140 -0
  61. package/client/favicon.svg +11 -0
  62. package/client/icon.ico +0 -0
  63. package/client/icon.png +0 -0
  64. package/client/index.html +230 -0
  65. package/client/lib/android-bootstrap.js +9 -0
  66. package/client/lib/android-bootstrap.js.map +1 -0
  67. package/client/lib/android-bootstrap.ts +9 -0
  68. package/client/lib/api-client.js +378 -0
  69. package/client/lib/api-client.js.map +1 -0
  70. package/client/lib/api-client.ts +439 -0
  71. package/client/lib/local-service.js +603 -0
  72. package/client/lib/local-service.js.map +1 -0
  73. package/client/lib/local-service.ts +646 -0
  74. package/client/lib/local-store.js +214 -0
  75. package/client/lib/local-store.js.map +1 -0
  76. package/client/lib/local-store.ts +283 -0
  77. package/client/lib/mailxapi.js +304 -0
  78. package/client/lib/message-state.js +132 -0
  79. package/client/lib/message-state.js.map +1 -0
  80. package/client/lib/message-state.ts +160 -0
  81. package/client/package.json +16 -0
  82. package/client/styles/components.css +2049 -0
  83. package/client/styles/layout.css +332 -0
  84. package/client/styles/variables.css +85 -0
  85. package/client/tsconfig.json +19 -0
  86. package/package.json +124 -0
  87. package/packages/mailx-api/.gitattributes +10 -0
  88. package/packages/mailx-api/index.d.ts +10 -0
  89. package/packages/mailx-api/index.d.ts.map +1 -0
  90. package/packages/mailx-api/index.js +322 -0
  91. package/packages/mailx-api/index.js.map +1 -0
  92. package/packages/mailx-api/index.ts +283 -0
  93. package/packages/mailx-api/package.json +27 -0
  94. package/packages/mailx-api/tsconfig.json +9 -0
  95. package/packages/mailx-api/tsconfig.tsbuildinfo +1 -0
  96. package/packages/mailx-compose/.gitattributes +10 -0
  97. package/packages/mailx-compose/index.d.ts +21 -0
  98. package/packages/mailx-compose/index.d.ts.map +1 -0
  99. package/packages/mailx-compose/index.js +61 -0
  100. package/packages/mailx-compose/index.js.map +1 -0
  101. package/packages/mailx-compose/index.ts +85 -0
  102. package/packages/mailx-compose/package.json +18 -0
  103. package/packages/mailx-compose/tsconfig.json +9 -0
  104. package/packages/mailx-core/index.d.ts +139 -0
  105. package/packages/mailx-core/index.d.ts.map +1 -0
  106. package/packages/mailx-core/index.js +403 -0
  107. package/packages/mailx-core/index.js.map +1 -0
  108. package/packages/mailx-core/index.ts +424 -0
  109. package/packages/mailx-core/ipc.d.ts +13 -0
  110. package/packages/mailx-core/ipc.d.ts.map +1 -0
  111. package/packages/mailx-core/ipc.js +56 -0
  112. package/packages/mailx-core/ipc.js.map +1 -0
  113. package/packages/mailx-core/ipc.ts +62 -0
  114. package/packages/mailx-core/package.json +18 -0
  115. package/packages/mailx-core/tsconfig.json +9 -0
  116. package/packages/mailx-host/.gitattributes +10 -0
  117. package/packages/mailx-host/index.d.ts +21 -0
  118. package/packages/mailx-host/index.d.ts.map +1 -0
  119. package/packages/mailx-host/index.js +29 -0
  120. package/packages/mailx-host/index.js.map +1 -0
  121. package/packages/mailx-host/index.ts +38 -0
  122. package/packages/mailx-host/package.json +23 -0
  123. package/packages/mailx-host/tsconfig.json +9 -0
  124. package/packages/mailx-host/types-shim.d.ts +14 -0
  125. package/packages/mailx-imap/.gitattributes +10 -0
  126. package/packages/mailx-imap/index.d.ts +442 -0
  127. package/packages/mailx-imap/index.d.ts.map +1 -0
  128. package/packages/mailx-imap/index.js +3684 -0
  129. package/packages/mailx-imap/index.js.map +1 -0
  130. package/packages/mailx-imap/index.ts +3652 -0
  131. package/packages/mailx-imap/package-lock.json +131 -0
  132. package/packages/mailx-imap/package.json +28 -0
  133. package/packages/mailx-imap/providers/gmail-api.d.ts +8 -0
  134. package/packages/mailx-imap/providers/gmail-api.d.ts.map +1 -0
  135. package/packages/mailx-imap/providers/gmail-api.js +8 -0
  136. package/packages/mailx-imap/providers/gmail-api.js.map +1 -0
  137. package/packages/mailx-imap/providers/gmail-api.ts +8 -0
  138. package/packages/mailx-imap/providers/outlook-api.ts +7 -0
  139. package/packages/mailx-imap/providers/types.d.ts +9 -0
  140. package/packages/mailx-imap/providers/types.d.ts.map +1 -0
  141. package/packages/mailx-imap/providers/types.js +9 -0
  142. package/packages/mailx-imap/providers/types.js.map +1 -0
  143. package/packages/mailx-imap/providers/types.ts +9 -0
  144. package/packages/mailx-imap/tsconfig.json +9 -0
  145. package/packages/mailx-imap/tsconfig.tsbuildinfo +1 -0
  146. package/packages/mailx-send/.gitattributes +10 -0
  147. package/packages/mailx-send/README.md +217 -0
  148. package/packages/mailx-send/cli-queue.d.ts +11 -0
  149. package/packages/mailx-send/cli-queue.d.ts.map +1 -0
  150. package/packages/mailx-send/cli-queue.js +59 -0
  151. package/packages/mailx-send/cli-queue.js.map +1 -0
  152. package/packages/mailx-send/cli-queue.ts +62 -0
  153. package/packages/mailx-send/cli-send.d.ts +17 -0
  154. package/packages/mailx-send/cli-send.d.ts.map +1 -0
  155. package/packages/mailx-send/cli-send.js +75 -0
  156. package/packages/mailx-send/cli-send.js.map +1 -0
  157. package/packages/mailx-send/cli-send.ts +83 -0
  158. package/packages/mailx-send/cli.d.ts +22 -0
  159. package/packages/mailx-send/cli.d.ts.map +1 -0
  160. package/packages/mailx-send/cli.js +115 -0
  161. package/packages/mailx-send/cli.js.map +1 -0
  162. package/packages/mailx-send/cli.ts +126 -0
  163. package/packages/mailx-send/index.d.ts +77 -0
  164. package/packages/mailx-send/index.d.ts.map +1 -0
  165. package/packages/mailx-send/index.js +264 -0
  166. package/packages/mailx-send/index.js.map +1 -0
  167. package/packages/mailx-send/index.ts +333 -0
  168. package/packages/mailx-send/mailsend/README.md +133 -0
  169. package/packages/mailx-send/mailsend/cli.d.ts +12 -0
  170. package/packages/mailx-send/mailsend/cli.d.ts.map +1 -0
  171. package/packages/mailx-send/mailsend/cli.js +70 -0
  172. package/packages/mailx-send/mailsend/cli.js.map +1 -0
  173. package/packages/mailx-send/mailsend/cli.ts +81 -0
  174. package/packages/mailx-send/mailsend/index.d.ts +77 -0
  175. package/packages/mailx-send/mailsend/index.d.ts.map +1 -0
  176. package/packages/mailx-send/mailsend/index.js +264 -0
  177. package/packages/mailx-send/mailsend/index.js.map +1 -0
  178. package/packages/mailx-send/mailsend/index.ts +333 -0
  179. package/packages/mailx-send/mailsend/package-lock.json +65 -0
  180. package/packages/mailx-send/mailsend/package.json +26 -0
  181. package/packages/mailx-send/mailsend/tsconfig.json +21 -0
  182. package/packages/mailx-send/package-lock.json +65 -0
  183. package/packages/mailx-send/package.json +37 -0
  184. package/packages/mailx-send/tsconfig.json +21 -0
  185. package/packages/mailx-server/.gitattributes +10 -0
  186. package/packages/mailx-server/index.d.ts +8 -0
  187. package/packages/mailx-server/index.d.ts.map +1 -0
  188. package/packages/mailx-server/index.js +397 -0
  189. package/packages/mailx-server/index.js.map +1 -0
  190. package/packages/mailx-server/index.ts +429 -0
  191. package/packages/mailx-server/package.json +28 -0
  192. package/packages/mailx-server/tsconfig.json +9 -0
  193. package/packages/mailx-server/tsconfig.tsbuildinfo +1 -0
  194. package/packages/mailx-service/google-sync.d.ts +96 -0
  195. package/packages/mailx-service/google-sync.d.ts.map +1 -0
  196. package/packages/mailx-service/google-sync.js +153 -0
  197. package/packages/mailx-service/google-sync.js.map +1 -0
  198. package/packages/mailx-service/google-sync.ts +238 -0
  199. package/packages/mailx-service/index.d.ts +366 -0
  200. package/packages/mailx-service/index.d.ts.map +1 -0
  201. package/packages/mailx-service/index.js +2589 -0
  202. package/packages/mailx-service/index.js.map +1 -0
  203. package/packages/mailx-service/index.ts +2512 -0
  204. package/packages/mailx-service/jsonrpc.d.ts +25 -0
  205. package/packages/mailx-service/jsonrpc.d.ts.map +1 -0
  206. package/packages/mailx-service/jsonrpc.js +238 -0
  207. package/packages/mailx-service/jsonrpc.js.map +1 -0
  208. package/packages/mailx-service/jsonrpc.ts +268 -0
  209. package/packages/mailx-service/package.json +22 -0
  210. package/packages/mailx-service/tsconfig.json +9 -0
  211. package/packages/mailx-settings/.gitattributes +10 -0
  212. package/packages/mailx-settings/cloud.d.ts +47 -0
  213. package/packages/mailx-settings/cloud.d.ts.map +1 -0
  214. package/packages/mailx-settings/cloud.js +408 -0
  215. package/packages/mailx-settings/cloud.js.map +1 -0
  216. package/packages/mailx-settings/cloud.ts +393 -0
  217. package/packages/mailx-settings/index.d.ts +140 -0
  218. package/packages/mailx-settings/index.d.ts.map +1 -0
  219. package/packages/mailx-settings/index.js +872 -0
  220. package/packages/mailx-settings/index.js.map +1 -0
  221. package/packages/mailx-settings/index.ts +908 -0
  222. package/packages/mailx-settings/package.json +20 -0
  223. package/packages/mailx-settings/tsconfig.json +9 -0
  224. package/packages/mailx-settings/tsconfig.tsbuildinfo +1 -0
  225. package/packages/mailx-store/.gitattributes +10 -0
  226. package/packages/mailx-store/db.d.ts +346 -0
  227. package/packages/mailx-store/db.d.ts.map +1 -0
  228. package/packages/mailx-store/db.js +1796 -0
  229. package/packages/mailx-store/db.js.map +1 -0
  230. package/packages/mailx-store/db.ts +2007 -0
  231. package/packages/mailx-store/file-store.d.ts +36 -0
  232. package/packages/mailx-store/file-store.d.ts.map +1 -0
  233. package/packages/mailx-store/file-store.js +80 -0
  234. package/packages/mailx-store/file-store.js.map +1 -0
  235. package/packages/mailx-store/file-store.ts +82 -0
  236. package/packages/mailx-store/index.d.ts +7 -0
  237. package/packages/mailx-store/index.d.ts.map +1 -0
  238. package/packages/mailx-store/index.js +7 -0
  239. package/packages/mailx-store/index.js.map +1 -0
  240. package/packages/mailx-store/index.ts +7 -0
  241. package/packages/mailx-store/package.json +19 -0
  242. package/packages/mailx-store/tsconfig.json +9 -0
  243. package/packages/mailx-store/tsconfig.tsbuildinfo +1 -0
  244. package/packages/mailx-store-web/android-bootstrap.d.ts +16 -0
  245. package/packages/mailx-store-web/android-bootstrap.d.ts.map +1 -0
  246. package/packages/mailx-store-web/android-bootstrap.js +1247 -0
  247. package/packages/mailx-store-web/android-bootstrap.js.map +1 -0
  248. package/packages/mailx-store-web/android-bootstrap.ts +1262 -0
  249. package/packages/mailx-store-web/db.d.ts +132 -0
  250. package/packages/mailx-store-web/db.d.ts.map +1 -0
  251. package/packages/mailx-store-web/db.js +642 -0
  252. package/packages/mailx-store-web/db.js.map +1 -0
  253. package/packages/mailx-store-web/db.ts +756 -0
  254. package/packages/mailx-store-web/gmail-api-web.d.ts +11 -0
  255. package/packages/mailx-store-web/gmail-api-web.d.ts.map +1 -0
  256. package/packages/mailx-store-web/gmail-api-web.js +11 -0
  257. package/packages/mailx-store-web/gmail-api-web.js.map +1 -0
  258. package/packages/mailx-store-web/gmail-api-web.ts +11 -0
  259. package/packages/mailx-store-web/imap-web-provider.d.ts +33 -0
  260. package/packages/mailx-store-web/imap-web-provider.d.ts.map +1 -0
  261. package/packages/mailx-store-web/imap-web-provider.js +140 -0
  262. package/packages/mailx-store-web/imap-web-provider.js.map +1 -0
  263. package/packages/mailx-store-web/imap-web-provider.ts +156 -0
  264. package/packages/mailx-store-web/index.d.ts +10 -0
  265. package/packages/mailx-store-web/index.d.ts.map +1 -0
  266. package/packages/mailx-store-web/index.js +10 -0
  267. package/packages/mailx-store-web/index.js.map +1 -0
  268. package/packages/mailx-store-web/index.ts +10 -0
  269. package/packages/mailx-store-web/main-thread-host.d.ts +15 -0
  270. package/packages/mailx-store-web/main-thread-host.d.ts.map +1 -0
  271. package/packages/mailx-store-web/main-thread-host.js +292 -0
  272. package/packages/mailx-store-web/main-thread-host.js.map +1 -0
  273. package/packages/mailx-store-web/main-thread-host.ts +322 -0
  274. package/packages/mailx-store-web/package.json +23 -0
  275. package/packages/mailx-store-web/provider-types.d.ts +7 -0
  276. package/packages/mailx-store-web/provider-types.d.ts.map +1 -0
  277. package/packages/mailx-store-web/provider-types.js +7 -0
  278. package/packages/mailx-store-web/provider-types.js.map +1 -0
  279. package/packages/mailx-store-web/provider-types.ts +7 -0
  280. package/packages/mailx-store-web/sql-wasm-esm.js +10 -0
  281. package/packages/mailx-store-web/sql.js.d.ts +29 -0
  282. package/packages/mailx-store-web/sync-manager.d.ts +68 -0
  283. package/packages/mailx-store-web/sync-manager.d.ts.map +1 -0
  284. package/packages/mailx-store-web/sync-manager.js +506 -0
  285. package/packages/mailx-store-web/sync-manager.js.map +1 -0
  286. package/packages/mailx-store-web/sync-manager.ts +508 -0
  287. package/packages/mailx-store-web/tsconfig.json +10 -0
  288. package/packages/mailx-store-web/web-jsonrpc.d.ts +20 -0
  289. package/packages/mailx-store-web/web-jsonrpc.d.ts.map +1 -0
  290. package/packages/mailx-store-web/web-jsonrpc.js +102 -0
  291. package/packages/mailx-store-web/web-jsonrpc.js.map +1 -0
  292. package/packages/mailx-store-web/web-jsonrpc.ts +116 -0
  293. package/packages/mailx-store-web/web-message-store.d.ts +16 -0
  294. package/packages/mailx-store-web/web-message-store.d.ts.map +1 -0
  295. package/packages/mailx-store-web/web-message-store.js +89 -0
  296. package/packages/mailx-store-web/web-message-store.js.map +1 -0
  297. package/packages/mailx-store-web/web-message-store.ts +97 -0
  298. package/packages/mailx-store-web/web-service.d.ts +111 -0
  299. package/packages/mailx-store-web/web-service.d.ts.map +1 -0
  300. package/packages/mailx-store-web/web-service.js +542 -0
  301. package/packages/mailx-store-web/web-service.js.map +1 -0
  302. package/packages/mailx-store-web/web-service.ts +616 -0
  303. package/packages/mailx-store-web/web-settings.d.ts +83 -0
  304. package/packages/mailx-store-web/web-settings.d.ts.map +1 -0
  305. package/packages/mailx-store-web/web-settings.js +491 -0
  306. package/packages/mailx-store-web/web-settings.js.map +1 -0
  307. package/packages/mailx-store-web/web-settings.ts +522 -0
  308. package/packages/mailx-store-web/worker-bundle.js +6838 -0
  309. package/packages/mailx-store-web/worker-entry.d.ts +8 -0
  310. package/packages/mailx-store-web/worker-entry.d.ts.map +1 -0
  311. package/packages/mailx-store-web/worker-entry.js +187 -0
  312. package/packages/mailx-store-web/worker-entry.js.map +1 -0
  313. package/packages/mailx-store-web/worker-entry.ts +215 -0
  314. package/packages/mailx-store-web/worker-tcp-transport.d.ts +28 -0
  315. package/packages/mailx-store-web/worker-tcp-transport.d.ts.map +1 -0
  316. package/packages/mailx-store-web/worker-tcp-transport.js +98 -0
  317. package/packages/mailx-store-web/worker-tcp-transport.js.map +1 -0
  318. package/packages/mailx-store-web/worker-tcp-transport.ts +101 -0
  319. package/packages/mailx-types/.gitattributes +10 -0
  320. package/packages/mailx-types/index.d.ts +319 -0
  321. package/packages/mailx-types/index.d.ts.map +1 -0
  322. package/packages/mailx-types/index.js +243 -0
  323. package/packages/mailx-types/index.js.map +1 -0
  324. package/packages/mailx-types/index.ts +498 -0
  325. package/packages/mailx-types/package.json +16 -0
  326. package/packages/mailx-types/tsconfig.json +9 -0
  327. package/packages/mailx-types/tsconfig.tsbuildinfo +1 -0
  328. package/todo.json +13 -0
  329. package/tsconfig.base.json +31 -0
  330. package/tsconfig.json +9 -0
@@ -0,0 +1,1291 @@
1
+ /**
2
+ * Message viewer component -- displays full message in sandboxed iframe.
3
+ * Subscribes to message-state: clears when selected becomes null.
4
+ */
5
+ import { getMessage, updateFlags, allowRemoteContent, flagSenderOrDomain, getAttachment, addContact, listContacts, upsertContact, unsubscribeOneClick, addPreferredContact } from "../lib/api-client.js";
6
+ import { showContextMenu } from "./context-menu.js";
7
+ import * as state from "../lib/message-state.js";
8
+ /** Currently displayed message (for reply/forward) */
9
+ let currentMessage = null;
10
+ let currentAccountId = "";
11
+ let showMessageGeneration = 0; // Cancel stale fetches
12
+ let retryCount = 0;
13
+ export function getCurrentMessage() {
14
+ if (!currentMessage)
15
+ return null;
16
+ return { accountId: currentAccountId, message: currentMessage };
17
+ }
18
+ /** Initialize viewer — subscribe to state changes */
19
+ export function initViewer() {
20
+ state.subscribe((change) => {
21
+ if (change === "removed") {
22
+ // Message was deleted/moved — show auto-selected next, or clear
23
+ const sel = state.getSelected();
24
+ if (!sel) {
25
+ clearViewer();
26
+ }
27
+ else if (sel.uid !== currentMessage?.uid || sel.accountId !== currentAccountId) {
28
+ showMessage(sel.accountId, sel.uid, sel.folderId);
29
+ }
30
+ }
31
+ else if (change === "selected") {
32
+ // Explicit deselect (folder switch, clearViewer)
33
+ if (!state.getSelected()) {
34
+ clearViewer();
35
+ }
36
+ }
37
+ else if (change === "messages") {
38
+ // List was replaced (search, folder switch, sync reload). If the
39
+ // currently-displayed message is no longer in the list, clear the
40
+ // viewer — otherwise the user sees a preview that doesn't match
41
+ // any visible row. (The "search-clears-preview" bug class.)
42
+ // setMessages already deselects in this case; we just need to
43
+ // notice and clear here since the viewer ignored "messages" before.
44
+ if (currentMessage && !state.getSelected()) {
45
+ clearViewer();
46
+ }
47
+ }
48
+ });
49
+ }
50
+ // Zoom is persisted across messages via localStorage
51
+ const ZOOM_KEY = "mailx-preview-zoom";
52
+ const ZOOM_MIN = 0.5;
53
+ const ZOOM_MAX = 3.0;
54
+ const ZOOM_STEP = 0.1;
55
+ let previewZoom = clampZoom(parseFloat(localStorage.getItem(ZOOM_KEY) || "1"));
56
+ function clampZoom(z) {
57
+ if (!Number.isFinite(z) || z <= 0)
58
+ return 1;
59
+ return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.round(z * 100) / 100));
60
+ }
61
+ function applyZoom(doc) {
62
+ // Zoom lives on <html>, not <body>, because WebView2/Chromium's scroll
63
+ // container is the root element — body.style.zoom leaves the scroll
64
+ // container out of sync with the zoomed content, breaking scrollbar
65
+ // display and wheel scrolling. documentElement.style.zoom keeps them
66
+ // aligned so the iframe scrolls normally at any zoom level.
67
+ if (doc.documentElement)
68
+ doc.documentElement.style.zoom = String(previewZoom);
69
+ }
70
+ function setZoom(z, doc) {
71
+ previewZoom = clampZoom(z);
72
+ localStorage.setItem(ZOOM_KEY, String(previewZoom));
73
+ applyZoom(doc);
74
+ }
75
+ /** Install preview iframe controls: key forwarding to parent, Ctrl+wheel zoom,
76
+ * keyboard zoom shortcuts (Ctrl+= / Ctrl+- / Ctrl+0), and the right-click menu. */
77
+ /** Run AI translate on `text` and show result in a small modal. Disabled
78
+ * by default — user enables via Settings (translateEnabled in
79
+ * AutocompleteSettings). When disabled, the modal explains how to enable. */
80
+ async function translateAndShow(text) {
81
+ if (!text.trim())
82
+ return;
83
+ const status = document.getElementById("status-sync");
84
+ if (status)
85
+ status.textContent = "Translating…";
86
+ const overlay = document.createElement("div");
87
+ overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:10000;display:flex;align-items:center;justify-content:center;";
88
+ const modal = document.createElement("div");
89
+ modal.style.cssText = "background:var(--bg, #fff);color:var(--fg, #000);border:1px solid var(--border, #ccc);border-radius:6px;width:560px;max-width:90vw;max-height:70vh;display:flex;flex-direction:column;box-shadow:0 4px 24px rgba(0,0,0,0.3);";
90
+ const header = document.createElement("div");
91
+ header.style.cssText = "padding:10px 14px;border-bottom:1px solid var(--border, #ddd);font-weight:600;display:flex;justify-content:space-between;align-items:center;";
92
+ header.innerHTML = `<span>Translation</span><button style="cursor:pointer;border:0;background:transparent;font-size:16px;" aria-label="Close">×</button>`;
93
+ const body = document.createElement("div");
94
+ body.style.cssText = "flex:1;overflow:auto;padding:12px 14px;white-space:pre-wrap;font-size:13px;line-height:1.45;";
95
+ body.textContent = "Working…";
96
+ modal.appendChild(header);
97
+ modal.appendChild(body);
98
+ overlay.appendChild(modal);
99
+ document.body.appendChild(overlay);
100
+ const close = () => overlay.remove();
101
+ header.querySelector("button")?.addEventListener("click", close);
102
+ overlay.addEventListener("click", (e) => { if (e.target === overlay)
103
+ close(); });
104
+ document.addEventListener("keydown", function onKey(e) {
105
+ if (e.key === "Escape") {
106
+ document.removeEventListener("keydown", onKey);
107
+ close();
108
+ }
109
+ });
110
+ try {
111
+ const { aiTransform } = await import("../lib/api-client.js");
112
+ const r = await aiTransform({ action: "translate", text, targetLang: "en" });
113
+ if (r.text) {
114
+ body.textContent = r.text;
115
+ if (status)
116
+ status.textContent = "";
117
+ }
118
+ else {
119
+ body.innerHTML = `<div style="color:var(--muted, #888);">No result.</div>` +
120
+ `<div style="margin-top:8px;font-size:12px;color:var(--muted, #888);">${r.reason || ""}</div>` +
121
+ `<div style="margin-top:14px;font-size:12px;">Enable AI translate in Settings → AI features (off by default).</div>`;
122
+ if (status)
123
+ status.textContent = `Translate: ${r.reason || "no result"}`;
124
+ }
125
+ }
126
+ catch (err) {
127
+ body.textContent = `Error: ${err?.message || String(err)}`;
128
+ if (status)
129
+ status.textContent = `Translate error: ${err?.message || ""}`;
130
+ }
131
+ }
132
+ function installPreviewControls(iframe) {
133
+ const attach = () => {
134
+ const doc = iframe.contentDocument;
135
+ if (!doc)
136
+ return;
137
+ applyZoom(doc);
138
+ doc.addEventListener("keydown", (e) => {
139
+ const target = e.target;
140
+ if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)))
141
+ return;
142
+ // Zoom is iframe-local — handle here, don't forward.
143
+ if (e.ctrlKey && (e.key === "=" || e.key === "+")) {
144
+ e.preventDefault();
145
+ setZoom(previewZoom + ZOOM_STEP, doc);
146
+ return;
147
+ }
148
+ if (e.ctrlKey && e.key === "-") {
149
+ e.preventDefault();
150
+ setZoom(previewZoom - ZOOM_STEP, doc);
151
+ return;
152
+ }
153
+ if (e.ctrlKey && e.key === "0") {
154
+ e.preventDefault();
155
+ setZoom(1, doc);
156
+ return;
157
+ }
158
+ // Forward EVERY keydown to the parent — no duplicated hotkey list.
159
+ // If the parent's handler calls preventDefault (because it owns the
160
+ // shortcut), dispatchEvent returns false, and we preventDefault on
161
+ // the iframe side too so the browser doesn't ALSO act on it
162
+ // (Ctrl+N otherwise pops a new browser window in some hosts).
163
+ // Single source of truth = app.ts hotkey handlers. Plain typing in
164
+ // the email body — letters, etc. — propagates with no parent
165
+ // handler matching, so dispatchEvent returns true and the iframe
166
+ // event is left alone.
167
+ const synth = new KeyboardEvent("keydown", {
168
+ key: e.key, code: e.code,
169
+ ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,
170
+ bubbles: true, cancelable: true,
171
+ });
172
+ const allowDefault = document.dispatchEvent(synth);
173
+ if (!allowDefault)
174
+ e.preventDefault();
175
+ });
176
+ doc.addEventListener("wheel", (e) => {
177
+ if (!e.ctrlKey)
178
+ return;
179
+ e.preventDefault();
180
+ setZoom(previewZoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP), doc);
181
+ }, { passive: false });
182
+ // Link interception lives in the iframe's own inline <script> (see
183
+ // wrapHtmlBody). That script runs under a CSP nonce so email-body
184
+ // scripts stay blocked while ours forwards taps to the parent frame.
185
+ doc.addEventListener("contextmenu", (e) => {
186
+ e.preventDefault();
187
+ const me = e;
188
+ const rect = iframe.getBoundingClientRect();
189
+ const x = rect.left + me.clientX;
190
+ const y = rect.top + me.clientY;
191
+ const pct = Math.round(previewZoom * 100);
192
+ const sel = doc.defaultView?.getSelection();
193
+ const selectedText = sel?.toString().trim() || "";
194
+ const runSearch = (query) => {
195
+ const input = document.getElementById("search-input");
196
+ if (!input)
197
+ return;
198
+ input.value = query;
199
+ // Trigger the existing search path — Enter keydown hits the
200
+ // immediate branch in app.ts's handler.
201
+ input.dispatchEvent(new Event("input"));
202
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
203
+ input.focus();
204
+ };
205
+ const items = [
206
+ { label: "Copy", action: () => doc.execCommand("copy") },
207
+ { label: "Select all", action: () => {
208
+ const s = doc.defaultView?.getSelection();
209
+ if (!s)
210
+ return;
211
+ const range = doc.createRange();
212
+ range.selectNodeContents(doc.body);
213
+ s.removeAllRanges();
214
+ s.addRange(range);
215
+ } },
216
+ ];
217
+ if (selectedText) {
218
+ items.push({ label: "", action: () => { }, separator: true },
219
+ // Truncate long selections in the label so the menu doesn't
220
+ // blow out; full string is what we search for.
221
+ { label: `Search messages for "${selectedText.length > 40 ? selectedText.slice(0, 40) + "…" : selectedText}"`,
222
+ action: () => runSearch(selectedText) }, {
223
+ label: "Copy as quoted (> prefix)",
224
+ action: async () => {
225
+ // Prefix each line with "> " (RFC 3676 reply-quote).
226
+ // Useful when pasting a snippet into a compose window
227
+ // without the usual full-message blockquote wrapping.
228
+ const quoted = selectedText.split(/\r?\n/).map(l => "> " + l).join("\n");
229
+ try {
230
+ await navigator.clipboard.writeText(quoted);
231
+ }
232
+ catch { /* */ }
233
+ },
234
+ });
235
+ }
236
+ const senderAddr = currentMessage?.from?.address || "";
237
+ if (senderAddr) {
238
+ items.push({
239
+ label: `Search messages from ${senderAddr}`,
240
+ action: () => runSearch(`from:${senderAddr}`),
241
+ });
242
+ }
243
+ items.push({ label: "", action: () => { }, separator: true }, { label: selectedText ? "Translate selection" : "Translate message",
244
+ action: () => translateAndShow(selectedText || (doc.body?.innerText || "")) }, { label: "", action: () => { }, separator: true }, { label: "Zoom in", action: () => setZoom(previewZoom + ZOOM_STEP, doc) }, { label: "Zoom out", action: () => setZoom(previewZoom - ZOOM_STEP, doc) }, { label: `Reset zoom (${pct}%)`, action: () => setZoom(1, doc) });
245
+ showContextMenu(x, y, items);
246
+ });
247
+ };
248
+ if (iframe.contentDocument?.readyState === "complete")
249
+ attach();
250
+ else
251
+ iframe.addEventListener("load", attach, { once: true });
252
+ }
253
+ function clearViewer() {
254
+ currentMessage = null;
255
+ currentAccountId = "";
256
+ showMessageGeneration++;
257
+ const headerEl = document.getElementById("mv-header");
258
+ const bodyEl = document.getElementById("mv-body");
259
+ const attEl = document.getElementById("mv-attachments");
260
+ if (bodyEl)
261
+ bodyEl.innerHTML = `<div class="mv-empty">Select a message to read</div>`;
262
+ if (headerEl)
263
+ headerEl.hidden = true;
264
+ if (attEl)
265
+ attEl.hidden = true;
266
+ }
267
+ export async function showMessage(accountId, uid, folderId, specialUse, isRetry = false) {
268
+ const gen = ++showMessageGeneration;
269
+ if (!isRetry)
270
+ retryCount = 0;
271
+ const headerEl = document.getElementById("mv-header");
272
+ const bodyEl = document.getElementById("mv-body");
273
+ const attEl = document.getElementById("mv-attachments");
274
+ // Envelope-first render: the row the user just clicked already has the
275
+ // subject / from / to / cc / date / preview in the message-state. Use
276
+ // that to populate the header + a snippet placeholder IMMEDIATELY so
277
+ // tapping a message never shows just "Fetching message body..." with
278
+ // nothing actionable. The full getMessage() call (which might block on
279
+ // a slow IMAP body fetch) only fills in the body and attachments.
280
+ const cached = state.getSelected();
281
+ if (cached && cached.uid === uid && (cached.accountId || accountId) === accountId) {
282
+ try {
283
+ renderHeaderFromEnvelope(headerEl, cached);
284
+ }
285
+ catch { /* */ }
286
+ bodyEl.innerHTML = `<div class="mv-empty">Fetching message body…<br><br><span style="color:var(--color-text-muted);font-size:0.9em">${escapeHtml(cached.preview || "")}</span></div>`;
287
+ }
288
+ else {
289
+ bodyEl.innerHTML = `<div class="mv-empty">Fetching message body...</div>`;
290
+ headerEl.hidden = true;
291
+ }
292
+ attEl.hidden = true;
293
+ try {
294
+ const msg = await getMessage(accountId, uid, false, folderId);
295
+ // Stale response — a newer showMessage was called while we were fetching
296
+ if (gen !== showMessageGeneration)
297
+ return;
298
+ currentMessage = msg;
299
+ currentAccountId = accountId;
300
+ // Mark as read — gated by user prefs:
301
+ // - mailx-automark-read (default "true"): if "false", never auto-mark
302
+ // - mailx-automark-delay (default "2"): seconds to wait before
303
+ // marking. Lets the user click through messages quickly without
304
+ // marking ones they didn't actually read. The timer is tied to
305
+ // showMessageGeneration; navigating to another message advances
306
+ // the generation and cancels the pending mark.
307
+ if (!msg.flags.includes("\\Seen")) {
308
+ let enabled = true;
309
+ let delaySec = 2;
310
+ try {
311
+ enabled = localStorage.getItem("mailx-automark-read") !== "false";
312
+ const d = parseFloat(localStorage.getItem("mailx-automark-delay") || "2");
313
+ if (Number.isFinite(d) && d >= 0)
314
+ delaySec = d;
315
+ }
316
+ catch { /* private mode — defaults */ }
317
+ if (enabled) {
318
+ const captureGen = gen;
319
+ const newFlags = [...msg.flags, "\\Seen"];
320
+ if (delaySec === 0) {
321
+ updateFlags(accountId, uid, newFlags);
322
+ }
323
+ else {
324
+ setTimeout(() => {
325
+ // Stale: user moved on before the timer fired.
326
+ if (captureGen !== showMessageGeneration)
327
+ return;
328
+ updateFlags(accountId, uid, newFlags);
329
+ // Reflect locally so the list row stops looking unread.
330
+ msg.flags = newFlags;
331
+ try {
332
+ state.updateMessageFlags(accountId, uid, newFlags);
333
+ }
334
+ catch { /* */ }
335
+ }, delaySec * 1000);
336
+ }
337
+ }
338
+ }
339
+ // Header
340
+ headerEl.hidden = false;
341
+ const fromEl = headerEl.querySelector(".mv-from");
342
+ const toEl = headerEl.querySelector(".mv-to");
343
+ fromEl.textContent = formatAddr(msg.from);
344
+ let toLine = `To: ${msg.to.map(formatAddr).join(", ")}`;
345
+ if (msg.cc?.length)
346
+ toLine += ` Cc: ${msg.cc.map(formatAddr).join(", ")}`;
347
+ // Always-visible Delivered-To line — shown when present and not already
348
+ // covered by the To/Cc list. Critical for accounts with multiple aliases
349
+ // where you need to see which one received the message at a glance.
350
+ const toAddrs = (msg.to || []).map((a) => a.address.toLowerCase());
351
+ const ccAddrs = (msg.cc || []).map((a) => a.address.toLowerCase());
352
+ const dt = (msg.deliveredTo || "").toLowerCase();
353
+ if (msg.deliveredTo && !toAddrs.includes(dt) && !ccAddrs.includes(dt)) {
354
+ toLine += ` Delivered-To: ${msg.deliveredTo}`;
355
+ }
356
+ toEl.textContent = toLine;
357
+ headerEl.querySelector(".mv-subject").textContent = msg.subject;
358
+ document.dispatchEvent(new CustomEvent("mailx-message-shown", { detail: { accountId } }));
359
+ // Right-click on email addresses in header: copy name, copy address,
360
+ // copy both, add to contacts, plus reply actions for the whole message.
361
+ for (const el of [fromEl, toEl]) {
362
+ el.addEventListener("contextmenu", (e) => {
363
+ e.preventDefault();
364
+ const me = e;
365
+ const items = [];
366
+ const addrs = el === fromEl ? [msg.from] : [...(msg.to || []), ...(msg.cc || [])];
367
+ for (const addr of addrs) {
368
+ if (!addr?.address)
369
+ continue;
370
+ const name = addr.name || "";
371
+ const both = name ? `${name} <${addr.address}>` : addr.address;
372
+ if (name) {
373
+ items.push({ label: `Copy name: ${name}`, action: () => navigator.clipboard.writeText(name) });
374
+ }
375
+ items.push({ label: `Copy address: ${addr.address}`, action: () => navigator.clipboard.writeText(addr.address) });
376
+ if (name) {
377
+ items.push({ label: `Copy both: ${both}`, action: () => navigator.clipboard.writeText(both) });
378
+ }
379
+ items.push({
380
+ label: `Add to contacts: ${addr.address}`,
381
+ action: async () => {
382
+ await showAddContactDialog(name, addr.address);
383
+ },
384
+ });
385
+ // "Add to preferred" — separate path: writes to
386
+ // contacts.jsonc#preferred[] with an optional source tag.
387
+ // Distinct from "Add to contacts" which goes into the DB +
388
+ // pushes to Google. Preferred entries rank higher in
389
+ // autocomplete and survive Google sync's churn.
390
+ items.push({
391
+ label: `Add to preferred: ${addr.address}`,
392
+ action: async () => {
393
+ const tag = prompt("Tag (e.g. work, family, vendor) — leave blank for default:", "");
394
+ if (tag === null)
395
+ return; // user cancelled
396
+ try {
397
+ await addPreferredContact({ name, email: addr.address, source: tag.trim() || undefined });
398
+ const status = document.getElementById("status-sync");
399
+ if (status)
400
+ status.textContent = `Added to preferred: ${addr.address}${tag ? ` [${tag}]` : ""}`;
401
+ }
402
+ catch (e) {
403
+ alert(`Couldn't add to preferred: ${e?.message || e}`);
404
+ }
405
+ },
406
+ });
407
+ items.push({ label: "", action: () => { }, separator: true });
408
+ }
409
+ items.push({ label: "Reply", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "reply" } })) });
410
+ items.push({ label: "Reply All", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "replyAll" } })) });
411
+ items.push({ label: "Forward", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "forward" } })) });
412
+ showContextMenu(me.clientX, me.clientY, items);
413
+ });
414
+ }
415
+ headerEl.querySelector(".mv-date").textContent = new Date(msg.date).toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
416
+ // Unsubscribe button (upper right of header).
417
+ // - One-Click (RFC 8058): POST via service; show result in status bar.
418
+ // - Plain HTTPS URL: open externally for user confirmation.
419
+ // - mailto: open a pre-filled compose so the reply uses the right account.
420
+ const unsubBtn = document.getElementById("mv-unsubscribe");
421
+ const httpUrl = msg.listUnsubscribeHttp || "";
422
+ const mailUrl = msg.listUnsubscribeMail || "";
423
+ const oneClick = !!msg.listUnsubscribeOneClick;
424
+ const anyUrl = httpUrl || mailUrl || msg.listUnsubscribe || "";
425
+ if (unsubBtn) {
426
+ if (anyUrl) {
427
+ unsubBtn.hidden = false;
428
+ unsubBtn.textContent = "Unsubscribe";
429
+ unsubBtn.removeAttribute("title");
430
+ unsubBtn.href = httpUrl || mailUrl || "#";
431
+ unsubBtn.onclick = async (e) => {
432
+ e.preventDefault();
433
+ const status = document.getElementById("status-sync");
434
+ if (httpUrl && oneClick) {
435
+ if (status)
436
+ status.textContent = "Unsubscribing…";
437
+ try {
438
+ const result = await unsubscribeOneClick(httpUrl);
439
+ if (status) {
440
+ status.textContent = result.ok
441
+ ? `Unsubscribed (${result.status} ${result.statusText})`
442
+ : `Unsubscribe failed: ${result.status} ${result.statusText}`;
443
+ }
444
+ }
445
+ catch (err) {
446
+ if (status)
447
+ status.textContent = `Unsubscribe error: ${err?.message || err}`;
448
+ }
449
+ return;
450
+ }
451
+ if (httpUrl) {
452
+ const api = window.mailxapi;
453
+ if (api?.openExternal)
454
+ api.openExternal(httpUrl);
455
+ else
456
+ window.open(httpUrl, "_blank", "noopener,noreferrer");
457
+ return;
458
+ }
459
+ if (mailUrl) {
460
+ const m = mailUrl.match(/^mailto:([^?]*)(?:\?(.*))?$/i);
461
+ const to = m?.[1] ? decodeURIComponent(m[1]) : "";
462
+ const qs = new URLSearchParams(m?.[2] || "");
463
+ const subject = qs.get("subject") || "Unsubscribe";
464
+ const body = qs.get("body") || "";
465
+ const init = {
466
+ mode: "new",
467
+ accountId: currentAccountId,
468
+ to: to ? [{ name: "", address: to }] : [],
469
+ cc: [],
470
+ subject,
471
+ bodyHtml: body ? `<p>${body}</p>` : "",
472
+ inReplyTo: "",
473
+ references: [],
474
+ accounts: [],
475
+ };
476
+ sessionStorage.setItem("composeInit", JSON.stringify(init));
477
+ document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "new" } }));
478
+ }
479
+ };
480
+ }
481
+ else {
482
+ unsubBtn.hidden = true;
483
+ }
484
+ }
485
+ // View Thread button — opens the thread popup from the message list
486
+ // so the user can see all messages in the conversation. Works from
487
+ // the viewer even when thread-grouping is off.
488
+ const threadBtn = document.getElementById("mv-view-thread");
489
+ if (threadBtn) {
490
+ const tid = msg.threadId || "";
491
+ if (tid) {
492
+ threadBtn.hidden = false;
493
+ threadBtn.onclick = async () => {
494
+ const { showThreadPopup } = await import("./message-list.js");
495
+ await showThreadPopup(threadBtn, { accountId, threadId: tid });
496
+ };
497
+ }
498
+ else {
499
+ threadBtn.hidden = true;
500
+ }
501
+ }
502
+ // View Source button — shows .eml file path
503
+ const srcBtn = document.getElementById("mv-view-source");
504
+ if (srcBtn) {
505
+ if (msg.emlPath) {
506
+ srcBtn.hidden = false;
507
+ srcBtn.title = msg.emlPath;
508
+ srcBtn.onclick = () => {
509
+ // Copy path to clipboard and show in status bar
510
+ navigator.clipboard.writeText(msg.emlPath).then(() => {
511
+ const status = document.getElementById("status-sync");
512
+ if (status)
513
+ status.textContent = `Path copied: ${msg.emlPath}`;
514
+ }).catch(() => {
515
+ prompt("EML file path:", msg.emlPath);
516
+ });
517
+ };
518
+ }
519
+ else {
520
+ srcBtn.hidden = true;
521
+ }
522
+ }
523
+ // Edit Draft / Send from Outbox button
524
+ const editBtn = document.getElementById("mv-edit-draft");
525
+ if (editBtn) {
526
+ const isDraft = specialUse === "drafts" || specialUse === "outbox";
527
+ if (isDraft) {
528
+ editBtn.hidden = false;
529
+ editBtn.textContent = specialUse === "outbox" ? "Edit & Send" : "Edit Draft";
530
+ editBtn.onclick = () => {
531
+ // Open compose window pre-filled with this draft
532
+ const init = {
533
+ mode: "draft",
534
+ accountId,
535
+ to: msg.to || [],
536
+ cc: msg.cc || [],
537
+ subject: msg.subject || "",
538
+ bodyHtml: msg.bodyHtml || "",
539
+ inReplyTo: msg.inReplyTo || "",
540
+ references: msg.references || [],
541
+ accounts: [],
542
+ draftUid: msg.uid,
543
+ draftFolderId: msg.folderId,
544
+ };
545
+ sessionStorage.setItem("composeInit", JSON.stringify(init));
546
+ // Trigger compose overlay via custom event (app.ts handles it)
547
+ document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "draft" } }));
548
+ };
549
+ }
550
+ else {
551
+ editBtn.hidden = true;
552
+ }
553
+ }
554
+ // Details toggle — show extra headers (Delivered-To, Return-Path, Message-ID, etc.)
555
+ const detailsEl = document.getElementById("mv-details");
556
+ const detailsBtn = document.getElementById("mv-toggle-details");
557
+ if (detailsEl && detailsBtn) {
558
+ // Q56: every row gets a Copy button so paths / IDs can be pasted
559
+ // into accounts.jsonc hints or bug reports.
560
+ const row = (label, value) => `<div class="mv-details-row"><span class="mv-details-label">${label}:</span> <span class="mv-details-value">${escapeText(value)}</span> <button type="button" class="mv-details-copy" data-copy="${escapeText(value).replace(/"/g, "&quot;")}" title="Copy">⧉</button></div>`;
561
+ const lines = [];
562
+ if (msg.deliveredTo)
563
+ lines.push(row("Delivered-To", msg.deliveredTo));
564
+ if (msg.returnPath)
565
+ lines.push(row("Return-Path", msg.returnPath));
566
+ if (msg.messageId)
567
+ lines.push(row("Message-ID", msg.messageId));
568
+ if (msg.listUnsubscribe)
569
+ lines.push(row("Unsubscribe", msg.listUnsubscribe));
570
+ if (msg.emlPath)
571
+ lines.push(row("EML file", msg.emlPath));
572
+ lines.push(row("Account", accountId));
573
+ lines.push(row("UID", `${msg.uid} (folder ${msg.folderId})`));
574
+ detailsEl.innerHTML = lines.join("");
575
+ detailsEl.hidden = true;
576
+ detailsBtn.textContent = "Details";
577
+ detailsBtn.onclick = () => {
578
+ detailsEl.hidden = !detailsEl.hidden;
579
+ detailsBtn.textContent = detailsEl.hidden ? "Details" : "\u2713 Details";
580
+ };
581
+ // Wire copy buttons.
582
+ detailsEl.querySelectorAll(".mv-details-copy").forEach(btn => {
583
+ btn.addEventListener("click", async (e) => {
584
+ e.stopPropagation();
585
+ const val = btn.dataset.copy || "";
586
+ try {
587
+ await navigator.clipboard.writeText(val);
588
+ btn.textContent = "✓";
589
+ setTimeout(() => { btn.textContent = "⧉"; }, 1500);
590
+ }
591
+ catch {
592
+ prompt("Copy:", val);
593
+ }
594
+ });
595
+ });
596
+ }
597
+ // Remote content banner (collapsible dropdown with sender/recipient details)
598
+ bodyEl.innerHTML = "";
599
+ if (msg.hasRemoteContent) {
600
+ const senderAddr = msg.from?.address || "";
601
+ const senderName = msg.from?.name || "";
602
+ const senderDomain = senderAddr.split("@")[1] || "";
603
+ const deliveredTo = msg.deliveredTo || "";
604
+ const toAddr = msg.to?.[0]?.address || "";
605
+ const returnPath = msg.returnPath || "";
606
+ const isFlagged = !!msg.isFlagged;
607
+ const reputation = msg.reputation;
608
+ const reputationFlagged = !!(reputation && reputation.flagged);
609
+ const reputationText = reputationFlagged
610
+ ? `⚠ ${reputation.listedCount} of ${reputation.checkedCount} reputation services flag <strong>${escapeText(senderDomain)}</strong> as <strong>${escapeText(reputation.verdict)}</strong> (${escapeText(reputation.sources.map(s => s.service).join(", "))})`
611
+ : "";
612
+ const banner = document.createElement("div");
613
+ banner.className = "mv-remote-banner"
614
+ + (isFlagged ? " mv-remote-banner-flagged" : "")
615
+ + (reputationFlagged ? " mv-remote-banner-reputation" : "");
616
+ banner.innerHTML =
617
+ (isFlagged
618
+ ? `<div class="mv-rb-flagged">⚠ FLAGGED: this sender or domain is on your flagged list</div>`
619
+ : "") +
620
+ (reputationFlagged
621
+ ? `<div class="mv-rb-reputation">${reputationText}</div>`
622
+ : "") +
623
+ `<div class="mv-rb-summary">` +
624
+ `<span class="mv-rb-toggle">&#x25B8;</span>` +
625
+ `<span>Remote content blocked</span>` +
626
+ `<span class="mv-rb-buttons">` +
627
+ `<button id="btn-load-remote">Load once</button>` +
628
+ `<button id="btn-allow-sender" title="${escapeText(senderAddr)}">Always: ${escapeText(senderAddr)}</button>` +
629
+ (senderDomain ? `<button id="btn-allow-domain" title="*@${escapeText(senderDomain)}">Always: *@${escapeText(senderDomain)}</button>` : "") +
630
+ `</span>` +
631
+ `</div>` +
632
+ `<div class="mv-rb-details" hidden>` +
633
+ `<div class="mv-rb-info">` +
634
+ `<div><span class="mv-rb-label">From:</span> ${escapeText(senderName ? `${senderName} <${senderAddr}>` : senderAddr)}</div>` +
635
+ (deliveredTo ? `<div><span class="mv-rb-label">Delivered-To:</span> ${escapeText(deliveredTo)}</div>` : "") +
636
+ (toAddr && toAddr !== deliveredTo ? `<div><span class="mv-rb-label">To:</span> ${escapeText(toAddr)}</div>` : "") +
637
+ (returnPath && returnPath !== senderAddr ? `<div><span class="mv-rb-label">Return-Path:</span> ${escapeText(returnPath)}</div>` : "") +
638
+ `</div>` +
639
+ (deliveredTo || toAddr ? `<div class="mv-rb-actions"><button id="btn-allow-to">Always allow to: ${escapeText(deliveredTo || toAddr)}</button></div>` : "") +
640
+ `<div class="mv-rb-actions">` +
641
+ `<button id="btn-flag-sender" class="mv-rb-flag-btn" title="${escapeText(senderAddr)}">${isFlagged ? "Unflag" : "Flag"} sender</button>` +
642
+ (senderDomain ? `<button id="btn-flag-domain" class="mv-rb-flag-btn" title="*@${escapeText(senderDomain)}">Flag domain *@${escapeText(senderDomain)}</button>` : "") +
643
+ `</div>` +
644
+ `<div class="mv-rb-actions"><button id="btn-edit-allowlist" title="View / edit the full allowlist">Edit allowlist…</button></div>` +
645
+ `</div>`;
646
+ bodyEl.appendChild(banner);
647
+ // Toggle dropdown — click arrow or text to expand details
648
+ const summary = banner.querySelector(".mv-rb-summary");
649
+ const details = banner.querySelector(".mv-rb-details");
650
+ const toggle = banner.querySelector(".mv-rb-toggle");
651
+ summary.addEventListener("click", (e) => {
652
+ if (e.target.tagName === "BUTTON")
653
+ return;
654
+ details.hidden = !details.hidden;
655
+ toggle.textContent = details.hidden ? "\u25B8" : "\u25BE";
656
+ });
657
+ const loadRemote = async () => {
658
+ banner.remove();
659
+ const full = await getMessage(accountId, uid, true);
660
+ if (full.bodyHtml) {
661
+ const oldIframe = bodyEl.querySelector("iframe");
662
+ if (oldIframe)
663
+ oldIframe.remove();
664
+ const iframe = document.createElement("iframe");
665
+ iframe.srcdoc = wrapHtmlBody(full.bodyHtml, true);
666
+ bodyEl.appendChild(iframe);
667
+ installPreviewControls(iframe);
668
+ }
669
+ };
670
+ banner.querySelector("#btn-load-remote").addEventListener("click", loadRemote);
671
+ banner.querySelector("#btn-allow-sender")?.addEventListener("click", async () => {
672
+ await allowRemoteContent("sender", senderAddr);
673
+ loadRemote();
674
+ });
675
+ banner.querySelector("#btn-allow-domain")?.addEventListener("click", async () => {
676
+ await allowRemoteContent("domain", senderDomain);
677
+ loadRemote();
678
+ });
679
+ banner.querySelector("#btn-allow-to")?.addEventListener("click", async () => {
680
+ const addr = deliveredTo || toAddr;
681
+ if (!addr)
682
+ return;
683
+ await allowRemoteContent("recipient", addr);
684
+ loadRemote();
685
+ });
686
+ // Flag (or unflag) sender / domain — toggles the allowlist's
687
+ // flaggedSenders / flaggedDomains lists. Subsequent messages
688
+ // from this sender or domain show a red FLAGGED warning at the
689
+ // top of this banner. Doesn't load remote content; this is a
690
+ // signal-to-the-user feature, orthogonal to allow/block.
691
+ const onFlagToggle = async (type, value) => {
692
+ if (!value)
693
+ return;
694
+ try {
695
+ const result = await flagSenderOrDomain(type, value);
696
+ const status = document.getElementById("status-sync");
697
+ if (status)
698
+ status.textContent = result.flagged
699
+ ? `Flagged ${type}: ${value}`
700
+ : `Unflagged ${type}: ${value}`;
701
+ // Re-render this message so the banner picks up the new
702
+ // flagged state without the user having to reselect.
703
+ if (currentMessage) {
704
+ showMessage(currentAccountId, currentMessage.uid, currentMessage.folderId, specialUse).catch(() => { });
705
+ }
706
+ }
707
+ catch (e) {
708
+ const status = document.getElementById("status-sync");
709
+ if (status)
710
+ status.textContent = `Flag failed: ${e?.message || e}`;
711
+ }
712
+ };
713
+ banner.querySelector("#btn-flag-sender")?.addEventListener("click", () => onFlagToggle("sender", senderAddr));
714
+ banner.querySelector("#btn-flag-domain")?.addEventListener("click", () => onFlagToggle("domain", senderDomain));
715
+ // "Edit allowlist…" — fires a document-level event that app.ts
716
+ // listens for and opens the JSONC editor pre-selected to
717
+ // allowlist.jsonc. Keeps message-viewer free of the editor import.
718
+ banner.querySelector("#btn-edit-allowlist")?.addEventListener("click", () => {
719
+ document.dispatchEvent(new CustomEvent("mailx-open-jsonc-editor", { detail: { file: "allowlist.jsonc" } }));
720
+ });
721
+ }
722
+ // Body fetch error — show banner above (empty) body instead of polluting
723
+ // the main content area with the error text. Transient errors get a retry.
724
+ if (msg.bodyError) {
725
+ const err = String(msg.bodyError);
726
+ const isTransient = !!msg.bodyErrorTransient;
727
+ const errBanner = document.createElement("div");
728
+ errBanner.className = "mv-system-message mv-system-error";
729
+ errBanner.innerHTML = `
730
+ <div class="mv-system-tag">mailx</div>
731
+ <div class="mv-system-title">Body unavailable</div>
732
+ <div class="mv-system-body">${err.replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c] || c))}</div>
733
+ ${isTransient ? `<div class="mv-system-actions"><button id="btn-retry-body" class="mv-system-btn">Retry</button></div>` : ""}
734
+ `;
735
+ bodyEl.appendChild(errBanner);
736
+ if (isTransient) {
737
+ errBanner.querySelector("#btn-retry-body")?.addEventListener("click", async () => {
738
+ errBanner.remove();
739
+ bodyEl.innerHTML = `<div class="mv-empty">Fetching message body...</div>`;
740
+ try {
741
+ const retry = await getMessage(accountId, uid, false);
742
+ if (retry.bodyError) {
743
+ // Still failing — rebuild the error banner via recursive render.
744
+ showMessage(accountId, uid, folderId, specialUse).catch(() => { });
745
+ return;
746
+ }
747
+ bodyEl.innerHTML = "";
748
+ if (retry.bodyHtml) {
749
+ const iframe = document.createElement("iframe");
750
+ iframe.sandbox.add("allow-same-origin");
751
+ iframe.sandbox.add("allow-popups");
752
+ iframe.sandbox.add("allow-popups-to-escape-sandbox");
753
+ iframe.sandbox.add("allow-top-navigation-by-user-activation");
754
+ iframe.sandbox.add("allow-scripts");
755
+ iframe.srcdoc = wrapHtmlBody(retry.bodyHtml, retry.remoteAllowed);
756
+ bodyEl.appendChild(iframe);
757
+ installPreviewControls(iframe);
758
+ }
759
+ else if (retry.bodyText) {
760
+ const pre = document.createElement("pre");
761
+ pre.style.cssText = "padding: 1rem; white-space: pre-wrap; word-break: break-word;";
762
+ pre.innerHTML = linkifyText(retry.bodyText);
763
+ bodyEl.appendChild(pre);
764
+ }
765
+ else {
766
+ bodyEl.innerHTML = `<div class="mv-empty">No content</div>`;
767
+ }
768
+ }
769
+ catch (e) {
770
+ bodyEl.innerHTML = `<div class="mv-empty">Retry failed: ${e.message}</div>`;
771
+ }
772
+ });
773
+ }
774
+ return;
775
+ }
776
+ // Body in sandboxed iframe
777
+ if (msg.bodyHtml) {
778
+ const iframe = document.createElement("iframe");
779
+ iframe.sandbox.add("allow-same-origin");
780
+ iframe.sandbox.add("allow-popups");
781
+ iframe.sandbox.add("allow-popups-to-escape-sandbox");
782
+ iframe.sandbox.add("allow-top-navigation-by-user-activation");
783
+ // allow-scripts lets OUR injected <script> run (for Android link
784
+ // interception — parent-side contentDocument listeners don't fire
785
+ // reliably on Android WebView). CSP with a nonce restricts script
786
+ // execution to our tag only; inline scripts in the email body are
787
+ // still blocked.
788
+ iframe.sandbox.add("allow-scripts");
789
+ iframe.srcdoc = wrapHtmlBody(msg.bodyHtml, msg.remoteAllowed);
790
+ bodyEl.appendChild(iframe);
791
+ installPreviewControls(iframe);
792
+ }
793
+ else if (msg.bodyText) {
794
+ const pre = document.createElement("pre");
795
+ pre.style.cssText = "padding: 1rem; white-space: pre-wrap; word-break: break-word;";
796
+ // Auto-linkify URLs in plain text
797
+ pre.innerHTML = linkifyText(msg.bodyText);
798
+ bodyEl.appendChild(pre);
799
+ }
800
+ else {
801
+ bodyEl.innerHTML = `<div class="mv-empty">No content</div>`;
802
+ }
803
+ // Attachments — always clear first to avoid stale chips from previous message
804
+ attEl.innerHTML = "";
805
+ attEl.hidden = true;
806
+ if (msg.attachments?.length) {
807
+ attEl.hidden = false;
808
+ for (let i = 0; i < msg.attachments.length; i++) {
809
+ const att = msg.attachments[i];
810
+ const chip = document.createElement("a");
811
+ chip.className = "mv-att-chip";
812
+ chip.textContent = `\uD83D\uDCCE ${att.filename} (${formatSize(att.size)})`;
813
+ chip.href = "#";
814
+ chip.title = `${att.filename} (${att.mimeType})`;
815
+ chip.addEventListener("click", async (e) => {
816
+ e.preventDefault();
817
+ try {
818
+ const data = await getAttachment(accountId, uid, i, msg.folderId);
819
+ const bridge = window._nativeBridge;
820
+ if (bridge?.openAttachment) {
821
+ // Android: blob URLs don't work in WebView. Pass base64
822
+ // to native bridge which saves to Downloads and opens
823
+ // with the system viewer.
824
+ await bridge.openAttachment(att.filename, data.contentType, data.content);
825
+ }
826
+ else {
827
+ const bytes = Uint8Array.from(atob(data.content), c => c.charCodeAt(0));
828
+ const blob = new Blob([bytes], { type: data.contentType });
829
+ const url = URL.createObjectURL(blob);
830
+ window.open(url, "_blank");
831
+ }
832
+ }
833
+ catch (err) {
834
+ console.error(`Attachment download failed: ${err.message}`);
835
+ }
836
+ });
837
+ // Drag the chip to an external target (Explorer / Finder / Files app)
838
+ // to drop the file there. Uses the Chromium `DownloadURL` dataTransfer
839
+ // format: "mime:filename:blob-url". We fetch the attachment first so
840
+ // the blob URL is valid by the time the drop lands.
841
+ chip.draggable = true;
842
+ chip.addEventListener("dragstart", async (e) => {
843
+ if (!e.dataTransfer)
844
+ return;
845
+ try {
846
+ const data = await getAttachment(accountId, uid, i, msg.folderId);
847
+ const bytes = Uint8Array.from(atob(data.content), c => c.charCodeAt(0));
848
+ const blob = new Blob([bytes], { type: data.contentType || "application/octet-stream" });
849
+ const url = URL.createObjectURL(blob);
850
+ // Sanitize filename: no path separators, no newlines.
851
+ const safeName = (att.filename || "attachment").replace(/[\r\n"\/\\]/g, "_");
852
+ const downloadUrl = `${data.contentType || "application/octet-stream"}:${safeName}:${url}`;
853
+ e.dataTransfer.setData("DownloadURL", downloadUrl);
854
+ e.dataTransfer.effectAllowed = "copy";
855
+ }
856
+ catch (err) {
857
+ console.error(`Attachment drag-out failed: ${err.message || err}`);
858
+ }
859
+ });
860
+ attEl.appendChild(chip);
861
+ }
862
+ }
863
+ }
864
+ catch (e) {
865
+ const err = e.message || "Unknown error";
866
+ console.error("showMessage error:", e);
867
+ // "Message was deleted from the server" — the service already dropped
868
+ // the local row. Remove it from the list so the UI advances to the next
869
+ // message instead of sitting on a stale error banner.
870
+ const isNotFound = /deleted from the server|isNotFound|not found|Not Found|404/.test(err);
871
+ if (isNotFound) {
872
+ // Drop the stale row so the list auto-advances to the next message
873
+ // (or clears the viewer). Leaves the user a way back on mobile where
874
+ // the viewer takes the whole screen.
875
+ state.removeMessages([{ accountId, uid }]);
876
+ return;
877
+ }
878
+ if (retryCount < 3) {
879
+ retryCount++;
880
+ bodyEl.innerHTML = `<div class="mv-empty">Loading failed: ${err} — retrying (${retryCount}/3)...</div>`;
881
+ setTimeout(() => { if (gen === showMessageGeneration)
882
+ showMessage(accountId, uid, folderId, specialUse, true); }, 3000);
883
+ }
884
+ else {
885
+ bodyEl.innerHTML = `<div class="mv-empty">Failed to load: ${err}</div>`;
886
+ }
887
+ }
888
+ }
889
+ function formatAddr(addr) {
890
+ if (addr.name)
891
+ return `${addr.name} <${addr.address}>`;
892
+ return addr.address;
893
+ }
894
+ /** Render the viewer header from a list-row envelope (instant — no body
895
+ * fetch awaited). Used to populate the header pane the moment a message
896
+ * is clicked so the user always sees something actionable; getMessage()
897
+ * later overwrites the same fields with the authoritative values from the
898
+ * body parse (which can add Cc, Delivered-To, etc. that the list row
899
+ * doesn't track). */
900
+ function renderHeaderFromEnvelope(headerEl, env) {
901
+ headerEl.hidden = false;
902
+ const fromEl = headerEl.querySelector(".mv-from");
903
+ const toEl = headerEl.querySelector(".mv-to");
904
+ const subjEl = headerEl.querySelector(".mv-subject");
905
+ const dateEl = headerEl.querySelector(".mv-date");
906
+ if (fromEl)
907
+ fromEl.textContent = formatAddr(env.from);
908
+ if (toEl) {
909
+ let toLine = `To: ${(env.to || []).map(formatAddr).join(", ")}`;
910
+ if (env.cc?.length)
911
+ toLine += ` Cc: ${env.cc.map(formatAddr).join(", ")}`;
912
+ toEl.textContent = toLine;
913
+ }
914
+ if (subjEl)
915
+ subjEl.textContent = env.subject || "";
916
+ if (dateEl) {
917
+ try {
918
+ dateEl.textContent = new Date(env.date).toLocaleString();
919
+ }
920
+ catch {
921
+ dateEl.textContent = "";
922
+ }
923
+ }
924
+ }
925
+ function escapeHtml(s) {
926
+ return (s || "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]));
927
+ }
928
+ /** Convert plain text URLs into clickable links, escaping HTML */
929
+ function linkifyText(text) {
930
+ // Escape HTML first
931
+ const escaped = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
932
+ // Then linkify URLs
933
+ return escaped.replace(/(https?:\/\/[^\s<>"')\]]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
934
+ }
935
+ function escapeText(s) {
936
+ const div = document.createElement("div");
937
+ div.textContent = s;
938
+ return div.innerHTML;
939
+ }
940
+ /** Minimal add-contact modal: name + email + organization with a duplicate
941
+ * check (checks the contacts DB for an existing row with the same email
942
+ * and surfaces it so the user can update instead of creating a second
943
+ * row with a different name). Future: AI-extracted fields from the letter
944
+ * body populate the form before it opens. */
945
+ async function showAddContactDialog(nameIn, emailIn) {
946
+ let dup = null;
947
+ try {
948
+ const existing = await listContacts(emailIn, 1, 10);
949
+ const match = (existing?.items || []).find((c) => (c.email || "").toLowerCase() === emailIn.toLowerCase());
950
+ if (match)
951
+ dup = match;
952
+ }
953
+ catch { /* non-fatal — dialog still works without dup info */ }
954
+ const backdrop = document.createElement("div");
955
+ backdrop.className = "mailx-modal-backdrop";
956
+ const panel = document.createElement("div");
957
+ panel.className = "mailx-modal";
958
+ panel.innerHTML = `
959
+ <div class="mailx-modal-title">
960
+ <span class="mailx-modal-title-text">${dup ? "Update contact" : "Add contact"}</span>
961
+ <button type="button" class="mailx-modal-close" id="ac-close" aria-label="Close">&times;</button>
962
+ </div>
963
+ ${dup ? `<div class="mailx-modal-info">Already in address book as <strong>${escapeText(dup.name || "(no name)")}</strong> (${escapeText(dup.source)}). Saving will update the name.</div>` : ""}
964
+ <label class="mailx-modal-label">Name
965
+ <input class="mailx-modal-input" id="ac-name" type="text" value="${escapeText(dup?.name || nameIn || "")}" autofocus>
966
+ </label>
967
+ <label class="mailx-modal-label">Email
968
+ <input class="mailx-modal-input" id="ac-email" type="email" value="${escapeText(emailIn)}" readonly>
969
+ </label>
970
+ <label class="mailx-modal-label">Organization <span style="color:var(--color-text-muted);font-size:0.85em">(optional)</span>
971
+ <input class="mailx-modal-input" id="ac-org" type="text" placeholder="">
972
+ </label>
973
+ <div class="mailx-modal-buttons">
974
+ <span class="mailx-modal-spacer"></span>
975
+ <button type="button" class="mailx-modal-btn" data-action="cancel">Cancel</button>
976
+ <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="save">${dup ? "Update" : "Save"}</button>
977
+ </div>`;
978
+ backdrop.appendChild(panel);
979
+ document.body.appendChild(backdrop);
980
+ const close = () => backdrop.remove();
981
+ panel.querySelector("#ac-close").addEventListener("click", close);
982
+ panel.querySelectorAll(".mailx-modal-btn").forEach(btn => {
983
+ btn.addEventListener("click", async () => {
984
+ if (btn.dataset.action === "cancel") {
985
+ close();
986
+ return;
987
+ }
988
+ const nameEl = panel.querySelector("#ac-name");
989
+ const emailEl = panel.querySelector("#ac-email");
990
+ btn.disabled = true;
991
+ btn.textContent = "Saving…";
992
+ try {
993
+ // upsertContact is the two-way cache path (enqueues a Google
994
+ // People push); for pure local-first addContact would also
995
+ // work but skips the Google sync. Use upsertContact so the
996
+ // row propagates to Google Contacts next drain tick.
997
+ await upsertContact(nameEl.value.trim(), emailEl.value.trim());
998
+ close();
999
+ }
1000
+ catch (e) {
1001
+ btn.disabled = false;
1002
+ btn.textContent = dup ? "Update" : "Save";
1003
+ alert(`Couldn't save: ${e?.message || e}`);
1004
+ }
1005
+ });
1006
+ });
1007
+ const onKey = (e) => {
1008
+ if (e.key === "Escape") {
1009
+ close();
1010
+ document.removeEventListener("keydown", onKey, true);
1011
+ }
1012
+ };
1013
+ document.addEventListener("keydown", onKey, true);
1014
+ // addContact is kept as a legacy silent path (no-form) for any caller
1015
+ // that still invokes it — currently none after this refactor.
1016
+ void addContact;
1017
+ }
1018
+ function formatSize(bytes) {
1019
+ if (bytes < 1024)
1020
+ return `${bytes} B`;
1021
+ if (bytes < 1048576)
1022
+ return `${(bytes / 1024).toFixed(0)} KB`;
1023
+ return `${(bytes / 1048576).toFixed(1)} MB`;
1024
+ }
1025
+ function wrapHtmlBody(html, allowRemote = false) {
1026
+ // CSP blocks remote resources (tracking pixels, external CSS). Inline
1027
+ // scripts are allowed via 'unsafe-inline' so our injected link-tap handler
1028
+ // runs; email-body <script> tags and on* handlers are stripped server-side
1029
+ // by sanitizeHtml() in mailx-core, so this doesn't actually widen the
1030
+ // attack surface. (A per-render nonce would be tidier, but meta-CSP with
1031
+ // nonces isn't reliably honored across older WebViews — and when a nonce
1032
+ // is present, 'unsafe-inline' is ignored, so our script fell back to
1033
+ // blocked on those WebViews.)
1034
+ const csp = allowRemote
1035
+ ? ""
1036
+ : `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: cid:; form-action 'none';">`;
1037
+ return `<!DOCTYPE html>
1038
+ <html><head>
1039
+ <meta charset="UTF-8">
1040
+ ${csp}
1041
+ <style>
1042
+ html, body { touch-action: pan-y pinch-zoom; }
1043
+ html { height: 100%; overflow-y: auto; overflow-x: hidden; }
1044
+ body {
1045
+ font-family: system-ui, sans-serif;
1046
+ font-size: 17.5px;
1047
+ line-height: 1.5;
1048
+ color: #1a1a2e;
1049
+ background: #fff;
1050
+ padding: 1rem;
1051
+ margin: 0;
1052
+ min-height: 100%;
1053
+ word-break: break-word;
1054
+ color-scheme: dark light;
1055
+ }
1056
+ img { max-width: 100%; height: auto; }
1057
+ a { color: #1a6dd4; }
1058
+ pre, code { white-space: pre-wrap; }
1059
+ blockquote { border-left: 3px solid #ccc; padding-left: 1rem; margin-left: 0; color: #666; }
1060
+ @media (prefers-color-scheme: dark) {
1061
+ body { color: #cdd6f4; background: #282840; }
1062
+ a { color: #89b4fa; }
1063
+ blockquote { border-color: #45475a; color: #6c7086; }
1064
+ }
1065
+ </style>
1066
+ <base target="_blank">
1067
+ <script>
1068
+ // Link interception — Android WebView doesn't fire the default <a target="_blank">
1069
+ // new-window handler, so we postMessage to the parent which routes through the
1070
+ // native bridge (mailxapi://openurl) to Launcher.OpenAsync.
1071
+ (function () {
1072
+ function handleLinkTap(e) {
1073
+ var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
1074
+ if (!a) return;
1075
+ var url = a.href;
1076
+ if (!url || url.indexOf("javascript:") === 0 || url.charAt(0) === "#") return;
1077
+ e.preventDefault();
1078
+ e.stopPropagation();
1079
+ window.parent.postMessage({ type: "linkClick", url: url }, "*");
1080
+ }
1081
+ document.addEventListener("click", handleLinkTap, true);
1082
+ // Android WebView fallback: some builds drop the synthetic click after
1083
+ // touchend, so treat a stationary touchstart→touchend on the same link
1084
+ // as a tap. Anything that moves more than TAP_SLOP pixels is a scroll
1085
+ // and must NOT activate the link.
1086
+ var TAP_SLOP = 10;
1087
+ var lastTouchTarget = null;
1088
+ var lastTouchX = 0;
1089
+ var lastTouchY = 0;
1090
+ var touchMoved = false;
1091
+ // All touch listeners are passive so Android WebView can compositor-scroll
1092
+ // the iframe without waiting on our JS. handleLinkTap's preventDefault only
1093
+ // matters for the "click" path (which is non-passive by default).
1094
+ document.addEventListener("touchstart", function (e) {
1095
+ var t0 = e.touches && e.touches[0];
1096
+ lastTouchX = t0 ? t0.clientX : 0;
1097
+ lastTouchY = t0 ? t0.clientY : 0;
1098
+ touchMoved = false;
1099
+ lastTouchTarget = (e.target && e.target.closest) ? e.target.closest("a[href]") || e.target : e.target;
1100
+ }, { passive: true, capture: true });
1101
+ document.addEventListener("touchmove", function (e) {
1102
+ if (touchMoved) return;
1103
+ var t = e.touches && e.touches[0];
1104
+ if (!t) return;
1105
+ if (Math.abs(t.clientX - lastTouchX) > TAP_SLOP || Math.abs(t.clientY - lastTouchY) > TAP_SLOP) {
1106
+ touchMoved = true;
1107
+ lastTouchTarget = null;
1108
+ }
1109
+ }, { passive: true, capture: true });
1110
+ document.addEventListener("touchend", function (e) {
1111
+ if (touchMoved) { lastTouchTarget = null; touchMoved = false; return; }
1112
+ var t = (e.target && e.target.closest) ? e.target.closest("a[href]") || e.target : e.target;
1113
+ if (lastTouchTarget && lastTouchTarget === t) handleLinkTap(e);
1114
+ lastTouchTarget = null;
1115
+ }, { passive: true, capture: true });
1116
+ document.addEventListener("touchcancel", function () {
1117
+ lastTouchTarget = null; touchMoved = false;
1118
+ }, { passive: true, capture: true });
1119
+ // Link hover popover removed 2026-04-24 — user feedback: it persisted
1120
+ // over the message body when the dismissers (mousedown/scroll/blur)
1121
+ // didn't fire from inside the iframe, leaving a multi-line URL hanging
1122
+ // in the middle of the reading pane. Right-click (desktop) and long-
1123
+ // press (touch) on a link still open the existing C29 menu with Open /
1124
+ // Save-as / Copy URL / Copy link-text.
1125
+ // C29: right-click on a link → ask parent for the Open/Save/Copy menu.
1126
+ document.addEventListener("contextmenu", function (e) {
1127
+ var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
1128
+ if (!a) return; // let parent's body-context handler take over
1129
+ e.preventDefault();
1130
+ e.stopPropagation();
1131
+ var rect = (e.target.getBoundingClientRect && e.target.getBoundingClientRect()) || { left: 0, top: 0 };
1132
+ window.parent.postMessage({
1133
+ type: "linkContextMenu",
1134
+ url: a.href,
1135
+ text: (a.textContent || "").slice(0, 100),
1136
+ x: e.clientX, y: e.clientY,
1137
+ iframeLeft: rect.left, iframeTop: rect.top
1138
+ }, "*");
1139
+ });
1140
+ // Key forwarding — Delete, Ctrl+D, arrow keys, etc. need to reach app.ts
1141
+ // even when focus is inside the sandboxed iframe. Parent-side
1142
+ // contentDocument listeners (see installPreviewControls) work on
1143
+ // desktop WebView2 but not Android WebView, so we post every keydown
1144
+ // that isn't plain typing.
1145
+ document.addEventListener("keydown", function (e) {
1146
+ var t = e.target;
1147
+ if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
1148
+ // Zoom keys handled by parent-side installPreviewControls; don't double-send.
1149
+ if (e.ctrlKey && (e.key === "=" || e.key === "+" || e.key === "-" || e.key === "0")) return;
1150
+ // Preventing the iframe's default for keys we forward to the parent
1151
+ // is essential — the parent's preventDefault on the synthetic
1152
+ // keydown can't suppress the browser's reaction to the ORIGINAL
1153
+ // event (Ctrl+R reload, Ctrl+F find, etc.). Suppress here so the
1154
+ // browser doesn't act before the parent processes the action.
1155
+ var k = (e.key || "").toLowerCase();
1156
+ var isShortcut = e.ctrlKey && !e.altKey && !e.metaKey && (
1157
+ k === "r" || k === "f" || k === "n" || k === "a" || k === "d" ||
1158
+ k === "z" || k === "y" || k === "k"
1159
+ );
1160
+ if (isShortcut) e.preventDefault();
1161
+ window.parent.postMessage({
1162
+ type: "previewKey",
1163
+ key: e.key, code: e.code,
1164
+ ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,
1165
+ }, "*");
1166
+ });
1167
+ })();
1168
+ </script>
1169
+ </head><body>${html}</body></html>`;
1170
+ }
1171
+ /** Open the current message in a separate view: floating draggable overlay
1172
+ * on desktop (multiple at once, like compose), full-screen mode on mobile.
1173
+ * Threshold matches the layout.css responsive breakpoint so the experience
1174
+ * is consistent with other narrow-mode behavior. Snapshot in time — the
1175
+ * pop-out doesn't auto-update if the user clicks another message. */
1176
+ export function popOutCurrentMessage() {
1177
+ if (!currentMessage)
1178
+ return;
1179
+ const isNarrow = window.innerWidth <= 768;
1180
+ if (isNarrow) {
1181
+ document.body.classList.toggle("viewer-fullscreen");
1182
+ return;
1183
+ }
1184
+ spawnDesktopPopout(currentMessage, currentAccountId);
1185
+ }
1186
+ /** Build a floating overlay carrying a snapshot of the message: header
1187
+ * (subject, from, to, date) + sandboxed body iframe + attachment chips.
1188
+ * Reuses the compose-overlay drag/resize/close pattern. Independent of the
1189
+ * main viewer — opening pop-out for message A then switching the main pane
1190
+ * to message B leaves A visible in its overlay. */
1191
+ function spawnDesktopPopout(msg, accountId) {
1192
+ const wrapper = document.createElement("div");
1193
+ wrapper.className = "compose-overlay viewer-popout";
1194
+ wrapper.style.cssText = "position:fixed;top:60px;right:20px;width:min(720px,55vw);height:min(800px,80vh);z-index:1000;border:1px solid var(--color-border, #ccc);border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.3);display:flex;flex-direction:column;background:var(--color-bg, #fff);resize:both;overflow:hidden;";
1195
+ const titleBar = document.createElement("div");
1196
+ titleBar.style.cssText = "display:flex;align-items:center;justify-content:space-between;padding:6px 10px;background:var(--color-bg-alt, #e8ecf0);color:var(--color-text, #000);border-radius:8px 8px 0 0;cursor:move;user-select:none;flex-shrink:0;font-size:13px;";
1197
+ const titleText = document.createElement("span");
1198
+ titleText.style.cssText = "overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;font-weight:600;";
1199
+ titleText.textContent = msg.subject || "(no subject)";
1200
+ titleBar.appendChild(titleText);
1201
+ const closeBtn = document.createElement("button");
1202
+ closeBtn.textContent = "✕";
1203
+ closeBtn.title = "Close pop-out";
1204
+ closeBtn.style.cssText = "background:none;border:none;font-size:16px;cursor:pointer;color:#666;padding:2px 8px;border-radius:4px;flex-shrink:0;";
1205
+ closeBtn.addEventListener("mouseenter", () => closeBtn.style.color = "#c00");
1206
+ closeBtn.addEventListener("mouseleave", () => closeBtn.style.color = "#666");
1207
+ closeBtn.addEventListener("click", () => wrapper.remove());
1208
+ titleBar.appendChild(closeBtn);
1209
+ const headerInfo = document.createElement("div");
1210
+ headerInfo.style.cssText = "padding:8px 12px;border-bottom:1px solid var(--color-border, #ddd);font-size:13px;line-height:1.4;flex-shrink:0;";
1211
+ const formatAddrLocal = (a) => a.name ? `${a.name} <${a.address}>` : a.address;
1212
+ const fromStr = formatAddrLocal(msg.from || { address: "" });
1213
+ const toStr = (msg.to || []).map(formatAddrLocal).join(", ");
1214
+ const ccStr = msg.cc?.length ? ` Cc: ${msg.cc.map(formatAddrLocal).join(", ")}` : "";
1215
+ const dateStr = msg.date ? new Date(msg.date).toLocaleString() : "";
1216
+ headerInfo.innerHTML =
1217
+ `<div><strong>${escapeHtmlLocal(fromStr)}</strong></div>` +
1218
+ `<div style="color:var(--color-text-muted, #666)">To: ${escapeHtmlLocal(toStr)}${escapeHtmlLocal(ccStr)}</div>` +
1219
+ `<div style="color:var(--color-text-muted, #666);font-size:12px">${escapeHtmlLocal(dateStr)}</div>`;
1220
+ const bodyContainer = document.createElement("div");
1221
+ bodyContainer.style.cssText = "flex:1;overflow:hidden;display:flex;";
1222
+ if (msg.bodyHtml) {
1223
+ const iframe = document.createElement("iframe");
1224
+ iframe.sandbox.add("allow-same-origin");
1225
+ iframe.sandbox.add("allow-popups");
1226
+ iframe.sandbox.add("allow-popups-to-escape-sandbox");
1227
+ iframe.sandbox.add("allow-top-navigation-by-user-activation");
1228
+ iframe.sandbox.add("allow-scripts");
1229
+ iframe.srcdoc = wrapHtmlBody(msg.bodyHtml, msg.remoteAllowed);
1230
+ iframe.style.cssText = "flex:1;border:none;width:100%;background:#fff;";
1231
+ bodyContainer.appendChild(iframe);
1232
+ }
1233
+ else {
1234
+ const pre = document.createElement("pre");
1235
+ pre.style.cssText = "padding:12px;white-space:pre-wrap;word-break:break-word;margin:0;flex:1;overflow:auto;";
1236
+ pre.textContent = msg.bodyText || "(no content)";
1237
+ bodyContainer.appendChild(pre);
1238
+ }
1239
+ // Drag — same pattern as compose-overlay: pointer-events:none on the
1240
+ // iframe so cursor crossing into it doesn't lose drag events.
1241
+ let dragX = 0, dragY = 0;
1242
+ titleBar.addEventListener("mousedown", (e) => {
1243
+ if (e.target === closeBtn)
1244
+ return;
1245
+ e.preventDefault();
1246
+ const rect = wrapper.getBoundingClientRect();
1247
+ dragX = e.clientX - rect.left;
1248
+ dragY = e.clientY - rect.top;
1249
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
1250
+ bodyContainer.style.pointerEvents = "none";
1251
+ document.body.style.userSelect = "none";
1252
+ const onMove = (ev) => {
1253
+ ev.preventDefault();
1254
+ const left = clamp(ev.clientX - dragX, 0, window.innerWidth - 40);
1255
+ const top = clamp(ev.clientY - dragY, 0, window.innerHeight - 40);
1256
+ wrapper.style.left = `${left}px`;
1257
+ wrapper.style.top = `${top}px`;
1258
+ wrapper.style.right = "auto";
1259
+ wrapper.style.bottom = "auto";
1260
+ };
1261
+ const onUp = () => {
1262
+ bodyContainer.style.pointerEvents = "";
1263
+ document.body.style.userSelect = "";
1264
+ document.removeEventListener("mousemove", onMove);
1265
+ document.removeEventListener("mouseup", onUp);
1266
+ };
1267
+ document.addEventListener("mousemove", onMove);
1268
+ document.addEventListener("mouseup", onUp);
1269
+ });
1270
+ // Bring to front on click — shared with compose-overlay so they all
1271
+ // restack uniformly.
1272
+ wrapper.addEventListener("mousedown", () => {
1273
+ document.querySelectorAll(".compose-overlay").forEach(el => el.style.zIndex = "1000");
1274
+ wrapper.style.zIndex = "1001";
1275
+ });
1276
+ // Cascade pop-outs so they don't all stack at the same coords.
1277
+ const existing = document.querySelectorAll(".viewer-popout").length;
1278
+ if (existing > 0) {
1279
+ wrapper.style.top = `${60 + existing * 28}px`;
1280
+ wrapper.style.right = `${20 + existing * 28}px`;
1281
+ }
1282
+ void accountId; // accountId reserved for future per-account actions on the popout
1283
+ wrapper.appendChild(titleBar);
1284
+ wrapper.appendChild(headerInfo);
1285
+ wrapper.appendChild(bodyContainer);
1286
+ document.body.appendChild(wrapper);
1287
+ }
1288
+ function escapeHtmlLocal(s) {
1289
+ return (s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1290
+ }
1291
+ //# sourceMappingURL=message-viewer.js.map