@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
package/client/app.js ADDED
@@ -0,0 +1,3400 @@
1
+ /**
2
+ * mailx client entry point.
3
+ * Wires together all UI components and WebSocket connection.
4
+ */
5
+ import { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced, getFolderSynced, setOutboxTotal } from "./components/folder-tree.js";
6
+ import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, clearSearchMode, getSelectedMessages, markBodiesCached } from "./components/message-list.js";
7
+ import { showMessage, getCurrentMessage, initViewer, popOutCurrentMessage } from "./components/message-viewer.js";
8
+ import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent, sendMessage as apiSendMessage } from "./lib/api-client.js";
9
+ import * as messageState from "./lib/message-state.js";
10
+ // ── New message badge (favicon + title) ──
11
+ let baseTitle = "mailx";
12
+ let lastSeenCount = 0;
13
+ let badgeCount = 0;
14
+ function updateBadge(count) {
15
+ badgeCount = count;
16
+ // Update title
17
+ document.title = count > 0 ? `(${count}) ${baseTitle}` : baseTitle;
18
+ // Generate a single badge bitmap used for both the favicon (visible on
19
+ // browser tabs / mobile homescreen) AND the Windows taskbar overlay
20
+ // icon (visible as a Thunderbird-style corner pill on the taskbar
21
+ // button when running via msger). Rendered once, consumed twice.
22
+ const canvas = document.createElement("canvas");
23
+ canvas.width = 32;
24
+ canvas.height = 32;
25
+ const ctx = canvas.getContext("2d");
26
+ // Base envelope icon (always drawn — so the favicon is a recognizable
27
+ // mailx icon even at 0 count).
28
+ ctx.fillStyle = "#4a7ccc";
29
+ ctx.fillRect(2, 8, 28, 20);
30
+ ctx.fillStyle = "#6a9cec";
31
+ ctx.beginPath();
32
+ ctx.moveTo(2, 8);
33
+ ctx.lineTo(16, 20);
34
+ ctx.lineTo(30, 8);
35
+ ctx.fill();
36
+ if (count > 0) {
37
+ // Red badge circle with count
38
+ ctx.fillStyle = "#e33";
39
+ ctx.beginPath();
40
+ ctx.arc(24, 8, 8, 0, Math.PI * 2);
41
+ ctx.fill();
42
+ ctx.fillStyle = "#fff";
43
+ ctx.font = "bold 11px sans-serif";
44
+ ctx.textAlign = "center";
45
+ ctx.textBaseline = "middle";
46
+ ctx.fillText(count > 99 ? "99+" : String(count), 24, 8);
47
+ }
48
+ // Set as favicon
49
+ let link = document.querySelector("link[rel='icon']");
50
+ if (!link) {
51
+ link = document.createElement("link");
52
+ link.rel = "icon";
53
+ document.head.appendChild(link);
54
+ }
55
+ const dataUrl = canvas.toDataURL("image/png");
56
+ link.href = dataUrl;
57
+ // Also push to the Windows taskbar overlay via msger's IPC helper —
58
+ // no-op on Linux/Mac. For count=0, render a dedicated "no-overlay"
59
+ // icon that's all-transparent so the base icon shows cleanly.
60
+ try {
61
+ const msgapi = window.msgapi;
62
+ if (msgapi?.setTaskbarOverlay) {
63
+ if (count > 0) {
64
+ // strip "data:image/png;base64," prefix → base64 only
65
+ const b64 = dataUrl.split(",")[1] || "";
66
+ msgapi.setTaskbarOverlay(b64, `${count} unread`);
67
+ }
68
+ else {
69
+ msgapi.setTaskbarOverlay("", "");
70
+ }
71
+ }
72
+ }
73
+ catch { /* msgapi unavailable in browser fallback */ }
74
+ }
75
+ async function updateNewMessageCount() {
76
+ try {
77
+ const accounts = await getAccounts();
78
+ let totalUnread = 0;
79
+ for (const acct of accounts) {
80
+ const folders = await getFolders(acct.id);
81
+ const inbox = folders.find((f) => f.specialUse === "inbox");
82
+ if (inbox)
83
+ totalUnread += inbox.unreadCount || 0;
84
+ }
85
+ // Rail badge: unread count on the Inbox and Unified-inbox rail buttons.
86
+ // Visible even when those views aren't the active one — part of C33
87
+ // "rail icon badges for unread counts."
88
+ updateRailBadge("rail-inbox", totalUnread);
89
+ updateRailBadge("rail-unified", totalUnread);
90
+ // First load: set baseline
91
+ if (lastSeenCount === 0) {
92
+ lastSeenCount = totalUnread;
93
+ updateBadge(0);
94
+ return;
95
+ }
96
+ const previousBadge = badgeCount;
97
+ // New messages = increase since last seen
98
+ const newCount = Math.max(0, totalUnread - lastSeenCount);
99
+ updateBadge(newCount);
100
+ // Flash the title when new mail arrives and the window isn't focused.
101
+ // Windows' taskbar mirrors document.title so this acts as a taskbar flash.
102
+ if (newCount > previousBadge && document.visibilityState !== "visible") {
103
+ startTitleFlash();
104
+ }
105
+ }
106
+ catch { /* offline */ }
107
+ }
108
+ function updateRailBadge(buttonId, count) {
109
+ const btn = document.getElementById(buttonId);
110
+ if (!btn)
111
+ return;
112
+ let badge = btn.querySelector(".rail-badge");
113
+ if (count <= 0) {
114
+ if (badge)
115
+ badge.remove();
116
+ return;
117
+ }
118
+ if (!badge) {
119
+ badge = document.createElement("span");
120
+ badge.className = "rail-badge";
121
+ btn.appendChild(badge);
122
+ }
123
+ badge.textContent = count > 999 ? "999+" : String(count);
124
+ }
125
+ // ── Taskbar flash via title alternation ──
126
+ let titleFlashTimer = null;
127
+ let titleFlashPhase = false;
128
+ function startTitleFlash() {
129
+ stopTitleFlash();
130
+ titleFlashPhase = true;
131
+ titleFlashTimer = setInterval(() => {
132
+ titleFlashPhase = !titleFlashPhase;
133
+ if (titleFlashPhase) {
134
+ document.title = `✉ NEW MAIL (${badgeCount})`;
135
+ }
136
+ else {
137
+ document.title = badgeCount > 0 ? `(${badgeCount}) ${baseTitle}` : baseTitle;
138
+ }
139
+ }, 1000);
140
+ }
141
+ function stopTitleFlash() {
142
+ if (titleFlashTimer) {
143
+ clearInterval(titleFlashTimer);
144
+ titleFlashTimer = null;
145
+ }
146
+ document.title = badgeCount > 0 ? `(${badgeCount}) ${baseTitle}` : baseTitle;
147
+ }
148
+ document.addEventListener("visibilitychange", () => {
149
+ if (document.visibilityState === "visible")
150
+ stopTitleFlash();
151
+ });
152
+ window.addEventListener("focus", stopTitleFlash);
153
+ /** Call when user actively views messages — resets the badge */
154
+ function markAsSeen() {
155
+ getAccounts().then(async (accounts) => {
156
+ let total = 0;
157
+ for (const acct of accounts) {
158
+ const folders = await getFolders(acct.id);
159
+ const inbox = folders.find((f) => f.specialUse === "inbox");
160
+ if (inbox)
161
+ total += inbox.unreadCount || 0;
162
+ }
163
+ lastSeenCount = total;
164
+ updateBadge(0);
165
+ }).catch(() => { });
166
+ }
167
+ function setTitle(title) {
168
+ baseTitle = title;
169
+ document.title = badgeCount > 0 ? `(${badgeCount}) ${baseTitle}` : baseTitle;
170
+ }
171
+ // ── Alert banner ──
172
+ const alertBanner = document.getElementById("alert-banner");
173
+ const alertText = document.getElementById("alert-text");
174
+ const alertDismiss = document.getElementById("alert-dismiss");
175
+ const dismissedAlerts = new Set();
176
+ let alertAutoDismissTimer = null;
177
+ function showAlert(message, key, opts) {
178
+ if (key && dismissedAlerts.has(key))
179
+ return;
180
+ if (alertBanner && alertText) {
181
+ alertText.textContent = message;
182
+ alertBanner.hidden = false;
183
+ alertBanner.dataset.key = key || "";
184
+ // Q65: auto-dismiss non-critical banners after 30s; sticky ones
185
+ // (acct-*, ws-error, config-restart) keep showing until user acts.
186
+ if (alertAutoDismissTimer) {
187
+ clearTimeout(alertAutoDismissTimer);
188
+ alertAutoDismissTimer = null;
189
+ }
190
+ const isCritical = !!opts?.sticky
191
+ || (key?.startsWith("acct-"))
192
+ || key === "ws-error"
193
+ || key === "config-restart";
194
+ if (!isCritical) {
195
+ alertAutoDismissTimer = setTimeout(() => {
196
+ if (alertBanner && alertBanner.dataset.key === (key || "")) {
197
+ alertBanner.hidden = true;
198
+ }
199
+ alertAutoDismissTimer = null;
200
+ }, 30_000);
201
+ }
202
+ }
203
+ }
204
+ function hideAlert() {
205
+ if (alertBanner) {
206
+ const key = alertBanner.dataset.key;
207
+ if (key)
208
+ dismissedAlerts.add(key);
209
+ alertBanner.hidden = true;
210
+ }
211
+ }
212
+ alertDismiss?.addEventListener("click", hideAlert);
213
+ /** Show the alert banner with a "Restart" button wired to the mailxapi
214
+ * restartDaemon action. Used when a watched config file whose changes
215
+ * don't apply live (accounts.jsonc) has been modified. */
216
+ function showRestartForConfigBanner() {
217
+ if (!alertBanner || !alertText)
218
+ return;
219
+ // Timestamp in the banner so repeated / spurious fires are visually
220
+ // distinguishable (and the user can see when the change actually
221
+ // happened, useful for debugging false triggers).
222
+ const ts = new Date().toLocaleTimeString([], { hour12: false });
223
+ alertText.textContent = `[${ts}] accounts.jsonc changed — restart to apply.`;
224
+ alertBanner.hidden = false;
225
+ alertBanner.dataset.key = "config-restart";
226
+ // Avoid duplicate buttons across repeat changes.
227
+ const existing = alertBanner.querySelector("#alert-restart-btn");
228
+ if (existing)
229
+ return;
230
+ const btn = document.createElement("button");
231
+ btn.id = "alert-restart-btn";
232
+ btn.textContent = "Restart now";
233
+ btn.style.cssText = "margin-left: 12px; padding: 3px 12px; cursor: pointer;";
234
+ btn.addEventListener("click", async () => {
235
+ btn.disabled = true;
236
+ btn.textContent = "Restarting…";
237
+ try {
238
+ const ipc = window.mailxapi;
239
+ if (ipc?.restartDaemon) {
240
+ await ipc.restartDaemon();
241
+ // Service is going down; the WebView should reload shortly
242
+ // when the replacement daemon takes over. Force a reload
243
+ // after a short delay in case the event doesn't arrive.
244
+ setTimeout(() => location.reload(), 2000);
245
+ }
246
+ else {
247
+ // Non-IPC (server/browser mode) — location reload won't
248
+ // restart the daemon but at least gives the user feedback.
249
+ location.reload();
250
+ }
251
+ }
252
+ catch (e) {
253
+ btn.textContent = `Failed: ${e?.message || e}`;
254
+ btn.disabled = false;
255
+ }
256
+ });
257
+ alertText.after(btn);
258
+ }
259
+ // ── Wire up components ──
260
+ const folderTree = document.getElementById("folder-tree");
261
+ let currentFolderSpecialUse = "";
262
+ function clearViewer() {
263
+ messageState.select(null); // Deselect — viewer clears via subscription
264
+ }
265
+ // Anyone can ask the viewer to clear by dispatching a `mailx-clear-viewer`
266
+ // CustomEvent on document. Used by message-list's loadSearchResults so the
267
+ // stale preview from the prior selection doesn't linger over the new search
268
+ // results. Slice D will replace this with row-object-level `unfocus()`.
269
+ document.addEventListener("mailx-clear-viewer", () => clearViewer());
270
+ const folderTitleEl = document.getElementById("ml-folder-title");
271
+ let currentFolderName = "";
272
+ let currentFolderSyncedAt;
273
+ function formatAge(ms) {
274
+ const s = Math.round(ms / 1000);
275
+ if (s < 60)
276
+ return `${s}s ago`;
277
+ const m = Math.round(s / 60);
278
+ if (m < 60)
279
+ return `${m}m ago`;
280
+ const h = Math.round(m / 60);
281
+ if (h < 24)
282
+ return `${h}h ago`;
283
+ return `${Math.round(h / 24)}d ago`;
284
+ }
285
+ function renderNarrowFolderTitle() {
286
+ if (!folderTitleEl)
287
+ return;
288
+ if (currentFolderSyncedAt) {
289
+ const age = formatAge(Date.now() - currentFolderSyncedAt);
290
+ folderTitleEl.innerHTML = `${currentFolderName}<span class="ml-folder-age"> · ${age}</span>`;
291
+ folderTitleEl.title = `Last synced ${new Date(currentFolderSyncedAt).toLocaleTimeString()}`;
292
+ }
293
+ else {
294
+ folderTitleEl.textContent = currentFolderName;
295
+ folderTitleEl.title = "";
296
+ }
297
+ }
298
+ function setNarrowFolderTitle(name) {
299
+ currentFolderName = name;
300
+ currentFolderSyncedAt = getFolderSynced(currentAccountId, currentFolderId);
301
+ renderNarrowFolderTitle();
302
+ }
303
+ // Tick the "3m ago" text every 30s so it stays truthful without flooding repaints.
304
+ setInterval(() => {
305
+ if (currentFolderSyncedAt)
306
+ renderNarrowFolderTitle();
307
+ }, 30_000);
308
+ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
309
+ currentFolderSpecialUse = specialUse;
310
+ currentAccountId = accountId;
311
+ currentFolderId = folderId;
312
+ if (searchInput)
313
+ searchInput.value = "";
314
+ markAsSeen();
315
+ clearViewer();
316
+ loadMessages(accountId, folderId, 1, specialUse);
317
+ setTitle(`mailx - ${folderName}`);
318
+ setNarrowFolderTitle(folderName);
319
+ document.dispatchEvent(new CustomEvent("mailx-folder-changed", { detail: { accountId, folderId } }));
320
+ }, () => {
321
+ // Unified inbox handler
322
+ currentFolderSpecialUse = "inbox";
323
+ clearViewer();
324
+ loadUnifiedInbox();
325
+ setTitle("mailx - All Inboxes");
326
+ setNarrowFolderTitle("All Inboxes");
327
+ });
328
+ initMessageList((accountId, uid, folderId) => {
329
+ showMessage(accountId, uid, folderId, currentFolderSpecialUse);
330
+ // Narrow screen: show message viewer, hide list
331
+ if (window.innerWidth <= 768) {
332
+ document.getElementById("message-viewer")?.classList.add("narrow-active");
333
+ document.getElementById("message-list")?.classList.add("narrow-hidden");
334
+ // Selecting a message means the user is done with the rail / folder
335
+ // drawers — auto-dismiss either if left open. Without this the rail
336
+ // floats over the message body (the "rail on top of the letter" bug).
337
+ document.querySelector(".icon-rail")?.classList.remove("open");
338
+ document.querySelector(".folder-panel")?.classList.remove("open");
339
+ }
340
+ });
341
+ initViewer();
342
+ // Status bar: show selected message UID/folder for debugging
343
+ messageState.subscribe((change) => {
344
+ if (change === "selected" || change === "removed") {
345
+ const acctEl = document.getElementById("status-accounts");
346
+ if (!acctEl)
347
+ return;
348
+ const sel = messageState.getSelected();
349
+ if (sel) {
350
+ acctEl.textContent = `${sel.accountId}/uid:${sel.uid} folder:${sel.folderId}`;
351
+ acctEl.style.color = "";
352
+ }
353
+ else {
354
+ acctEl.textContent = "";
355
+ }
356
+ }
357
+ });
358
+ // Q53: per-account last-sync timestamps surfaced via the status-sync hover.
359
+ const lastSyncByAccount = {};
360
+ function recordAccountSync(accountId) {
361
+ lastSyncByAccount[accountId] = Date.now();
362
+ refreshSyncTooltip();
363
+ }
364
+ function refreshSyncTooltip() {
365
+ const el = document.getElementById("status-sync");
366
+ if (!el)
367
+ return;
368
+ const accts = Object.keys(lastSyncByAccount).sort();
369
+ if (accts.length === 0) {
370
+ el.title = "";
371
+ return;
372
+ }
373
+ el.title = "Last sync:\n" + accts.map(a => {
374
+ const ts = lastSyncByAccount[a];
375
+ const d = new Date(ts);
376
+ return ` ${a}: ${d.toLocaleTimeString()} (${formatAge(Date.now() - ts)})`;
377
+ }).join("\n");
378
+ }
379
+ // Refresh the tooltip every 30s so the "(12m ago)" stays current even with
380
+ // no new sync events.
381
+ setInterval(refreshSyncTooltip, 30_000);
382
+ // ── Auto two-line when message list is narrow ──
383
+ const messageList = document.getElementById("message-list");
384
+ if (messageList) {
385
+ const twoLineThreshold = 600; // px — switch to two-line below this width
386
+ const userTwoLine = localStorage.getItem("mailx-two-line") === "true";
387
+ new ResizeObserver(([entry]) => {
388
+ const narrow = entry.contentRect.width < twoLineThreshold;
389
+ // Auto two-line when narrow, respect user preference when wide
390
+ if (narrow) {
391
+ messageList.classList.add("two-line");
392
+ }
393
+ else if (!userTwoLine) {
394
+ messageList.classList.remove("two-line");
395
+ }
396
+ }).observe(messageList);
397
+ }
398
+ // ── Narrow/medium drawer toggles ──
399
+ // Hamburger (☰): rail drawer on narrow; on wider tiers the rail is already
400
+ // visible so this is a no-op visually (the toggle still fires but the rail
401
+ // has no `.open` style to invoke).
402
+ // Folder (📁): folder-panel drawer on any tier where it's positioned as an
403
+ // overlay (medium + narrow).
404
+ document.getElementById("btn-menu")?.addEventListener("click", () => {
405
+ document.querySelector(".icon-rail")?.classList.toggle("open");
406
+ // Rail drawer and folder drawer are mutually exclusive — opening one
407
+ // closes the other so they don't fight for the left edge.
408
+ document.querySelector(".folder-panel")?.classList.remove("open");
409
+ });
410
+ document.getElementById("btn-folder-toggle")?.addEventListener("click", () => {
411
+ document.querySelector(".folder-panel")?.classList.toggle("open");
412
+ document.querySelector(".icon-rail")?.classList.remove("open");
413
+ });
414
+ const backToList = (e) => {
415
+ e.preventDefault();
416
+ e.stopPropagation();
417
+ // If user is in full-screen-viewer mode, the first back tap should exit
418
+ // full-screen and return to the normal narrow split (list + active
419
+ // viewer). It shouldn't also deselect — that would yank the user out two
420
+ // levels in one tap.
421
+ if (document.body.classList.contains("viewer-fullscreen")) {
422
+ document.body.classList.remove("viewer-fullscreen");
423
+ return;
424
+ }
425
+ document.getElementById("message-viewer")?.classList.remove("narrow-active");
426
+ document.getElementById("message-list")?.classList.remove("narrow-hidden");
427
+ // Deselect the message so the viewer component clears. Without this, a
428
+ // subsequent "selected" state change (e.g. sync reload) could re-show the
429
+ // same message and re-trigger narrow-active.
430
+ messageState.select(null);
431
+ };
432
+ document.getElementById("btn-back")?.addEventListener("click", backToList);
433
+ // Android WebView sometimes drops synthetic clicks after a touchend inside a
434
+ // header bar layered above the iframe — handle touchend explicitly too.
435
+ document.getElementById("btn-back")?.addEventListener("touchend", backToList);
436
+ // Pop-out viewer button — desktop spawns a floating overlay (multiple at
437
+ // once), mobile toggles `body.viewer-fullscreen` for full-screen reading.
438
+ // Threshold and behavior live in popOutCurrentMessage.
439
+ document.getElementById("mv-popout")?.addEventListener("click", () => popOutCurrentMessage());
440
+ // Close folder panel when a folder is selected (narrow mode)
441
+ // Also reset narrow navigation: show message list, hide viewer
442
+ document.getElementById("folder-tree")?.addEventListener("click", (e) => {
443
+ if (window.innerWidth <= 768 && e.target.closest(".ft-folder")) {
444
+ document.querySelector(".folder-panel")?.classList.remove("open");
445
+ document.getElementById("message-viewer")?.classList.remove("narrow-active");
446
+ document.getElementById("message-list")?.classList.remove("narrow-hidden");
447
+ }
448
+ });
449
+ // Close folder overlay when user clicks outside it (narrow mode OR
450
+ // medium-width mode where the folder panel slides in as an overlay).
451
+ // Uses capture phase so it beats any child handler that might stopPropagation.
452
+ document.addEventListener("pointerdown", (e) => {
453
+ const panel = document.querySelector(".folder-panel");
454
+ if (!panel || !panel.classList.contains("open"))
455
+ return;
456
+ const target = e.target;
457
+ // Ignore clicks inside the panel itself and on either toggle button.
458
+ // Without `#btn-folder-toggle` in this list, clicking the folder icon
459
+ // while the panel is open closed it here (capture phase) then the click
460
+ // handler reopened it — net effect: panel stuck open, "doesn't toggle".
461
+ if (target.closest(".folder-panel")
462
+ || target.closest("#btn-menu")
463
+ || target.closest("#btn-folder-toggle"))
464
+ return;
465
+ // Only auto-dismiss when we're in overlay mode (small or medium screens).
466
+ // On wide screens the panel is a permanent column and the "open" class
467
+ // is irrelevant.
468
+ if (window.innerWidth <= 1100 || window.innerHeight <= 600) {
469
+ panel.classList.remove("open");
470
+ }
471
+ }, true);
472
+ // Same auto-dismiss for the icon-rail drawer (narrow only — on medium/wide
473
+ // the rail is a permanent column and `.open` has no visual effect).
474
+ document.addEventListener("pointerdown", (e) => {
475
+ const rail = document.querySelector(".icon-rail");
476
+ if (!rail || !rail.classList.contains("open"))
477
+ return;
478
+ const target = e.target;
479
+ if (target.closest(".icon-rail") || target.closest("#btn-menu"))
480
+ return;
481
+ if (window.innerWidth <= 768)
482
+ rail.classList.remove("open");
483
+ }, true);
484
+ // Tapping any rail button dismisses the drawer afterward — the user picked
485
+ // a destination, the drawer's job is done. Skipping the menus that anchor
486
+ // off the rail (settings/view) so the menu has time to open before the
487
+ // rail collapses out from under it.
488
+ document.querySelectorAll(".icon-rail .rail-btn").forEach(btn => {
489
+ btn.addEventListener("click", () => {
490
+ if (window.innerWidth > 768)
491
+ return;
492
+ if (btn.id === "rail-settings" || btn.id === "rail-view")
493
+ return;
494
+ document.querySelector(".icon-rail")?.classList.remove("open");
495
+ });
496
+ });
497
+ // ── Toolbar actions ──
498
+ document.getElementById("btn-sync")?.addEventListener("click", async () => {
499
+ const btn = document.getElementById("btn-sync");
500
+ btn.disabled = true;
501
+ btn.classList.add("syncing");
502
+ const statusSync = document.getElementById("status-sync");
503
+ if (statusSync)
504
+ statusSync.textContent = "Syncing...";
505
+ try {
506
+ await triggerSync();
507
+ // Button stays spinning — WebSocket syncProgress/folderCountsChanged will update UI
508
+ // Set a timeout to re-enable if no WebSocket response
509
+ setTimeout(() => {
510
+ btn.disabled = false;
511
+ btn.classList.remove("syncing");
512
+ refreshFolderTree();
513
+ reloadCurrentFolder();
514
+ if (statusSync && statusSync.textContent === "Syncing...") {
515
+ statusSync.textContent = `Synced ${new Date().toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false })}`;
516
+ }
517
+ }, 30000);
518
+ }
519
+ catch (e) {
520
+ if (statusSync)
521
+ statusSync.textContent = `Sync error: ${e.message}`;
522
+ btn.disabled = false;
523
+ btn.classList.remove("syncing");
524
+ }
525
+ });
526
+ // Restart menu dropdown
527
+ const restartBtn = document.getElementById("btn-restart");
528
+ const restartDropdown = document.getElementById("restart-dropdown");
529
+ restartBtn?.addEventListener("click", () => {
530
+ if (restartDropdown)
531
+ restartDropdown.hidden = !restartDropdown.hidden;
532
+ });
533
+ document.addEventListener("click", (e) => {
534
+ if (restartDropdown && !restartDropdown.hidden && !e.target.closest("#restart-menu")) {
535
+ restartDropdown.hidden = true;
536
+ }
537
+ });
538
+ document.getElementById("btn-restart-quick")?.addEventListener("click", async () => {
539
+ if (restartDropdown)
540
+ restartDropdown.hidden = true;
541
+ if (isApp) {
542
+ // Android has no daemon — only the WebView. Reload-the-page is the
543
+ // right action there. Desktop IPC mode is a different story below.
544
+ if (window.mailxapi?.platform === "android") {
545
+ const f = document.createElement("iframe");
546
+ f.style.display = "none";
547
+ f.src = "mailxapi://checkUpdate";
548
+ document.body.appendChild(f);
549
+ setTimeout(() => f.remove(), 100);
550
+ location.reload();
551
+ return;
552
+ }
553
+ // Desktop IPC mode: there IS a daemon (the --daemon child of mailx)
554
+ // running mailx-service / mailx-imap / mailx-store. Just calling
555
+ // location.reload() reloads the WebView but the daemon keeps running
556
+ // the old code, so daemon-side changes (sync, store, IPC handlers)
557
+ // don't get picked up. Trigger restartDaemon — it spawns a fresh
558
+ // `mailx` process, hands off the instance.json slot, then gracefully
559
+ // shuts down the current daemon. The UI reloads after a short delay
560
+ // so the new daemon's WebView replaces this one.
561
+ const statusSync = document.getElementById("status-sync");
562
+ if (statusSync)
563
+ statusSync.textContent = "Restarting...";
564
+ const ipc = window.mailxapi;
565
+ if (ipc?.restartDaemon) {
566
+ try {
567
+ await ipc.restartDaemon();
568
+ }
569
+ catch { /* daemon shutting down */ }
570
+ setTimeout(() => location.reload(), 2000);
571
+ }
572
+ else {
573
+ // Older host with no restartDaemon IPC — fall back to UI reload.
574
+ location.reload();
575
+ }
576
+ }
577
+ else {
578
+ const statusSync = document.getElementById("status-sync");
579
+ if (statusSync)
580
+ statusSync.textContent = "Restarting...";
581
+ try {
582
+ await restartServer();
583
+ }
584
+ catch { /* server is shutting down */ }
585
+ }
586
+ });
587
+ document.getElementById("btn-update")?.addEventListener("click", async () => {
588
+ if (restartDropdown)
589
+ restartDropdown.hidden = true;
590
+ const statusSync = document.getElementById("status-sync");
591
+ if (statusSync)
592
+ statusSync.textContent = "Checking for updates...";
593
+ const ipc = window.mailxapi || window.opener?.mailxapi;
594
+ if (ipc?.performUpdate) {
595
+ if (statusSync)
596
+ statusSync.textContent = "Updating... mailx will restart when done";
597
+ ipc.performUpdate();
598
+ }
599
+ else if (statusSync) {
600
+ statusSync.textContent = "Update not available in this mode";
601
+ }
602
+ });
603
+ document.getElementById("btn-rebuild")?.addEventListener("click", async () => {
604
+ if (restartDropdown)
605
+ restartDropdown.hidden = true;
606
+ if (!confirm("Rebuild local cache?\n\nThis wipes the local database and message store, then re-downloads everything.\nAccounts and settings are preserved.\n\nThis is safe and usually takes just a few minutes."))
607
+ return;
608
+ const statusSync = document.getElementById("status-sync");
609
+ if (statusSync)
610
+ statusSync.textContent = "Rebuilding...";
611
+ try {
612
+ await restartServer();
613
+ }
614
+ catch { /* restarting */ }
615
+ });
616
+ document.getElementById("btn-factory-reset")?.addEventListener("click", async () => {
617
+ if (restartDropdown)
618
+ restartDropdown.hidden = true;
619
+ if (!confirm("Factory reset?\n\nThis deletes ALL data — accounts, settings, messages, cache.\nYou will need to set up your account again."))
620
+ return;
621
+ const ipc = window.mailxapi;
622
+ if (ipc?.resetAll) {
623
+ await ipc.resetAll();
624
+ }
625
+ else {
626
+ // Fallback: clear IndexedDB + localStorage manually
627
+ const dbs = await indexedDB.databases();
628
+ for (const db of dbs) {
629
+ if (db.name)
630
+ indexedDB.deleteDatabase(db.name);
631
+ }
632
+ localStorage.clear();
633
+ location.reload();
634
+ }
635
+ });
636
+ async function openCompose(mode) {
637
+ logClientEvent("openCompose-entry", { mode });
638
+ const current = getCurrentMessage();
639
+ // Local-first: if the row is selected we already have its headers in the
640
+ // local DB. Populate the compose form unconditionally; the user can edit
641
+ // anything missing. Don't show "still loading" alerts — the message IS
642
+ // loaded (it's in the list), body is a separate fetch that isn't needed
643
+ // for Reply's headers. Missing fields become empty strings.
644
+ if ((mode === "reply" || mode === "replyAll" || mode === "forward") && !current) {
645
+ // Only true blocker: no message selected at all.
646
+ console.warn(`[compose] ${mode} — no message selected`);
647
+ return;
648
+ }
649
+ const accounts = await getAccounts();
650
+ const accountId = current?.accountId || accounts[0]?.id || "";
651
+ const msg = current?.message;
652
+ const rePrefix = /^(re|fwd?):\s*/i;
653
+ const cleanSubject = msg ? msg.subject.replace(rePrefix, "") : "";
654
+ const init = {
655
+ mode,
656
+ accountId,
657
+ to: [],
658
+ cc: [],
659
+ subject: "",
660
+ bodyHtml: "",
661
+ inReplyTo: "",
662
+ references: [],
663
+ accounts: accounts.map((a) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature })),
664
+ };
665
+ // Auto-detect reply From: if the message was delivered to an identity address
666
+ // (an alias on the account's domain, or the explicit `identityDomains` list
667
+ // in accounts.jsonc), reply from that address instead of the account's
668
+ // primary. Always derive identityDomains from the account email's domain
669
+ // when not configured — explicit list was a regression source (users would
670
+ // see Reply pick the wrong From silently when the list was missing).
671
+ const account = accounts.find((a) => a.id === accountId);
672
+ const explicitDomains = (account?.identityDomains || []).map((d) => d.toLowerCase());
673
+ const accountDomain = (account?.email || "").split("@")[1]?.toLowerCase();
674
+ const identityDomains = explicitDomains.length > 0
675
+ ? explicitDomains
676
+ : (accountDomain ? [accountDomain] : []);
677
+ function detectReplyFrom() {
678
+ if (!msg)
679
+ return undefined;
680
+ // Delivered-To is set by the receiving server — it IS an identity at this
681
+ // account, by definition. Trust it unconditionally when present (after
682
+ // deliveredToPrefix stripping in the service). Fall back to To/Cc only
683
+ // when their domain matches the account's identityDomains, since To/Cc
684
+ // can be set by the sender and aren't authoritative.
685
+ if (msg.deliveredTo) {
686
+ console.log(`[compose] reply From → ${msg.deliveredTo} (Delivered-To)`);
687
+ return msg.deliveredTo;
688
+ }
689
+ if (identityDomains.length === 0)
690
+ return undefined;
691
+ const candidates = [
692
+ ...((msg.to || []).map((a) => a.address)),
693
+ ...((msg.cc || []).map((a) => a.address)),
694
+ ].filter(Boolean);
695
+ for (const addr of candidates) {
696
+ const domain = addr.split("@")[1]?.toLowerCase();
697
+ if (domain && identityDomains.some(d => domain === d || domain.endsWith(`.${d}`))) {
698
+ console.log(`[compose] reply From → ${addr} (To/Cc match)`);
699
+ return addr;
700
+ }
701
+ }
702
+ console.log(`[compose] no identity match`);
703
+ return undefined;
704
+ }
705
+ // Defensive: msg.from / msg.to may be missing on rows that arrived before
706
+ // headers finished loading. Don't push undefined into init.to — that
707
+ // bubbles to the compose form as literal "undefined". Empty-out gracefully.
708
+ if (msg && mode === "reply") {
709
+ init.to = msg.from ? [msg.from] : [];
710
+ init.subject = `Re: ${cleanSubject}`;
711
+ init.bodyHtml = quoteBody(msg);
712
+ init.inReplyTo = msg.messageId || "";
713
+ init.references = [...(msg.references || []), msg.messageId].filter(Boolean);
714
+ init.fromAddress = detectReplyFrom();
715
+ }
716
+ else if (msg && mode === "replyAll") {
717
+ const toList = msg.from ? [msg.from] : [];
718
+ if (Array.isArray(msg.to)) {
719
+ for (const a of msg.to) {
720
+ if (a?.address && a.address !== msg.from?.address)
721
+ toList.push(a);
722
+ }
723
+ }
724
+ init.to = toList;
725
+ init.cc = Array.isArray(msg.cc) ? msg.cc : [];
726
+ init.subject = `Re: ${cleanSubject}`;
727
+ init.bodyHtml = quoteBody(msg);
728
+ init.inReplyTo = msg.messageId || "";
729
+ init.references = [...(msg.references || []), msg.messageId].filter(Boolean);
730
+ init.fromAddress = detectReplyFrom();
731
+ }
732
+ else if (msg && mode === "forward") {
733
+ init.subject = `Fwd: ${cleanSubject}`;
734
+ init.bodyHtml = forwardBody(msg);
735
+ init.fromAddress = detectReplyFrom();
736
+ }
737
+ // Store init data for compose window to pick up
738
+ sessionStorage.setItem("composeInit", JSON.stringify(init));
739
+ // Inline compose: load compose.html in an overlay iframe (same origin, same IPC)
740
+ // Popup windows don't work in IPC mode (custom protocol doesn't propagate to child windows)
741
+ // Title reflects mode + subject so the user can see what they're replying to
742
+ // ("Re: Stars of STEM 2026" instead of just "Compose"). Forward shows the
743
+ // forward target subject; new compose stays generic.
744
+ const titlePrefix = mode === "reply" ? "Reply" :
745
+ mode === "replyAll" ? "Reply All" :
746
+ mode === "forward" ? "Forward" :
747
+ "Compose";
748
+ const titleSubject = mode === "new" ? "" : (msg?.subject || init.subject || "");
749
+ showComposeOverlay(titleSubject ? `${titlePrefix}: ${titleSubject}` : titlePrefix);
750
+ }
751
+ function showComposeOverlay(title = "Compose") {
752
+ const wrapper = document.createElement("div");
753
+ wrapper.className = "compose-overlay";
754
+ // Full-screen on small/short screens, floating on larger
755
+ const isSmall = window.innerWidth <= 768 || window.innerHeight <= 600;
756
+ if (isSmall) {
757
+ wrapper.style.cssText = "position:fixed;inset:0;z-index:1000;display:flex;flex-direction:column;background:#fff;";
758
+ }
759
+ else {
760
+ wrapper.style.cssText = "position:fixed;bottom:0;right:16px;width:min(900px,55vw);height:min(700px,70vh);z-index:1000;border-radius:8px 8px 0 0;box-shadow:0 -4px 24px rgba(0,0,0,0.3);display:flex;flex-direction:column;resize:both;overflow:hidden;";
761
+ }
762
+ // Title bar — drag to move, close button
763
+ const titleBar = document.createElement("div");
764
+ titleBar.style.cssText = "display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:#e8ecf0;border-radius:8px 8px 0 0;cursor:move;user-select:none;flex-shrink:0;";
765
+ titleBar.textContent = title;
766
+ const closeBtn = document.createElement("button");
767
+ closeBtn.textContent = "✕";
768
+ closeBtn.title = "Save draft and close";
769
+ closeBtn.style.cssText = "background:none;border:none;font-size:16px;cursor:pointer;color:#666;padding:2px 6px;border-radius:4px;";
770
+ closeBtn.addEventListener("mouseenter", () => closeBtn.style.color = "#c00");
771
+ closeBtn.addEventListener("mouseleave", () => closeBtn.style.color = "#666");
772
+ closeBtn.addEventListener("click", () => {
773
+ // compose.ts handles the prompt (Save/Discard/Cancel) and then calls
774
+ // window.close() which is redirected to wrapper.remove() at line below.
775
+ // If the user cancels the prompt, closeCompose() is never called and
776
+ // the wrapper stays. Don't force-remove on a timer — that defeats Cancel.
777
+ try {
778
+ const win = frame.contentWindow;
779
+ if (win)
780
+ win.dispatchEvent(new Event("compose-save-and-close"));
781
+ }
782
+ catch { /* */ }
783
+ });
784
+ titleBar.appendChild(closeBtn);
785
+ // Drag to move. While dragging we set pointer-events:none on the iframe
786
+ // so mouse events don't get swallowed by the inner document the moment
787
+ // the cursor crosses into the iframe region. Without that, drag only
788
+ // worked if you stayed on the title bar pixels, which is why it felt
789
+ // broken except at the lower-right (resize grip) corner.
790
+ let dragX = 0, dragY = 0;
791
+ titleBar.addEventListener("mousedown", (e) => {
792
+ if (e.target === closeBtn)
793
+ return;
794
+ e.preventDefault();
795
+ const rect = wrapper.getBoundingClientRect();
796
+ dragX = e.clientX - rect.left;
797
+ dragY = e.clientY - rect.top;
798
+ // Clamp movement to the viewport so the title bar stays grabbable.
799
+ const clamp = (val, min, max) => Math.max(min, Math.min(max, val));
800
+ frame.style.pointerEvents = "none";
801
+ document.body.style.userSelect = "none";
802
+ const onMove = (ev) => {
803
+ ev.preventDefault();
804
+ const w = wrapper.offsetWidth;
805
+ const h = wrapper.offsetHeight;
806
+ const left = clamp(ev.clientX - dragX, 0, window.innerWidth - 40);
807
+ const top = clamp(ev.clientY - dragY, 0, window.innerHeight - 40);
808
+ wrapper.style.left = `${left}px`;
809
+ wrapper.style.top = `${top}px`;
810
+ wrapper.style.bottom = "auto";
811
+ wrapper.style.right = "auto";
812
+ };
813
+ const onUp = () => {
814
+ frame.style.pointerEvents = "";
815
+ document.body.style.userSelect = "";
816
+ document.removeEventListener("mousemove", onMove);
817
+ document.removeEventListener("mouseup", onUp);
818
+ };
819
+ document.addEventListener("mousemove", onMove);
820
+ document.addEventListener("mouseup", onUp);
821
+ });
822
+ const frame = document.createElement("iframe");
823
+ frame.src = "compose/compose.html";
824
+ frame.style.cssText = "flex:1;border:none;background:#fff;width:100%;";
825
+ // Close when compose calls window.close()
826
+ frame.addEventListener("load", () => {
827
+ try {
828
+ const win = frame.contentWindow;
829
+ if (win) {
830
+ win.close = () => wrapper.remove();
831
+ }
832
+ }
833
+ catch { /* cross-origin safety */ }
834
+ });
835
+ // Bring to front on click
836
+ wrapper.addEventListener("mousedown", () => {
837
+ document.querySelectorAll(".compose-overlay").forEach(el => el.style.zIndex = "1000");
838
+ wrapper.style.zIndex = "1001";
839
+ });
840
+ wrapper.appendChild(titleBar);
841
+ wrapper.appendChild(frame);
842
+ document.body.appendChild(wrapper);
843
+ }
844
+ // Marketing-email layout tables (deeply nested, fixed widths) collapse to
845
+ // 30-40px columns inside a phone-width compose pane and wrap text
846
+ // character-by-character. Strip styles + flatten tables before quoting.
847
+ function sanitizeQuotedBody(msg) {
848
+ let body = msg.bodyHtml || `<pre>${msg.bodyText || ""}</pre>`;
849
+ body = body.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
850
+ body = body.replace(/\s+style="[^"]*"/gi, "");
851
+ body = body.replace(/\s+class="[^"]*"/gi, "");
852
+ body = body.replace(/\s+(width|height|align|valign|bgcolor|cellpadding|cellspacing|border)="[^"]*"/gi, "");
853
+ body = body.replace(/<table[^>]*>/gi, "<div>").replace(/<\/table>/gi, "</div>");
854
+ body = body.replace(/<t[rdh][^>]*>/gi, "").replace(/<\/t[rdh]>/gi, " ");
855
+ body = body.replace(/<thead[^>]*>|<\/thead>|<tbody[^>]*>|<\/tbody>/gi, "");
856
+ return body;
857
+ }
858
+ function quoteBody(msg) {
859
+ const date = new Date(msg.date).toLocaleString();
860
+ const from = msg.from.name ? `${msg.from.name} &lt;${msg.from.address}&gt;` : msg.from.address;
861
+ const body = sanitizeQuotedBody(msg);
862
+ // Two blank lines above the quote so the cursor lands with breathing room
863
+ // between the user's reply and the "On ... wrote:" line.
864
+ return `<br><br><div class="reply"><p>On ${date}, ${from} wrote:</p><blockquote>${body}</blockquote></div>`;
865
+ }
866
+ function forwardBody(msg) {
867
+ const date = new Date(msg.date).toLocaleString();
868
+ const from = msg.from.name ? `${msg.from.name} &lt;${msg.from.address}&gt;` : msg.from.address;
869
+ const to = msg.to.map((a) => a.name ? `${a.name} &lt;${a.address}&gt;` : a.address).join(", ");
870
+ const body = sanitizeQuotedBody(msg);
871
+ return `<br><br><div class="reply"><p>---------- Forwarded message ----------<br>From: ${from}<br>Date: ${date}<br>Subject: ${msg.subject}<br>To: ${to}</p>${body}</div>`;
872
+ }
873
+ let lastDeleted = null;
874
+ let lastMoved = null;
875
+ let undoTimeout = null;
876
+ async function deleteSelectedMessages() {
877
+ const selected = getSelectedMessages();
878
+ // Fall back to single message from viewer if nothing selected in list
879
+ if (selected.length === 0) {
880
+ const current = getCurrentMessage();
881
+ if (!current)
882
+ return;
883
+ selected.push({ accountId: current.accountId, uid: current.message.uid, folderId: current.message.folderId });
884
+ }
885
+ const statusSync = document.getElementById("status-sync");
886
+ try {
887
+ // Delete on server — group by account for bulk operations
888
+ const byAccount = new Map();
889
+ for (const msg of selected) {
890
+ const uids = byAccount.get(msg.accountId) || [];
891
+ uids.push(msg.uid);
892
+ byAccount.set(msg.accountId, uids);
893
+ }
894
+ for (const [accountId, uids] of byAccount) {
895
+ await deleteMessages(accountId, uids);
896
+ }
897
+ // Undo supports the last batch
898
+ if (selected.length === 1) {
899
+ lastDeleted = { ...selected[0], subject: "" };
900
+ if (statusSync)
901
+ statusSync.textContent = `Trashed 1 message (syncing) — Ctrl+Z to undo`;
902
+ }
903
+ else {
904
+ lastDeleted = null;
905
+ if (statusSync)
906
+ statusSync.textContent = `Trashed ${selected.length} messages (syncing)`;
907
+ }
908
+ if (undoTimeout)
909
+ clearTimeout(undoTimeout);
910
+ undoTimeout = setTimeout(() => {
911
+ lastDeleted = null;
912
+ if (statusSync?.textContent?.includes("undo"))
913
+ statusSync.textContent = "";
914
+ }, 30000);
915
+ // Remove from shared state — list and viewer update automatically
916
+ messageState.removeMessages(selected);
917
+ }
918
+ catch (e) {
919
+ console.error(`Delete failed: ${e.message}`);
920
+ }
921
+ }
922
+ async function undoDelete() {
923
+ if (!lastDeleted)
924
+ return;
925
+ const { accountId, uid, folderId } = lastDeleted;
926
+ try {
927
+ await undeleteMessage(accountId, uid, folderId);
928
+ const statusSync = document.getElementById("status-sync");
929
+ if (statusSync)
930
+ statusSync.textContent = "Message restored";
931
+ lastDeleted = null;
932
+ if (undoTimeout)
933
+ clearTimeout(undoTimeout);
934
+ reloadCurrentFolder();
935
+ }
936
+ catch (e) {
937
+ console.error(`Undo failed: ${e.message}`);
938
+ }
939
+ }
940
+ async function undoMove() {
941
+ if (!lastMoved)
942
+ return;
943
+ const { messages } = lastMoved;
944
+ const statusSync = document.getElementById("status-sync");
945
+ try {
946
+ // Group by (sourceAccountId, sourceFolderId) and move each group back
947
+ const byDest = new Map();
948
+ for (const m of messages) {
949
+ const key = `${m.accountId}:${m.sourceFolderId}`;
950
+ if (!byDest.has(key))
951
+ byDest.set(key, { accountId: m.accountId, folderId: m.sourceFolderId, uids: [] });
952
+ byDest.get(key).uids.push(m.uid);
953
+ }
954
+ const { moveMessages, moveMessage } = await import("./lib/api-client.js");
955
+ for (const group of byDest.values()) {
956
+ if (group.uids.length === 1)
957
+ await moveMessage(group.accountId, group.uids[0], group.folderId);
958
+ else
959
+ await moveMessages(group.accountId, group.uids, group.folderId);
960
+ }
961
+ if (statusSync)
962
+ statusSync.textContent = `Undid move of ${messages.length} message${messages.length !== 1 ? "s" : ""}`;
963
+ lastMoved = null;
964
+ if (undoTimeout)
965
+ clearTimeout(undoTimeout);
966
+ reloadCurrentFolder();
967
+ }
968
+ catch (e) {
969
+ console.error(`Undo move failed: ${e.message}`);
970
+ if (statusSync)
971
+ statusSync.textContent = `Undo move failed: ${e.message}`;
972
+ }
973
+ }
974
+ // Listen for the "mailx-moved" custom event emitted by folder-tree's drop
975
+ // handler so Ctrl+Z can reverse the most recent move.
976
+ document.addEventListener("mailx-moved", (e) => {
977
+ lastMoved = e.detail;
978
+ lastDeleted = null; // Ctrl+Z undoes whichever came last
979
+ if (undoTimeout)
980
+ clearTimeout(undoTimeout);
981
+ undoTimeout = setTimeout(() => { lastMoved = null; }, 60000);
982
+ });
983
+ document.getElementById("btn-delete")?.addEventListener("click", deleteSelectedMessages);
984
+ // Same handlers also bound to the top-toolbar icons so delete/spam work
985
+ // regardless of whether a message is open in the viewer. Useful for quick
986
+ // triage from a list-only view.
987
+ document.getElementById("btn-tb-delete")?.addEventListener("click", deleteSelectedMessages);
988
+ document.getElementById("btn-tb-spam")?.addEventListener("click", spamSelectedMessages);
989
+ // ── Flag toggle ──
990
+ document.getElementById("btn-flag")?.addEventListener("click", async () => {
991
+ const sel = messageState.getSelected();
992
+ if (!sel)
993
+ return;
994
+ const isFlagged = sel.flags.includes("\\Flagged");
995
+ const newFlags = isFlagged
996
+ ? sel.flags.filter((f) => f !== "\\Flagged")
997
+ : [...sel.flags, "\\Flagged"];
998
+ try {
999
+ await updateFlags(sel.accountId, sel.uid, newFlags);
1000
+ sel.flags = newFlags;
1001
+ messageState.updateMessageFlags(sel.accountId, sel.uid, newFlags);
1002
+ // Update the message-list row's flag indicator
1003
+ const row = document.querySelector(`.ml-row[data-uid="${sel.uid}"][data-account-id="${sel.accountId}"]`);
1004
+ if (row) {
1005
+ row.classList.toggle("flagged", newFlags.includes("\\Flagged"));
1006
+ const flagEl = row.querySelector(".ml-flag");
1007
+ if (flagEl)
1008
+ flagEl.textContent = newFlags.includes("\\Flagged") ? "\u2605" : "\u2606";
1009
+ }
1010
+ }
1011
+ catch (e) {
1012
+ console.error(`Flag toggle failed: ${e.message}`);
1013
+ }
1014
+ });
1015
+ async function spamSelectedMessages() {
1016
+ console.log("[spam] click — finding selection");
1017
+ const selected = getSelectedMessages();
1018
+ if (selected.length === 0) {
1019
+ const current = getCurrentMessage();
1020
+ if (!current) {
1021
+ console.warn("[spam] no message selected and none in viewer — nothing to do");
1022
+ alert("No message selected. Click a message first, then the spam button.");
1023
+ return;
1024
+ }
1025
+ selected.push({ accountId: current.accountId, uid: current.message.uid, folderId: current.message.folderId });
1026
+ }
1027
+ console.log(`[spam] marking ${selected.length} message(s):`, selected);
1028
+ const statusSync = document.getElementById("status-sync");
1029
+ // Optimistic: remove from list immediately so the user sees action happen.
1030
+ // If the IPC fails, put them back. This matches local-first — the server
1031
+ // sync is a background detail, the user's action should feel instant.
1032
+ const snapshot = [...selected];
1033
+ messageState.removeMessages(selected);
1034
+ try {
1035
+ const byAccount = new Map();
1036
+ for (const msg of snapshot) {
1037
+ const uids = byAccount.get(msg.accountId) || [];
1038
+ uids.push(msg.uid);
1039
+ byAccount.set(msg.accountId, uids);
1040
+ }
1041
+ for (const [accountId, uids] of byAccount) {
1042
+ const result = await markAsSpamMessages(accountId, uids);
1043
+ console.log(`[spam] ${accountId}: moved ${result?.moved ?? uids.length} to folderId=${result?.targetFolderId}`);
1044
+ }
1045
+ if (statusSync)
1046
+ statusSync.textContent = `Spam: ${snapshot.length} queued — pending server sync`;
1047
+ }
1048
+ catch (e) {
1049
+ console.error(`[spam] failed:`, e);
1050
+ if (statusSync)
1051
+ statusSync.textContent = `Spam failed: ${e?.message || e}`;
1052
+ alert(`Mark-as-spam failed: ${e?.message || e}\n\n${selected.length} message(s) stayed in the list; check Settings → account spam folder and accounts.jsonc.`);
1053
+ // Best-effort restore: re-set the messages we optimistically removed.
1054
+ // removeMessages has no inverse in message-state, so we'll rely on the
1055
+ // next folder reload to repopulate. Surface the failure clearly.
1056
+ }
1057
+ }
1058
+ document.getElementById("btn-spam")?.addEventListener("click", spamSelectedMessages);
1059
+ /** Show/hide the Spam button based on whether the current account has "spam" configured. */
1060
+ async function refreshSpamButtonVisibility() {
1061
+ const btn = document.getElementById("btn-spam");
1062
+ if (!btn)
1063
+ return;
1064
+ const current = getCurrentMessage();
1065
+ const accountId = current?.accountId || currentAccountId;
1066
+ if (!accountId) {
1067
+ btn.hidden = true;
1068
+ return;
1069
+ }
1070
+ try {
1071
+ const accounts = await getAccounts();
1072
+ const acct = accounts.find((a) => a.id === accountId);
1073
+ btn.hidden = !acct?.spam;
1074
+ }
1075
+ catch {
1076
+ btn.hidden = true;
1077
+ }
1078
+ }
1079
+ document.addEventListener("mailx-message-shown", refreshSpamButtonVisibility);
1080
+ document.addEventListener("mailx-folder-changed", refreshSpamButtonVisibility);
1081
+ // Q100 placeholder — append a row to ~/.mailx/spam.csv for later analysis.
1082
+ // No folder move, no flag change, no auto-delete. Button is always visible
1083
+ // (no configuration required; unlike btn-spam which needs a junk folder).
1084
+ document.getElementById("btn-spam-report")?.addEventListener("click", async () => {
1085
+ const current = getCurrentMessage();
1086
+ const msg = current?.message;
1087
+ const accountId = current?.accountId;
1088
+ if (!msg || !accountId)
1089
+ return;
1090
+ const btn = document.getElementById("btn-spam-report");
1091
+ const originalLabel = btn.textContent;
1092
+ btn.disabled = true;
1093
+ btn.textContent = "…";
1094
+ try {
1095
+ const { recordSpamReport } = await import("./lib/api-client.js");
1096
+ await recordSpamReport(accountId, msg.uid, msg.folderId);
1097
+ btn.textContent = "✓";
1098
+ const status = document.getElementById("status-sync");
1099
+ if (status)
1100
+ status.textContent = "Logged to ~/.rmfmail/spam.csv";
1101
+ setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 1500);
1102
+ }
1103
+ catch (e) {
1104
+ btn.textContent = "✗";
1105
+ const status = document.getElementById("status-sync");
1106
+ if (status)
1107
+ status.textContent = `Spam log failed: ${e?.message || e}`;
1108
+ setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 2500);
1109
+ }
1110
+ });
1111
+ document.getElementById("btn-compose")?.addEventListener("click", () => openCompose("new"));
1112
+ document.getElementById("btn-mark-unread")?.addEventListener("click", () => {
1113
+ // Toggle \Seen on the currently-selected message. Mirrors the R
1114
+ // keyboard shortcut and the right-click "Mark unread" menu item, but
1115
+ // as a visible toolbar button so users discover the behavior.
1116
+ const sel = messageState.getSelected();
1117
+ if (!sel)
1118
+ return;
1119
+ const isSeen = sel.flags.includes("\\Seen");
1120
+ const newFlags = isSeen
1121
+ ? sel.flags.filter((f) => f !== "\\Seen")
1122
+ : [...sel.flags, "\\Seen"];
1123
+ updateFlags(sel.accountId, sel.uid, newFlags).then(() => {
1124
+ sel.flags = newFlags;
1125
+ messageState.updateMessageFlags(sel.accountId, sel.uid, newFlags);
1126
+ const row = document.querySelector(`.ml-row[data-uid="${sel.uid}"][data-account-id="${sel.accountId}"]`);
1127
+ if (row)
1128
+ row.classList.toggle("unread", !newFlags.includes("\\Seen"));
1129
+ }).catch(() => { });
1130
+ });
1131
+ document.getElementById("btn-reply")?.addEventListener("click", () => openCompose("reply"));
1132
+ document.getElementById("btn-reply-all")?.addEventListener("click", () => openCompose("replyAll"));
1133
+ document.getElementById("btn-forward")?.addEventListener("click", () => openCompose("forward"));
1134
+ // ── Icon rail wiring ──
1135
+ // Rail is the always-visible vertical bar on the far left (Thunderbird/Dovecot
1136
+ // style). Mostly mirrors toolbar/menu actions for one-click access; calendar /
1137
+ // tasks / contacts buttons are placeholders until those features ship.
1138
+ document.getElementById("rail-compose")?.addEventListener("click", () => openCompose("new"));
1139
+ document.getElementById("rail-inbox")?.addEventListener("click", () => {
1140
+ // Trigger the existing folder-tree click on the first inbox folder.
1141
+ const inbox = document.querySelector('.folder-tree .folder-item[data-special-use="inbox"]');
1142
+ inbox?.click();
1143
+ });
1144
+ document.getElementById("rail-unified")?.addEventListener("click", () => {
1145
+ const unified = document.querySelector('.folder-tree .all-inboxes')
1146
+ || document.getElementById("ft-all-inboxes");
1147
+ unified?.click();
1148
+ });
1149
+ document.getElementById("rail-contacts")?.addEventListener("click", async () => {
1150
+ const { openAddressBook } = await import("./components/address-book.js");
1151
+ openAddressBook();
1152
+ setRailActive("rail-contacts");
1153
+ });
1154
+ // Q114 decided 2026-04-24: full-screen calendar/tasks modals are
1155
+ // temporarily retired — the right-docked sidebar (calendar-sidebar.ts)
1156
+ // owns both views. Rail buttons now just reveal the sidebar. Files kept
1157
+ // (`calendar.ts`, `tasks.ts`) for potential revival; not imported.
1158
+ document.getElementById("rail-calendar")?.addEventListener("click", async () => {
1159
+ const { showCalendarSidebar } = await import("./components/calendar-sidebar.js");
1160
+ await showCalendarSidebar();
1161
+ // Flip the View-menu checkbox so the on-state stays coherent across paths.
1162
+ const optSidebar = document.getElementById("opt-calendar-sidebar");
1163
+ if (optSidebar)
1164
+ optSidebar.checked = true;
1165
+ setRailActive("rail-calendar");
1166
+ });
1167
+ document.getElementById("rail-tasks")?.addEventListener("click", async () => {
1168
+ const { showCalendarSidebar } = await import("./components/calendar-sidebar.js");
1169
+ await showCalendarSidebar();
1170
+ // Scroll the sidebar to the tasks section if possible.
1171
+ document.getElementById("cal-side-tasks")?.scrollIntoView({ block: "start", behavior: "smooth" });
1172
+ const optSidebar = document.getElementById("opt-calendar-sidebar");
1173
+ if (optSidebar)
1174
+ optSidebar.checked = true;
1175
+ setRailActive("rail-tasks");
1176
+ });
1177
+ /** Open a toolbar dropdown (View / Settings) anchored to a rail icon.
1178
+ *
1179
+ * In wide mode the toolbar buttons own the menu and do their own toggle.
1180
+ * In narrow mode the toolbar is `display:none`, so the dropdown — which
1181
+ * lives as a child of `.tb-menu` inside the toolbar — is invisible no
1182
+ * matter what `hidden` is set to. Forwarding the rail click to the
1183
+ * toolbar button toggled `hidden` but the user still saw nothing, hence
1184
+ * the "setup icon does nothing" bug report.
1185
+ *
1186
+ * The fix: when a rail icon opens the menu, reparent the dropdown to
1187
+ * `<body>` (so the toolbar's display:none can't hide it), switch to
1188
+ * `position: fixed` anchored to the icon, and let the existing menu
1189
+ * handlers fire normally — same dropdown DOM, same listeners, same
1190
+ * content. We never put the dropdown back: the toolbar handler also
1191
+ * works on a body-attached dropdown (it's just toggling .hidden), and
1192
+ * re-parenting on every toggle would defeat any focus state inside. */
1193
+ function openMenuFromRail(dropdownId, anchor) {
1194
+ const dd = document.getElementById(dropdownId);
1195
+ if (!dd)
1196
+ return;
1197
+ if (!dd.hidden) {
1198
+ dd.hidden = true;
1199
+ return;
1200
+ }
1201
+ // Close sibling rail-opened menus so two don't stack.
1202
+ for (const id of ["settings-dropdown", "view-dropdown", "restart-dropdown"]) {
1203
+ const other = document.getElementById(id);
1204
+ if (other && other !== dd)
1205
+ other.hidden = true;
1206
+ }
1207
+ if (dd.parentElement?.classList.contains("tb-menu")) {
1208
+ document.body.appendChild(dd);
1209
+ }
1210
+ const rect = anchor.getBoundingClientRect();
1211
+ dd.style.position = "fixed";
1212
+ // Anchor to the right of the rail icon. Clamp horizontally so the menu
1213
+ // doesn't fall off the right edge on narrow screens.
1214
+ const minWidth = 220;
1215
+ const left = Math.min(rect.right + 6, window.innerWidth - minWidth - 8);
1216
+ dd.style.left = `${Math.max(8, left)}px`;
1217
+ // Reset any prior anchoring so measurement is clean.
1218
+ dd.style.top = "";
1219
+ dd.style.bottom = "";
1220
+ dd.style.maxHeight = "";
1221
+ dd.style.overflowY = "";
1222
+ dd.style.zIndex = "10000";
1223
+ dd.hidden = false;
1224
+ // Bottom-rail icons (settings, theme, help) sit near the viewport floor.
1225
+ // Anchoring `top: rect.top` makes the menu spill off the bottom — the
1226
+ // user only sees the first section ("Theme") and assumes the rest of
1227
+ // the menu is missing. Measure after revealing, then anchor whichever
1228
+ // edge has more room. Cap height with overflow so a tall menu still
1229
+ // fits on a small viewport.
1230
+ const ddHeight = dd.getBoundingClientRect().height;
1231
+ const spaceBelow = window.innerHeight - rect.top - 8;
1232
+ const spaceAbove = rect.bottom - 8;
1233
+ if (ddHeight > spaceBelow && spaceAbove > spaceBelow) {
1234
+ dd.style.bottom = `${Math.max(8, window.innerHeight - rect.bottom)}px`;
1235
+ if (ddHeight > spaceAbove) {
1236
+ dd.style.maxHeight = `${spaceAbove}px`;
1237
+ dd.style.overflowY = "auto";
1238
+ }
1239
+ }
1240
+ else {
1241
+ dd.style.top = `${Math.max(8, rect.top)}px`;
1242
+ if (ddHeight > spaceBelow) {
1243
+ dd.style.maxHeight = `${spaceBelow}px`;
1244
+ dd.style.overflowY = "auto";
1245
+ }
1246
+ }
1247
+ }
1248
+ document.getElementById("rail-settings")?.addEventListener("click", (e) => {
1249
+ e.stopPropagation();
1250
+ openMenuFromRail("settings-dropdown", e.currentTarget);
1251
+ });
1252
+ document.getElementById("rail-view")?.addEventListener("click", (e) => {
1253
+ e.stopPropagation();
1254
+ openMenuFromRail("view-dropdown", e.currentTarget);
1255
+ });
1256
+ document.getElementById("rail-help")?.addEventListener("click", () => {
1257
+ document.getElementById("btn-about")?.click();
1258
+ });
1259
+ document.getElementById("rail-theme")?.addEventListener("click", () => {
1260
+ // Rail theme icon cycles system → dark → light → system. Settings menu
1261
+ // exposes the same three as radio buttons for direct selection.
1262
+ const root = document.documentElement;
1263
+ const cur = root.getAttribute("data-theme") || "system";
1264
+ const next = cur === "system" ? "dark" : cur === "dark" ? "light" : "system";
1265
+ applyTheme(next);
1266
+ });
1267
+ function applyTheme(theme) {
1268
+ document.documentElement.setAttribute("data-theme", theme);
1269
+ try {
1270
+ localStorage.setItem("mailx-theme", theme);
1271
+ }
1272
+ catch { /* private mode */ }
1273
+ // Reflect in the Settings menu radios so the two paths stay in sync.
1274
+ const radio = document.getElementById(`opt-theme-${theme}`);
1275
+ if (radio)
1276
+ radio.checked = true;
1277
+ }
1278
+ // Restore saved theme + wire the Settings radios. Defaults to "system".
1279
+ (() => {
1280
+ const saved = (() => { try {
1281
+ return localStorage.getItem("mailx-theme") || "system";
1282
+ }
1283
+ catch {
1284
+ return "system";
1285
+ } })();
1286
+ applyTheme(saved);
1287
+ for (const t of ["system", "light", "dark"]) {
1288
+ document.getElementById(`opt-theme-${t}`)?.addEventListener("change", (e) => {
1289
+ if (e.target.checked)
1290
+ applyTheme(t);
1291
+ });
1292
+ }
1293
+ })();
1294
+ // Highlight the current rail target. For now just inbox is the default; once
1295
+ // calendar/tasks ship, update this on view change.
1296
+ function setRailActive(id) {
1297
+ document.querySelectorAll(".rail-btn[data-active]").forEach(el => el.removeAttribute("data-active"));
1298
+ document.getElementById(id)?.setAttribute("data-active", "true");
1299
+ }
1300
+ document.addEventListener("mailx-folder-changed", () => setRailActive("rail-inbox"));
1301
+ // Context menu events from message-list right-click
1302
+ document.addEventListener("mailx-compose", ((e) => {
1303
+ if (e.detail.mode === "draft" && sessionStorage.getItem("composeInit")) {
1304
+ // Draft already stored by viewer — just show overlay
1305
+ showComposeOverlay();
1306
+ }
1307
+ else {
1308
+ openCompose(e.detail.mode);
1309
+ }
1310
+ }));
1311
+ document.addEventListener("mailx-delete", () => deleteSelectedMessages());
1312
+ // ── Search ──
1313
+ let searchTimeout;
1314
+ const searchInput = document.getElementById("search-input");
1315
+ const searchScope = document.getElementById("search-scope");
1316
+ function doSearch(immediate = false) {
1317
+ const query = searchInput.value.trim();
1318
+ if (query.length === 0) {
1319
+ reloadCurrentFolder();
1320
+ return;
1321
+ }
1322
+ if (query.length < 2 && !immediate)
1323
+ return;
1324
+ // P20: orthogonal "Server" checkbox. When checked, scope switches to
1325
+ // "server" which spans all folders on all accounts. Local scope dropdown
1326
+ // is unchanged (all/current) for the local-only case.
1327
+ const serverCheck = document.getElementById("search-server-too");
1328
+ const localScope = searchScope?.value || "all";
1329
+ const effectiveScope = serverCheck?.checked ? "server" : localScope;
1330
+ // "This folder" scope: instant client-side filter on debounce, server search on Enter
1331
+ if (effectiveScope === "current" && !immediate) {
1332
+ // Client-side filter of visible rows
1333
+ const body = document.getElementById("ml-body");
1334
+ if (body) {
1335
+ const lower = query.toLowerCase();
1336
+ for (const row of body.querySelectorAll(".ml-row")) {
1337
+ const text = row.textContent?.toLowerCase() || "";
1338
+ row.classList.toggle("filter-hidden", !text.includes(lower));
1339
+ }
1340
+ }
1341
+ return;
1342
+ }
1343
+ loadSearchResults(query, effectiveScope, currentAccountId, currentFolderId);
1344
+ setTitle(`mailx - Search: ${query}`);
1345
+ }
1346
+ // Track current folder for scoped search
1347
+ let currentAccountId = "";
1348
+ let currentFolderId = 0;
1349
+ let reloadDebounceTimer = null;
1350
+ searchInput?.addEventListener("input", () => {
1351
+ clearTimeout(searchTimeout);
1352
+ if (searchInput.value.trim() === "") {
1353
+ // Cleared — reset immediately, no debounce. Must exit search mode
1354
+ // first; otherwise reloadCurrentFolder() sees searchMode=true and
1355
+ // re-runs the stale query (user-reported regression 2026-04-24).
1356
+ clearSearchMode();
1357
+ const body = document.getElementById("ml-body");
1358
+ if (body)
1359
+ body.querySelectorAll(".filter-hidden").forEach(r => r.classList.remove("filter-hidden"));
1360
+ reloadCurrentFolder();
1361
+ setTitle("mailx");
1362
+ }
1363
+ else {
1364
+ searchTimeout = setTimeout(() => doSearch(false), 300);
1365
+ }
1366
+ });
1367
+ searchInput?.addEventListener("keydown", (e) => {
1368
+ if (e.key === "Enter") {
1369
+ clearTimeout(searchTimeout);
1370
+ doSearch(true);
1371
+ }
1372
+ if (e.key === "Escape") {
1373
+ searchInput.value = "";
1374
+ clearSearchMode();
1375
+ // Clear any client-side filters
1376
+ const body = document.getElementById("ml-body");
1377
+ if (body)
1378
+ body.querySelectorAll(".filter-hidden").forEach(r => r.classList.remove("filter-hidden"));
1379
+ reloadCurrentFolder();
1380
+ setTitle("mailx");
1381
+ }
1382
+ });
1383
+ // Re-run the active search when the scope dropdown or "server too" checkbox
1384
+ // flips. Without this, switching all/current/server after typing the query
1385
+ // left the old result set on screen — the controls looked like they did
1386
+ // nothing. Treat the change as `immediate=true` so the user sees the new
1387
+ // scope's results without having to retype Enter; clear any client-side
1388
+ // filter-hidden flags from the prior "current folder" path so the row set
1389
+ // resets cleanly.
1390
+ function rerunActiveSearch() {
1391
+ if (!searchInput || searchInput.value.trim() === "")
1392
+ return;
1393
+ const body = document.getElementById("ml-body");
1394
+ if (body)
1395
+ body.querySelectorAll(".filter-hidden").forEach(r => r.classList.remove("filter-hidden"));
1396
+ clearTimeout(searchTimeout);
1397
+ doSearch(true);
1398
+ }
1399
+ searchScope?.addEventListener("change", rerunActiveSearch);
1400
+ document.getElementById("search-server-too")?.addEventListener("change", rerunActiveSearch);
1401
+ // Message state handles move/delete — no manual event listener needed
1402
+ // ── Folder filter ──
1403
+ const ftFilterInput = document.getElementById("ft-filter-input");
1404
+ if (ftFilterInput) {
1405
+ ftFilterInput.addEventListener("input", () => {
1406
+ const query = ftFilterInput.value.toLowerCase();
1407
+ const tree = document.getElementById("folder-tree");
1408
+ if (!tree)
1409
+ return;
1410
+ if (!query) {
1411
+ // Clear filter — show everything
1412
+ tree.querySelectorAll(".ft-filter-hidden").forEach(el => el.classList.remove("ft-filter-hidden"));
1413
+ return;
1414
+ }
1415
+ // Hide all folders first, then show matches + their parent accounts
1416
+ const folders = tree.querySelectorAll(".ft-folder");
1417
+ const accounts = tree.querySelectorAll(".ft-account");
1418
+ for (const acct of accounts)
1419
+ acct.classList.add("ft-filter-hidden");
1420
+ for (const f of folders)
1421
+ f.classList.add("ft-filter-hidden");
1422
+ for (const f of folders) {
1423
+ const name = f.querySelector(".ft-folder-name")?.textContent?.toLowerCase() || "";
1424
+ if (name.includes(query)) {
1425
+ f.classList.remove("ft-filter-hidden");
1426
+ // Show parent account
1427
+ const acct = f.closest(".ft-account");
1428
+ if (acct)
1429
+ acct.classList.remove("ft-filter-hidden");
1430
+ }
1431
+ }
1432
+ // Also show unified inbox if it matches
1433
+ const unified = tree.querySelector(".ft-unified");
1434
+ if (unified) {
1435
+ const text = unified.textContent?.toLowerCase() || "";
1436
+ unified.classList.toggle("ft-filter-hidden", !text.includes(query));
1437
+ }
1438
+ });
1439
+ ftFilterInput.addEventListener("keydown", (e) => {
1440
+ if (e.key === "Escape") {
1441
+ ftFilterInput.value = "";
1442
+ ftFilterInput.dispatchEvent(new Event("input"));
1443
+ }
1444
+ });
1445
+ }
1446
+ // ── Open links from email body in system browser ──
1447
+ window.addEventListener("message", (e) => {
1448
+ // Relay traces from iframes (compose) to Node via our working bridge.
1449
+ // The iframe calls logClientEvent which tries its own bridge first; if
1450
+ // that path is broken it also posts here as backup. Tag gets a `via-relay`
1451
+ // suffix when the iframe couldn't reach its own bridge — that alone
1452
+ // diagnoses whether the iframe bridge works.
1453
+ if (e.data?.type === "mailx-trace" && typeof e.data.tag === "string") {
1454
+ const relayTag = e.data.bridged ? e.data.tag : `${e.data.tag} (via-relay)`;
1455
+ logClientEvent(relayTag, e.data.data);
1456
+ return;
1457
+ }
1458
+ // Compose-send relay: iframe posts the send request here because its own
1459
+ // bridge call to sendMessage was failing to reach Node. This window's
1460
+ // bridge is proven (getAccounts / getOutboxStatus run every few seconds
1461
+ // with no failures), so we do the IPC from here and post the result back
1462
+ // to the iframe via its source. `e.source` is the iframe's window; use it
1463
+ // so targeting works even if the iframe moves in the DOM.
1464
+ // S61 2026-04-24: parent-relay compose close. On Android the
1465
+ // window.close() override applied in `frame.onload` doesn't always fire
1466
+ // (WebView2 / MAUI WebView dispatches close to the shell in some cases),
1467
+ // leaving the compose overlay stuck after Send. postMessage is reliable;
1468
+ // compose.ts's closeCompose() posts this, and we find-and-remove the
1469
+ // overlay whose iframe window matches e.source.
1470
+ if (e.data?.type === "mailx-compose-close") {
1471
+ const src = e.source;
1472
+ document.querySelectorAll(".compose-overlay").forEach(el => {
1473
+ const iframe = el.querySelector("iframe");
1474
+ if (!src || iframe?.contentWindow === src)
1475
+ el.remove();
1476
+ });
1477
+ return;
1478
+ }
1479
+ if (e.data?.type === "mailx-compose-send" && e.data.id && e.data.body) {
1480
+ const src = e.source;
1481
+ const id = e.data.id;
1482
+ logClientEvent("relay-compose-send-received", { id });
1483
+ (async () => {
1484
+ try {
1485
+ await apiSendMessage(e.data.body);
1486
+ logClientEvent("relay-compose-send-ok", { id });
1487
+ src?.postMessage({ type: "mailx-compose-send-result", id, ok: true }, "*");
1488
+ }
1489
+ catch (err) {
1490
+ const msg = err?.message || String(err);
1491
+ logClientEvent("relay-compose-send-error", { id, error: msg });
1492
+ src?.postMessage({ type: "mailx-compose-send-result", id, ok: false, error: msg }, "*");
1493
+ }
1494
+ })();
1495
+ return;
1496
+ }
1497
+ // Generic IPC relay: the iframe's api-client routes every IPC call through
1498
+ // postMessage when it's running in a child frame. Same reason as the
1499
+ // compose-send relay — sendMessage wasn't the only method the iframe's
1500
+ // bridge dropped; saveDraft hit the same wall ("Draft save failed: mailxapi
1501
+ // timeout"). This handler invokes the named method on THIS window's
1502
+ // mailxapi and posts the result back to the iframe.
1503
+ if (e.data?.type === "mailx-ipc" && e.data.id && e.data.method) {
1504
+ const src = e.source;
1505
+ const { id, method, args } = e.data;
1506
+ const bridge = window.mailxapi;
1507
+ const fn = bridge?.[method];
1508
+ if (typeof fn !== "function") {
1509
+ src?.postMessage({ type: "mailx-ipc-result", id, ok: false, error: `parent bridge has no method "${method}"` }, "*");
1510
+ return;
1511
+ }
1512
+ try {
1513
+ const result = fn.apply(bridge, args || []);
1514
+ Promise.resolve(result).then((value) => src?.postMessage({ type: "mailx-ipc-result", id, ok: true, result: value }, "*"), (err) => src?.postMessage({ type: "mailx-ipc-result", id, ok: false, error: err?.message || String(err) }, "*"));
1515
+ }
1516
+ catch (err) {
1517
+ src?.postMessage({ type: "mailx-ipc-result", id, ok: false, error: err?.message || String(err) }, "*");
1518
+ }
1519
+ return;
1520
+ }
1521
+ if (e.data?.type === "openLink" && e.data.url) {
1522
+ window.open(e.data.url, "_blank", "noopener,noreferrer");
1523
+ }
1524
+ if (e.data?.type === "linkClick" && e.data.url) {
1525
+ const url = e.data.url;
1526
+ if (window.mailxapi?.platform === "android") {
1527
+ // Android: use mailxapi:// bridge scheme — OnNavigating intercepts it
1528
+ // and opens in system browser. Raw http:// in sub-frames doesn't trigger OnNavigating.
1529
+ const f = document.createElement("iframe");
1530
+ f.style.display = "none";
1531
+ f.src = `mailxapi://openurl?url=${encodeURIComponent(url)}`;
1532
+ document.body.appendChild(f);
1533
+ setTimeout(() => f.remove(), 500);
1534
+ }
1535
+ else {
1536
+ window.open(url, "_blank", "noopener,noreferrer");
1537
+ }
1538
+ }
1539
+ if (e.data?.type === "linkContextMenu") {
1540
+ // C29: right-click in body iframe → Open / Save / Copy URL menu.
1541
+ // Iframe's clientX/Y is relative to the iframe; translate to viewport.
1542
+ let iframeRect = null;
1543
+ for (const f of Array.from(document.querySelectorAll("iframe"))) {
1544
+ if (f.contentWindow === e.source) {
1545
+ iframeRect = f.getBoundingClientRect();
1546
+ break;
1547
+ }
1548
+ }
1549
+ const x = (iframeRect?.left || 0) + (e.data.x || 0);
1550
+ const y = (iframeRect?.top || 0) + (e.data.y || 0);
1551
+ const url = e.data.url || "";
1552
+ // Find a sensible filename for the Save action.
1553
+ const guessName = (() => {
1554
+ try {
1555
+ const u = new URL(url);
1556
+ const last = u.pathname.split("/").pop() || "";
1557
+ return last && last.includes(".") ? last : "";
1558
+ }
1559
+ catch {
1560
+ return "";
1561
+ }
1562
+ })();
1563
+ const items = [
1564
+ { label: "Open in browser", action: () => {
1565
+ window.open(url, "_blank", "noopener,noreferrer");
1566
+ } },
1567
+ { label: guessName ? `Save "${guessName}"…` : "Save link as…", action: () => {
1568
+ // Trigger a download via anchor with download attr.
1569
+ const a = document.createElement("a");
1570
+ a.href = url;
1571
+ if (guessName)
1572
+ a.download = guessName;
1573
+ else
1574
+ a.download = "";
1575
+ a.style.display = "none";
1576
+ document.body.appendChild(a);
1577
+ a.click();
1578
+ setTimeout(() => a.remove(), 1000);
1579
+ } },
1580
+ { label: "Copy URL", action: async () => {
1581
+ try {
1582
+ await navigator.clipboard.writeText(url);
1583
+ }
1584
+ catch {
1585
+ prompt("URL:", url);
1586
+ }
1587
+ } },
1588
+ { label: "Copy link text", action: async () => {
1589
+ try {
1590
+ await navigator.clipboard.writeText(e.data.text || url);
1591
+ }
1592
+ catch {
1593
+ prompt("Text:", e.data.text || url);
1594
+ }
1595
+ } },
1596
+ ];
1597
+ // Build a tiny inline menu (showContextMenu would do but it's in components/).
1598
+ const menu = document.createElement("div");
1599
+ menu.style.cssText = `position:fixed;z-index:2400;background:var(--color-bg);border:1px solid var(--color-border);border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.2);padding:4px 0;font-size:13px;min-width:180px;`;
1600
+ menu.style.left = `${Math.min(x, window.innerWidth - 200)}px`;
1601
+ menu.style.top = `${Math.min(y, window.innerHeight - 200)}px`;
1602
+ // mousedown inside the menu must NOT reach the document-level
1603
+ // dismiss handler — otherwise the menu is removed before click
1604
+ // fires on the row and the action silently no-ops (user report
1605
+ // 2026-04-24). Stop propagation at the menu root covers every row.
1606
+ menu.addEventListener("mousedown", (ev) => ev.stopPropagation());
1607
+ for (const it of items) {
1608
+ const row = document.createElement("div");
1609
+ row.textContent = it.label;
1610
+ row.style.cssText = `padding:6px 12px;cursor:pointer;`;
1611
+ row.addEventListener("mouseenter", () => row.style.background = "var(--color-bg-hover)");
1612
+ row.addEventListener("mouseleave", () => row.style.background = "");
1613
+ row.addEventListener("click", () => { menu.remove(); it.action(); });
1614
+ menu.appendChild(row);
1615
+ }
1616
+ document.body.appendChild(menu);
1617
+ const dismiss = () => { menu.remove(); document.removeEventListener("mousedown", dismiss); document.removeEventListener("keydown", dismiss); };
1618
+ setTimeout(() => {
1619
+ document.addEventListener("mousedown", dismiss);
1620
+ document.addEventListener("keydown", dismiss);
1621
+ }, 0);
1622
+ return;
1623
+ }
1624
+ if (e.data?.type === "mailx-send-error") {
1625
+ // Send failed AFTER compose closed (fire-and-forget model). Surface in
1626
+ // the status bar so the user sees something instead of the silence.
1627
+ const statusSync = document.getElementById("status-sync");
1628
+ if (statusSync) {
1629
+ statusSync.textContent = `Send failed: ${e.data.message}`;
1630
+ statusSync.style.color = "oklch(0.65 0.2 25)";
1631
+ }
1632
+ return;
1633
+ }
1634
+ if (e.data?.type === "linkHover") {
1635
+ // Cancel any pending show — every hoverover/hoverout from the iframe
1636
+ // triggers this branch. Without the timer, the popover appears
1637
+ // instantly and lingers when the user moves to do anything else,
1638
+ // including punching through the compose overlay (which sits at
1639
+ // z-index 1000 — popover was at 10000, hence the bug in the
1640
+ // screenshot). Now: 500ms hover delay; suppressed entirely when
1641
+ // any overlay (compose, modal) is open; auto-dismissed on click,
1642
+ // scroll, blur, or any keypress.
1643
+ const w = window;
1644
+ if (w._linkHoverShowTimer) {
1645
+ clearTimeout(w._linkHoverShowTimer);
1646
+ w._linkHoverShowTimer = null;
1647
+ }
1648
+ let pop = document.getElementById("link-hover-popover");
1649
+ const hidePop = () => { if (pop)
1650
+ pop.style.display = "none"; };
1651
+ if (!e.data.url) {
1652
+ hidePop();
1653
+ return;
1654
+ }
1655
+ // Suppress when compose / modal overlay is up — user shouldn't see
1656
+ // a tooltip for a link they can't reach without dismissing first.
1657
+ if (document.querySelector(".compose-overlay, .mailx-modal-backdrop")) {
1658
+ hidePop();
1659
+ return;
1660
+ }
1661
+ const data = e.data;
1662
+ const source = e.source;
1663
+ w._linkHoverShowTimer = setTimeout(() => {
1664
+ // Re-check overlay state at fire time — overlay may have appeared
1665
+ // during the 500ms wait.
1666
+ if (document.querySelector(".compose-overlay, .mailx-modal-backdrop"))
1667
+ return;
1668
+ if (!pop) {
1669
+ pop = document.createElement("div");
1670
+ pop.id = "link-hover-popover";
1671
+ // z-index 500 — above the message body iframe (no z-index)
1672
+ // but BELOW the compose overlay (z-index 1000) and modals (2000).
1673
+ pop.style.cssText = "position:fixed;z-index:500;max-width:520px;padding:6px 10px;background:var(--color-surface,#fff);color:var(--color-text,#000);border:1px solid var(--color-border,#888);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.18);font-size:12px;line-height:1.4;word-break:break-all;pointer-events:none;";
1674
+ document.body.appendChild(pop);
1675
+ // One-time dismissers on the popover lifetime.
1676
+ const dismiss = () => hidePop();
1677
+ document.addEventListener("mousedown", dismiss, true);
1678
+ document.addEventListener("scroll", dismiss, true);
1679
+ document.addEventListener("keydown", dismiss, true);
1680
+ window.addEventListener("blur", dismiss);
1681
+ }
1682
+ pop.textContent = data.url;
1683
+ pop.style.display = "block";
1684
+ let iframeRect = null;
1685
+ for (const f of Array.from(document.querySelectorAll("iframe"))) {
1686
+ if (f.contentWindow === source) {
1687
+ iframeRect = f.getBoundingClientRect();
1688
+ break;
1689
+ }
1690
+ }
1691
+ const r = data.rect;
1692
+ if (iframeRect && r) {
1693
+ const x = Math.max(4, Math.min(window.innerWidth - 528, iframeRect.left + r.left));
1694
+ let y = iframeRect.top + r.bottom + 4;
1695
+ if (y + 60 > window.innerHeight)
1696
+ y = Math.max(4, iframeRect.top + r.top - 60);
1697
+ pop.style.left = x + "px";
1698
+ pop.style.top = y + "px";
1699
+ }
1700
+ }, 500);
1701
+ }
1702
+ if (e.data?.type === "previewKey" && typeof e.data.key === "string") {
1703
+ // Re-dispatch as a real keydown on document so the hotkey handler
1704
+ // below runs the same code path as a list-focused keypress. Used
1705
+ // when focus is inside the sandboxed preview iframe — works on
1706
+ // platforms where parent-side contentDocument listeners don't.
1707
+ const ev = new KeyboardEvent("keydown", {
1708
+ key: e.data.key, code: e.data.code || "",
1709
+ ctrlKey: !!e.data.ctrlKey, shiftKey: !!e.data.shiftKey,
1710
+ altKey: !!e.data.altKey, metaKey: !!e.data.metaKey,
1711
+ bubbles: true, cancelable: true,
1712
+ });
1713
+ document.dispatchEvent(ev);
1714
+ }
1715
+ });
1716
+ // ── Splitter drag ──
1717
+ const splitter = document.getElementById("splitter-h");
1718
+ if (splitter) {
1719
+ // Restore saved position
1720
+ const saved = localStorage.getItem("mailx-split");
1721
+ if (saved)
1722
+ document.documentElement.style.setProperty("--list-viewer-split", saved);
1723
+ let dragging = false;
1724
+ let startX;
1725
+ let startSplit;
1726
+ splitter.addEventListener("pointerdown", (e) => {
1727
+ dragging = true;
1728
+ startX = e.clientX;
1729
+ const mainArea = document.querySelector(".main-area");
1730
+ startSplit = mainArea.getBoundingClientRect().width * (parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--list-viewer-split")) / 100);
1731
+ splitter.setPointerCapture(e.pointerId);
1732
+ });
1733
+ splitter.addEventListener("pointermove", (e) => {
1734
+ if (!dragging)
1735
+ return;
1736
+ const mainArea = document.querySelector(".main-area");
1737
+ const totalWidth = mainArea.getBoundingClientRect().width;
1738
+ const newSplit = ((startSplit + (e.clientX - startX)) / totalWidth) * 100;
1739
+ const clamped = Math.max(15, Math.min(85, newSplit));
1740
+ const val = `${clamped}%`;
1741
+ document.documentElement.style.setProperty("--list-viewer-split", val);
1742
+ localStorage.setItem("mailx-split", val);
1743
+ });
1744
+ splitter.addEventListener("pointerup", () => { dragging = false; });
1745
+ }
1746
+ // ── WebSocket for live updates ──
1747
+ connectWebSocket();
1748
+ onWsEvent((event) => {
1749
+ const statusSync = document.getElementById("status-sync");
1750
+ const startupStatus = document.getElementById("startup-status");
1751
+ switch (event.type) {
1752
+ case "connected":
1753
+ if (statusSync)
1754
+ statusSync.textContent = "Connected";
1755
+ if (startupStatus)
1756
+ startupStatus.textContent = "Loading accounts...";
1757
+ // Don't refresh folder tree on connect — it's already loaded by initFolderTree
1758
+ break;
1759
+ case "syncProgress": {
1760
+ // Aggregate folders phases ("folders:<path>" when starting a folder,
1761
+ // "folders-done" between folders) print as a proportion so the user
1762
+ // can see forward progress instead of a meaningless "47%". Older
1763
+ // phase strings ("sync:<path>", "folders") still render raw.
1764
+ let label = `${event.phase} ${event.progress || 0}%`;
1765
+ if (typeof event.phase === "string" && event.phase.startsWith("folders:")) {
1766
+ const folderPath = event.phase.slice("folders:".length);
1767
+ label = `folders — ${folderPath} (${event.progress || 0}%)`;
1768
+ }
1769
+ else if (event.phase === "folders-done") {
1770
+ label = `folders ${event.progress || 0}% done`;
1771
+ }
1772
+ if (statusSync)
1773
+ statusSync.textContent = `Syncing ${event.accountId}: ${label}`;
1774
+ if (startupStatus)
1775
+ startupStatus.textContent = `Syncing ${event.accountId}: ${label}`;
1776
+ // Mark syncing folder in tree — bubble up to visible parent if collapsed
1777
+ const syncPath = event.phase?.startsWith("sync:") ? event.phase.slice(5) : null;
1778
+ // Clear previous syncing markers for this account
1779
+ document.querySelectorAll(`.ft-folder.ft-syncing[data-account-id="${event.accountId}"]`).forEach(el => el.classList.remove("ft-syncing"));
1780
+ if (syncPath && event.progress < 100) {
1781
+ // Try exact match first
1782
+ let folderEl = document.querySelector(`.ft-folder[data-account-id="${event.accountId}"][data-folder-path="${CSS.escape(syncPath)}"]`);
1783
+ if (!folderEl) {
1784
+ // Folder not visible (parent collapsed) — find nearest visible ancestor
1785
+ const parts = syncPath.split(/[./]/);
1786
+ for (let i = parts.length - 1; i >= 1; i--) {
1787
+ const parentPath = parts.slice(0, i).join(".");
1788
+ folderEl = document.querySelector(`.ft-folder[data-account-id="${event.accountId}"][data-folder-path="${CSS.escape(parentPath)}"]`);
1789
+ if (folderEl)
1790
+ break;
1791
+ }
1792
+ }
1793
+ if (folderEl)
1794
+ folderEl.classList.add("ft-syncing");
1795
+ }
1796
+ break;
1797
+ }
1798
+ case "syncComplete":
1799
+ // After sync completes, refresh the folder tree (critical for first-run on Android
1800
+ // where folders don't exist until sync fetches them from Gmail API)
1801
+ refreshFolderTree();
1802
+ // Q53: track per-account last-sync timestamp for the status-bar hover.
1803
+ recordAccountSync(event.accountId);
1804
+ break;
1805
+ case "folderSynced":
1806
+ // Per-folder timestamps — drives the tooltip + freshness dot.
1807
+ for (const entry of event.entries || []) {
1808
+ setFolderSynced(event.accountId, entry.folderId, entry.syncedAt);
1809
+ if (currentFolderId === entry.folderId && currentAccountId === event.accountId) {
1810
+ currentFolderSyncedAt = entry.syncedAt;
1811
+ renderNarrowFolderTitle();
1812
+ }
1813
+ }
1814
+ break;
1815
+ case "folderCountsChanged": {
1816
+ // Incremental update only — updateFolderCounts patches badge counts
1817
+ // in-place and falls back to a full refreshFolderTree() when the
1818
+ // folder structure has actually changed. Calling both was doing a
1819
+ // 300 ms debounced rebuild on every sync tick even when just the
1820
+ // unread count moved — visible as folder-tree flicker on Dovecot
1821
+ // accounts where STATUS polls fire frequently.
1822
+ updateFolderCounts();
1823
+ updateNewMessageCount();
1824
+ // Debounced silent reload — preserves scroll position, selection, and viewer
1825
+ if (reloadDebounceTimer)
1826
+ clearTimeout(reloadDebounceTimer);
1827
+ reloadDebounceTimer = setTimeout(() => {
1828
+ reloadDebounceTimer = null;
1829
+ reloadCurrentFolder();
1830
+ }, 2000);
1831
+ // Sync succeeded — clear any transient error banner and re-enable sync button
1832
+ hideAlert();
1833
+ const syncBtn = document.getElementById("btn-sync");
1834
+ if (syncBtn) {
1835
+ syncBtn.disabled = false;
1836
+ syncBtn.classList.remove("syncing");
1837
+ }
1838
+ if (statusSync)
1839
+ statusSync.textContent = `Synced ${new Date().toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false })}`;
1840
+ break;
1841
+ }
1842
+ case "updateAvailable": {
1843
+ const banner = document.getElementById("alert-banner");
1844
+ const text = document.getElementById("alert-text");
1845
+ if (banner && text) {
1846
+ banner.hidden = false;
1847
+ banner.style.background = "oklch(0.45 0.12 250)";
1848
+ // Stash the update banner contents so updateFailed can restore
1849
+ // it (offering a retry) instead of leaving "Updating..." pinned.
1850
+ const restoreHtml = `mailx ${event.latest} available (you have ${event.current}) — <button id="btn-do-update" style="background:none;border:1px solid #fff;color:#fff;padding:0.15em 0.5em;border-radius:3px;cursor:pointer;margin-left:0.5em">Update now</button>`;
1851
+ window.__mailxUpdateBannerHtml = restoreHtml;
1852
+ text.innerHTML = restoreHtml;
1853
+ document.getElementById("btn-do-update")?.addEventListener("click", () => {
1854
+ text.textContent = "Updating... mailx will restart when done";
1855
+ // performUpdate runs npm install then restarts the service
1856
+ const ipc = window.mailxapi || window.opener?.mailxapi;
1857
+ if (ipc?.performUpdate)
1858
+ ipc.performUpdate();
1859
+ });
1860
+ }
1861
+ break;
1862
+ }
1863
+ case "updateFailed": {
1864
+ // Service tried to install but failed (typically offline). Restore
1865
+ // the "Update now" banner so the user can retry — and prefix it
1866
+ // with a short status so they know why the previous tap silently
1867
+ // came back. mailx itself keeps running on the current version.
1868
+ const banner = document.getElementById("alert-banner");
1869
+ const text = document.getElementById("alert-text");
1870
+ if (banner && text) {
1871
+ const restoreHtml = window.__mailxUpdateBannerHtml;
1872
+ const prefix = event.offline ? "No connection — update postponed. " : "Update failed — ";
1873
+ banner.hidden = false;
1874
+ banner.style.background = event.offline ? "oklch(0.42 0.06 70)" : "oklch(0.45 0.12 25)";
1875
+ text.innerHTML = `${prefix}${restoreHtml ?? ""}`;
1876
+ document.getElementById("btn-do-update")?.addEventListener("click", () => {
1877
+ text.textContent = "Updating... mailx will restart when done";
1878
+ const ipc = window.mailxapi || window.opener?.mailxapi;
1879
+ if (ipc?.performUpdate)
1880
+ ipc.performUpdate();
1881
+ });
1882
+ }
1883
+ break;
1884
+ }
1885
+ case "syncActionFailed": {
1886
+ // Surface sync failures (move/delete/flag not applied on server)
1887
+ // so the user knows local-first actions haven't propagated yet.
1888
+ const action = event.action === "move" ? "Move" : event.action === "delete" ? "Delete" : event.action;
1889
+ if (statusSync)
1890
+ statusSync.textContent = `Sync failed: ${action} — ${event.error}`;
1891
+ break;
1892
+ }
1893
+ case "reload":
1894
+ location.reload();
1895
+ break;
1896
+ case "bodyCached":
1897
+ // Prefetch (or on-demand fetch) downloaded a body — flip the
1898
+ // "not-downloaded" indicator to the teal dot for any rows in view.
1899
+ markBodiesCached(event.items || []);
1900
+ break;
1901
+ case "configChanged":
1902
+ // A watched config file was modified — could be user edit via the
1903
+ // JSONC editor, a GDrive sync, or mailx itself saving (e.g.
1904
+ // allowlist update on "allow sender").
1905
+ //
1906
+ // For accounts.jsonc specifically, surface a sticky banner with a
1907
+ // Restart button — the file change has no effect on the running
1908
+ // daemon (IMAP connections, token caches, sync loops use the old
1909
+ // config snapshot), and users shouldn't need `mailx -kill` just
1910
+ // to apply an edit. For other files (allowlist / clients /
1911
+ // config) the service handles live, a status-bar flash suffices.
1912
+ if (statusSync) {
1913
+ statusSync.textContent = `${event.filename} updated`;
1914
+ setTimeout(() => {
1915
+ if (statusSync.textContent === `${event.filename} updated`)
1916
+ statusSync.textContent = "";
1917
+ }, 8000);
1918
+ }
1919
+ if (event.filename && /accounts\.jsonc/i.test(String(event.filename))) {
1920
+ showRestartForConfigBanner();
1921
+ }
1922
+ break;
1923
+ case "cloudError":
1924
+ // Cloud read/write failed (Google Drive auth/network/etc.). Show a
1925
+ // sticky banner so the user knows the change wasn't synced. When
1926
+ // error is null, the next successful op cleared it — hide it.
1927
+ if (event.error) {
1928
+ const where = event.filename ? ` (${event.op || "sync"} ${event.filename})` : "";
1929
+ showAlert(`Cloud sync error${where}: ${event.error}`, "cloud-error");
1930
+ }
1931
+ else {
1932
+ // Only hide if the visible banner is the cloud-error one
1933
+ if (alertBanner && alertBanner.dataset.key === "cloud-error") {
1934
+ alertBanner.hidden = true;
1935
+ dismissedAlerts.delete("cloud-error");
1936
+ }
1937
+ }
1938
+ break;
1939
+ case "error":
1940
+ if (statusSync)
1941
+ statusSync.textContent = `Error: ${event.message}`;
1942
+ showAlert(event.message, "ws-error");
1943
+ break;
1944
+ case "outboxStatus":
1945
+ renderOutboxStatus(event);
1946
+ break;
1947
+ case "calendarUpdated":
1948
+ case "tasksUpdated":
1949
+ // Reauth succeeded (or was never broken): clear any lingering
1950
+ // scope banner for this feature. Handled here (not just in the
1951
+ // sidebar) because the global fallback banner isn't tied to the
1952
+ // sidebar's lifecycle.
1953
+ if (alertBanner && /^scope-(calendar|tasks|google)$/.test(alertBanner.dataset.key || "")) {
1954
+ alertBanner.hidden = true;
1955
+ alertBanner.dataset.key = "";
1956
+ alertBanner.querySelector(".status-action")?.remove();
1957
+ }
1958
+ break;
1959
+ case "authScopeError": {
1960
+ // Fallback banner: calendar-sidebar.ts already shows this inline
1961
+ // when the sidebar is visible, but if the user has the sidebar
1962
+ // off or is on a narrow tier where it's hidden, the error would
1963
+ // otherwise be invisible. Global banner with the same button.
1964
+ const feat = event.feature || "google";
1965
+ const key = `scope-${feat}`;
1966
+ const msg = event.message || `Google ${feat} access needs re-consent.`;
1967
+ showAlert(msg, key, { sticky: true });
1968
+ const bannerText = document.getElementById("alert-text");
1969
+ if (bannerText && bannerText.textContent === msg) {
1970
+ const existing = bannerText.parentElement?.querySelector(".status-action");
1971
+ if (!existing) {
1972
+ const btn = document.createElement("button");
1973
+ btn.className = "status-action";
1974
+ btn.textContent = "Re-authenticate";
1975
+ btn.addEventListener("click", async () => {
1976
+ btn.disabled = true;
1977
+ btn.textContent = "Opening browser…";
1978
+ try {
1979
+ const { reauthGoogleScopes } = await import("./lib/api-client.js");
1980
+ await reauthGoogleScopes();
1981
+ btn.textContent = "Consent opened — finish in browser";
1982
+ }
1983
+ catch (err) {
1984
+ btn.disabled = false;
1985
+ btn.textContent = `Failed: ${err?.message || err}`;
1986
+ }
1987
+ });
1988
+ bannerText.parentElement?.insertBefore(btn, document.getElementById("alert-dismiss"));
1989
+ }
1990
+ }
1991
+ break;
1992
+ }
1993
+ case "accountError": {
1994
+ // Show actual error + hint in banner
1995
+ const msg = `${event.accountId}: ${event.error}`;
1996
+ showAlert(msg, `acct-${event.accountId}`);
1997
+ // Add action button: Re-authenticate for OAuth, Retry for password accounts
1998
+ const bannerText = document.getElementById("alert-text");
1999
+ if (bannerText && bannerText.textContent === msg) {
2000
+ const existing = bannerText.parentElement?.querySelector(".status-action");
2001
+ if (!existing) {
2002
+ const btn = document.createElement("button");
2003
+ btn.className = "status-action";
2004
+ if (event.isOAuth) {
2005
+ btn.textContent = "Re-authenticate";
2006
+ btn.addEventListener("click", async () => {
2007
+ btn.disabled = true;
2008
+ btn.textContent = "Authenticating...";
2009
+ try {
2010
+ const data = await reauthenticate(event.accountId);
2011
+ if (data.ok) {
2012
+ hideAlert();
2013
+ const acctEl = document.getElementById("status-accounts");
2014
+ if (acctEl) {
2015
+ acctEl.textContent = `${event.accountId}: reconnected`;
2016
+ acctEl.style.color = "";
2017
+ }
2018
+ }
2019
+ else {
2020
+ btn.textContent = "Re-authenticate";
2021
+ btn.disabled = false;
2022
+ }
2023
+ }
2024
+ catch {
2025
+ btn.textContent = "Re-authenticate";
2026
+ btn.disabled = false;
2027
+ }
2028
+ });
2029
+ }
2030
+ else {
2031
+ btn.textContent = "Retry";
2032
+ btn.addEventListener("click", async () => {
2033
+ btn.disabled = true;
2034
+ btn.textContent = "Syncing...";
2035
+ try {
2036
+ const data = await syncAccount(event.accountId);
2037
+ if (data.ok) {
2038
+ hideAlert();
2039
+ const acctEl = document.getElementById("status-accounts");
2040
+ if (acctEl) {
2041
+ acctEl.textContent = `${event.accountId}: reconnected`;
2042
+ acctEl.style.color = "";
2043
+ }
2044
+ }
2045
+ else {
2046
+ btn.textContent = "Retry";
2047
+ btn.disabled = false;
2048
+ }
2049
+ }
2050
+ catch {
2051
+ btn.textContent = "Retry";
2052
+ btn.disabled = false;
2053
+ }
2054
+ });
2055
+ }
2056
+ bannerText.parentElement?.insertBefore(btn, document.getElementById("alert-dismiss"));
2057
+ }
2058
+ }
2059
+ // Also show in status bar
2060
+ const acctEl = document.getElementById("status-accounts");
2061
+ if (acctEl) {
2062
+ acctEl.textContent = `${event.accountId}: ${event.hint}`;
2063
+ acctEl.style.color = "oklch(0.65 0.2 25)";
2064
+ }
2065
+ break;
2066
+ }
2067
+ }
2068
+ });
2069
+ // ── Keyboard shortcuts ──
2070
+ document.addEventListener("keydown", (e) => {
2071
+ // Ctrl+N or Ctrl+Shift+M = Compose
2072
+ if ((e.ctrlKey && e.key === "n") || (e.ctrlKey && e.shiftKey && e.key === "M")) {
2073
+ e.preventDefault();
2074
+ openCompose("new");
2075
+ }
2076
+ // Ctrl+R = Reply (without Shift)
2077
+ if (e.ctrlKey && e.key === "r" && !e.shiftKey && !e.altKey && !e.metaKey) {
2078
+ e.preventDefault();
2079
+ openCompose("reply");
2080
+ }
2081
+ // Ctrl+Shift+R = Reply All
2082
+ if (e.ctrlKey && e.shiftKey && e.key === "R" && !e.altKey && !e.metaKey) {
2083
+ e.preventDefault();
2084
+ openCompose("replyAll");
2085
+ }
2086
+ // Ctrl+F = Forward (without Shift). Use toLowerCase so a Caps-Lock or
2087
+ // shifted state doesn't bypass us. Single handler — the previous
2088
+ // duplicate fired openCompose twice, which double-loaded the compose
2089
+ // iframe and the second copy got an empty sessionStorage (the first
2090
+ // had already consumed it), producing an empty Forward form.
2091
+ if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && (e.key === "f" || e.key === "F")) {
2092
+ e.preventDefault();
2093
+ openCompose("forward");
2094
+ }
2095
+ // Ctrl+A = Select all visible messages
2096
+ if (e.ctrlKey && e.key === "a") {
2097
+ const mlBody = document.getElementById("ml-body");
2098
+ if (mlBody && document.activeElement?.closest(".message-list, .ml-body, body")) {
2099
+ e.preventDefault();
2100
+ mlBody.querySelectorAll(".ml-row").forEach(r => r.classList.add("selected"));
2101
+ }
2102
+ }
2103
+ // Ctrl+D or Delete = Delete selected messages.
2104
+ // P15: don't hijack Delete inside text inputs / textareas / contenteditable
2105
+ // — JSONC editor's Delete key was being eaten because we always preventDefault'd.
2106
+ if ((e.ctrlKey && e.key === "d") || e.key === "Delete") {
2107
+ const t = e.target;
2108
+ const tag = t?.tagName;
2109
+ const editable = t?.isContentEditable;
2110
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || editable)
2111
+ return;
2112
+ e.preventDefault();
2113
+ deleteSelectedMessages();
2114
+ }
2115
+ // Ctrl+Z = Undo the most recent delete or move
2116
+ if (e.ctrlKey && e.key === "z") {
2117
+ if (lastMoved) {
2118
+ e.preventDefault();
2119
+ undoMove();
2120
+ }
2121
+ else if (lastDeleted) {
2122
+ e.preventDefault();
2123
+ undoDelete();
2124
+ }
2125
+ }
2126
+ // F5 = Sync
2127
+ if (e.key === "F5") {
2128
+ e.preventDefault();
2129
+ document.getElementById("btn-sync")?.click();
2130
+ }
2131
+ // R = Toggle read/unread
2132
+ if (e.key.toLowerCase() === "r" && !e.ctrlKey && !e.metaKey && !e.altKey) {
2133
+ const active = document.activeElement;
2134
+ if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.tagName === "SELECT"))
2135
+ return;
2136
+ const sel = messageState.getSelected();
2137
+ if (!sel)
2138
+ return;
2139
+ e.preventDefault();
2140
+ const isSeen = sel.flags.includes("\\Seen");
2141
+ const newFlags = isSeen
2142
+ ? sel.flags.filter((f) => f !== "\\Seen")
2143
+ : [...sel.flags, "\\Seen"];
2144
+ updateFlags(sel.accountId, sel.uid, newFlags).then(() => {
2145
+ sel.flags = newFlags;
2146
+ messageState.updateMessageFlags(sel.accountId, sel.uid, newFlags);
2147
+ const row = document.querySelector(`.ml-row[data-uid="${sel.uid}"][data-account-id="${sel.accountId}"]`);
2148
+ if (row)
2149
+ row.classList.toggle("unread", !newFlags.includes("\\Seen"));
2150
+ }).catch(() => { });
2151
+ }
2152
+ // Arrow keys + Home/End/PgUp/PgDn — navigate message list (Q58).
2153
+ if (["ArrowDown", "ArrowUp", "Home", "End", "PageDown", "PageUp"].includes(e.key)) {
2154
+ const active = document.activeElement;
2155
+ if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.tagName === "SELECT"))
2156
+ return;
2157
+ const body = document.getElementById("ml-body");
2158
+ if (!body)
2159
+ return;
2160
+ const rows = Array.from(body.querySelectorAll(".ml-row"));
2161
+ if (rows.length === 0)
2162
+ return;
2163
+ const selected = body.querySelector(".ml-row.selected");
2164
+ const idx = selected ? rows.indexOf(selected) : -1;
2165
+ let target;
2166
+ if (e.key === "ArrowDown")
2167
+ target = rows[idx + 1] || rows[idx];
2168
+ else if (e.key === "ArrowUp")
2169
+ target = rows[Math.max(0, idx - 1)];
2170
+ else if (e.key === "Home")
2171
+ target = rows[0];
2172
+ else if (e.key === "End")
2173
+ target = rows[rows.length - 1];
2174
+ else if (e.key === "PageDown")
2175
+ target = rows[Math.min(rows.length - 1, idx + 10)];
2176
+ else if (e.key === "PageUp")
2177
+ target = rows[Math.max(0, idx - 10)];
2178
+ if (target && (!selected || target !== selected)) {
2179
+ e.preventDefault();
2180
+ target.click();
2181
+ target.scrollIntoView({ block: "nearest" });
2182
+ }
2183
+ }
2184
+ });
2185
+ // ── View menu ──
2186
+ const viewBtn = document.getElementById("btn-view");
2187
+ const viewDropdown = document.getElementById("view-dropdown");
2188
+ const optTwoLine = document.getElementById("opt-two-line");
2189
+ const optPreview = document.getElementById("opt-preview");
2190
+ const optSnippet = document.getElementById("opt-snippet");
2191
+ const optThreaded = document.getElementById("opt-threaded");
2192
+ const optFlagged = document.getElementById("opt-flagged");
2193
+ const optFolderCounts = document.getElementById("opt-folder-counts");
2194
+ const optCalendarSidebar = document.getElementById("opt-calendar-sidebar");
2195
+ const optThreadFilter = document.getElementById("opt-thread-filter");
2196
+ // Toggle dropdown — also close any other open toolbar menu so they can't
2197
+ // overlap. Without this, opening View while Settings was already open left
2198
+ // both visible at once (user-reported screenshot).
2199
+ viewBtn?.addEventListener("click", (e) => {
2200
+ e.stopPropagation();
2201
+ const settingsDd = document.getElementById("settings-dropdown");
2202
+ if (settingsDd)
2203
+ settingsDd.hidden = true;
2204
+ const restartDd = document.getElementById("restart-dropdown");
2205
+ if (restartDd)
2206
+ restartDd.hidden = true;
2207
+ if (viewDropdown)
2208
+ viewDropdown.hidden = !viewDropdown.hidden;
2209
+ });
2210
+ document.addEventListener("click", (e) => {
2211
+ // Only close when the click is genuinely outside the menu container.
2212
+ // The earlier unconditional close had two problems: clicks on radio
2213
+ // buttons / checkboxes INSIDE the menu also closed it (so the user
2214
+ // couldn't toggle anything without reopening), and any handler
2215
+ // upstream that consumed the event (preventDefault paths, focus
2216
+ // shifts) could keep the menu open inappropriately. The closest()
2217
+ // check ensures inside-clicks pass through and outside-clicks close.
2218
+ const target = e.target;
2219
+ if (viewDropdown && !viewDropdown.hidden && !target?.closest("#view-menu") && !target?.closest("#view-dropdown")) {
2220
+ viewDropdown.hidden = true;
2221
+ }
2222
+ if (settingsDropdown && !settingsDropdown.hidden && !target?.closest("#settings-menu") && !target?.closest("#settings-dropdown")) {
2223
+ settingsDropdown.hidden = true;
2224
+ }
2225
+ });
2226
+ // Restore saved view settings
2227
+ const savedTwoLine = localStorage.getItem("mailx-two-line") === "true";
2228
+ const savedPreview = localStorage.getItem("mailx-preview") !== "false"; // default true
2229
+ const savedSnippet = localStorage.getItem("mailx-snippet") !== "false"; // default true
2230
+ const savedThreaded = localStorage.getItem("mailx-threaded") === "true";
2231
+ const savedFlagged = localStorage.getItem("mailx-flagged") === "true";
2232
+ const savedFolderCounts = localStorage.getItem("mailx-folder-counts") === "true";
2233
+ if (optTwoLine)
2234
+ optTwoLine.checked = savedTwoLine;
2235
+ if (optPreview)
2236
+ optPreview.checked = savedPreview;
2237
+ if (optSnippet)
2238
+ optSnippet.checked = savedSnippet;
2239
+ if (optThreaded)
2240
+ optThreaded.checked = savedThreaded;
2241
+ if (optFlagged)
2242
+ optFlagged.checked = savedFlagged;
2243
+ if (optFolderCounts)
2244
+ optFolderCounts.checked = savedFolderCounts;
2245
+ if (savedTwoLine)
2246
+ document.getElementById("message-list")?.classList.add("two-line");
2247
+ if (!savedPreview)
2248
+ document.querySelector(".main-area")?.classList.add("no-preview");
2249
+ if (!savedSnippet)
2250
+ document.getElementById("message-list")?.classList.add("no-snippets");
2251
+ if (savedThreaded)
2252
+ document.getElementById("ml-body")?.classList.add("threaded");
2253
+ if (savedFlagged)
2254
+ document.getElementById("ml-body")?.classList.add("flagged-only");
2255
+ if (savedFolderCounts)
2256
+ document.getElementById("folder-tree")?.classList.add("show-folder-counts");
2257
+ // "Only this conversation" toggle — hides rows whose threadId differs from
2258
+ // the currently-selected message's threadId. Client-side only (no server
2259
+ // round-trip); toggling off restores the full list. Persisted per-session
2260
+ // but not across reloads (thread context is tied to current selection).
2261
+ optThreadFilter?.addEventListener("change", () => {
2262
+ const body = document.getElementById("ml-body");
2263
+ if (!body)
2264
+ return;
2265
+ body.classList.toggle("thread-filter-on", optThreadFilter.checked);
2266
+ applyThreadFilter();
2267
+ });
2268
+ messageState.subscribe(() => applyThreadFilter());
2269
+ function applyThreadFilter() {
2270
+ const body = document.getElementById("ml-body");
2271
+ if (!body)
2272
+ return;
2273
+ if (!optThreadFilter?.checked) {
2274
+ body.querySelectorAll(".ml-row.thread-filter-hidden")
2275
+ .forEach(r => r.classList.remove("thread-filter-hidden"));
2276
+ return;
2277
+ }
2278
+ const sel = messageState.getSelected();
2279
+ const tid = sel?.threadId;
2280
+ if (!tid)
2281
+ return;
2282
+ body.querySelectorAll(".ml-row").forEach(r => {
2283
+ const rowTid = r.dataset.threadId;
2284
+ if (rowTid === tid || r.classList.contains("selected")) {
2285
+ r.classList.remove("thread-filter-hidden");
2286
+ }
2287
+ else {
2288
+ r.classList.add("thread-filter-hidden");
2289
+ }
2290
+ });
2291
+ }
2292
+ // S51 — Calendar sidebar: View-menu toggle, restore from localStorage,
2293
+ // hide auto-magically on narrow screens (CSS handles that).
2294
+ (async () => {
2295
+ const { initCalendarSidebar, isCalendarSidebarOn, showCalendarSidebar, hideCalendarSidebar } = await import("./components/calendar-sidebar.js");
2296
+ initCalendarSidebar();
2297
+ const on = isCalendarSidebarOn();
2298
+ if (optCalendarSidebar)
2299
+ optCalendarSidebar.checked = on;
2300
+ if (on)
2301
+ await showCalendarSidebar();
2302
+ optCalendarSidebar?.addEventListener("change", () => {
2303
+ if (optCalendarSidebar.checked)
2304
+ showCalendarSidebar();
2305
+ else
2306
+ hideCalendarSidebar();
2307
+ });
2308
+ })();
2309
+ // P17 / Q104: alarm subsystem — Thunderbird/Outlook-style popup with
2310
+ // snooze + dismiss. Covers calendar events + tasks today; mail reminders
2311
+ // will slot in here when the mail-reminder feature lands.
2312
+ (async () => {
2313
+ try {
2314
+ const { startAlarmPoller } = await import("./components/alarms.js");
2315
+ startAlarmPoller();
2316
+ }
2317
+ catch (e) {
2318
+ console.error("alarm poller init failed:", e?.message || e);
2319
+ }
2320
+ })();
2321
+ // Two-line toggle
2322
+ optTwoLine?.addEventListener("change", () => {
2323
+ const list = document.getElementById("message-list");
2324
+ if (optTwoLine.checked) {
2325
+ list?.classList.add("two-line");
2326
+ }
2327
+ else {
2328
+ list?.classList.remove("two-line");
2329
+ }
2330
+ localStorage.setItem("mailx-two-line", String(optTwoLine.checked));
2331
+ });
2332
+ // Preview pane toggle
2333
+ optPreview?.addEventListener("change", () => {
2334
+ const main = document.querySelector(".main-area");
2335
+ if (optPreview.checked) {
2336
+ main?.classList.remove("no-preview");
2337
+ }
2338
+ else {
2339
+ main?.classList.add("no-preview");
2340
+ }
2341
+ localStorage.setItem("mailx-preview", String(optPreview.checked));
2342
+ });
2343
+ // Preview snippet toggle
2344
+ optSnippet?.addEventListener("change", () => {
2345
+ const list = document.getElementById("message-list");
2346
+ if (optSnippet.checked) {
2347
+ list?.classList.remove("no-snippets");
2348
+ }
2349
+ else {
2350
+ list?.classList.add("no-snippets");
2351
+ }
2352
+ localStorage.setItem("mailx-snippet", String(optSnippet.checked));
2353
+ });
2354
+ // ── JSONC config file editor ──
2355
+ document.getElementById("btn-edit-jsonc")?.addEventListener("click", async () => {
2356
+ const settingsDropdown = document.getElementById("settings-dropdown");
2357
+ if (settingsDropdown)
2358
+ settingsDropdown.hidden = true;
2359
+ await openJsoncEditor("accounts.jsonc");
2360
+ });
2361
+ // Allow other components (remote-content banner, etc.) to open the editor
2362
+ // pre-selected to a specific file.
2363
+ document.addEventListener("mailx-open-jsonc-editor", async (ev) => {
2364
+ const file = ev.detail?.file || "accounts.jsonc";
2365
+ await openJsoncEditor(file);
2366
+ });
2367
+ // Q61: open ~/.mailx in OS file explorer.
2368
+ document.getElementById("btn-open-mailx-dir")?.addEventListener("click", async () => {
2369
+ const settingsDropdown = document.getElementById("settings-dropdown");
2370
+ if (settingsDropdown)
2371
+ settingsDropdown.hidden = true;
2372
+ try {
2373
+ const { openLocalPath } = await import("./lib/api-client.js");
2374
+ await openLocalPath("config");
2375
+ }
2376
+ catch (e) {
2377
+ alert(`Couldn't open folder: ${e?.message || e}`);
2378
+ }
2379
+ });
2380
+ // Q62: open today's log file.
2381
+ document.getElementById("btn-open-log")?.addEventListener("click", async () => {
2382
+ const settingsDropdown = document.getElementById("settings-dropdown");
2383
+ if (settingsDropdown)
2384
+ settingsDropdown.hidden = true;
2385
+ try {
2386
+ const { openLocalPath } = await import("./lib/api-client.js");
2387
+ await openLocalPath("log");
2388
+ }
2389
+ catch (e) {
2390
+ alert(`Couldn't open log: ${e?.message || e}`);
2391
+ }
2392
+ });
2393
+ async function openJsoncEditor(initialFile) {
2394
+ const { readJsoncFile, writeJsoncFile, readConfigHelp, formatJsonc } = await import("./lib/api-client.js");
2395
+ const backdrop = document.createElement("div");
2396
+ backdrop.className = "mailx-modal-backdrop";
2397
+ const panel = document.createElement("div");
2398
+ panel.className = "mailx-modal mailx-modal-wide";
2399
+ panel.innerHTML = `
2400
+ <div class="mailx-modal-title">
2401
+ <span class="mailx-modal-title-text">Edit config file</span>
2402
+ <button type="button" class="mailx-modal-close" id="jsonc-close" title="Close (Esc)" aria-label="Close">&times;</button>
2403
+ </div>
2404
+ <label class="mailx-modal-label">File
2405
+ <select class="mailx-modal-input" id="jsonc-file">
2406
+ <option value="accounts.jsonc">accounts.jsonc — accounts (shared via Google Drive)</option>
2407
+ <option value="contacts.jsonc">contacts.jsonc — preferred + denylist + discovered (shared)</option>
2408
+ <option value="allowlist.jsonc">allowlist.jsonc — remote-content allowlist (shared)</option>
2409
+ <option value="clients.jsonc">clients.jsonc — per-device registrations (shared)</option>
2410
+ <option value="config.jsonc">config.jsonc — local per-machine overrides (not synced)</option>
2411
+ </select>
2412
+ </label>
2413
+ <div class="mailx-modal-split">
2414
+ <label class="mailx-modal-label mailx-modal-split-left">Contents (JSONC — comments and trailing commas allowed)
2415
+ <div class="jsonc-editor-wrap">
2416
+ <div class="jsonc-gutter" id="jsonc-gutter" aria-hidden="true"></div>
2417
+ <textarea class="mailx-modal-input mailx-modal-textarea jsonc-textarea" id="jsonc-content" spellcheck="false"></textarea>
2418
+ </div>
2419
+ </label>
2420
+ <div class="mailx-modal-split-right mailx-help-panel">
2421
+ <div class="mailx-help-title">
2422
+ <button type="button" class="mailx-help-toggle" id="jsonc-help-toggle" aria-expanded="true" title="Hide/show help">▾ Help</button>
2423
+ </div>
2424
+ <div class="mailx-help-body" id="jsonc-help-body"></div>
2425
+ </div>
2426
+ </div>
2427
+ <div class="mailx-modal-error" id="jsonc-error" hidden></div>
2428
+ <div class="mailx-modal-buttons">
2429
+ <button type="button" class="mailx-modal-btn" data-action="format" title="Reformat indentation while preserving comments and trailing commas">Format</button>
2430
+ <span class="mailx-modal-spacer"></span>
2431
+ <button type="button" class="mailx-modal-btn" data-action="cancel">Cancel</button>
2432
+ <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="save">Save</button>
2433
+ </div>`;
2434
+ backdrop.appendChild(panel);
2435
+ document.body.appendChild(backdrop);
2436
+ const fileSelect = panel.querySelector("#jsonc-file");
2437
+ const textarea = panel.querySelector("#jsonc-content");
2438
+ const gutter = panel.querySelector("#jsonc-gutter");
2439
+ const errorEl = panel.querySelector("#jsonc-error");
2440
+ const saveBtn = panel.querySelector('[data-action="save"]');
2441
+ const helpBody = panel.querySelector("#jsonc-help-body");
2442
+ const helpToggle = panel.querySelector("#jsonc-help-toggle");
2443
+ const helpPanel = panel.querySelector(".mailx-help-panel");
2444
+ fileSelect.value = initialFile;
2445
+ // Line-number gutter — recomputed whenever the textarea content changes,
2446
+ // scroll-synced so numbers stay aligned. errorLine (1-based) is highlighted
2447
+ // red so the "Line N, col M" error message in the status bar points at a
2448
+ // visible marker in the gutter.
2449
+ let errorLine = 0;
2450
+ const renderGutter = () => {
2451
+ const lines = textarea.value.split("\n").length;
2452
+ let html = "";
2453
+ for (let i = 1; i <= lines; i++) {
2454
+ html += i === errorLine
2455
+ ? `<div class="jsonc-gutter-line jsonc-gutter-error">${i}</div>`
2456
+ : `<div class="jsonc-gutter-line">${i}</div>`;
2457
+ }
2458
+ gutter.innerHTML = html;
2459
+ };
2460
+ const syncScroll = () => { gutter.scrollTop = textarea.scrollTop; };
2461
+ textarea.addEventListener("scroll", syncScroll);
2462
+ textarea.addEventListener("input", renderGutter);
2463
+ helpToggle.addEventListener("click", () => {
2464
+ const open = helpPanel.classList.toggle("mailx-help-collapsed");
2465
+ helpToggle.textContent = open ? "▸ Help" : "▾ Help";
2466
+ helpToggle.setAttribute("aria-expanded", open ? "false" : "true");
2467
+ });
2468
+ const loadHelp = async () => {
2469
+ helpBody.textContent = "Loading help…";
2470
+ try {
2471
+ const r = await readConfigHelp(fileSelect.value);
2472
+ const md = (r?.content || "").trim();
2473
+ helpBody.innerHTML = md ? renderMarkdown(md) : "<em>No help available for this file.</em>";
2474
+ }
2475
+ catch (e) {
2476
+ helpBody.textContent = `Help unavailable: ${e.message}`;
2477
+ }
2478
+ };
2479
+ const clearValidation = () => {
2480
+ errorEl.hidden = true;
2481
+ errorEl.textContent = "";
2482
+ textarea.classList.remove("mailx-modal-input-error");
2483
+ saveBtn.disabled = false;
2484
+ errorLine = 0;
2485
+ renderGutter();
2486
+ };
2487
+ const showValidation = (err) => {
2488
+ // CRITICAL: do NOT move the cursor here. Validation fires every 600ms
2489
+ // while the user types; auto-selecting the error position yanked the
2490
+ // cursor mid-edit and made fixing the error impossible (the user
2491
+ // reported this as a fatal bug — the very mechanism preventing a save
2492
+ // was preventing the fix). Location is shown via the gutter highlight
2493
+ // + the "Line N, col M" message, and the user can click "Jump" to
2494
+ // explicitly navigate.
2495
+ errorEl.innerHTML = "";
2496
+ const text = document.createElement("span");
2497
+ text.textContent = `Line ${err.line}, col ${err.col}: ${err.message} `;
2498
+ const jumpBtn = document.createElement("button");
2499
+ jumpBtn.type = "button";
2500
+ jumpBtn.className = "mailx-modal-btn mailx-modal-btn-link";
2501
+ jumpBtn.textContent = "Jump to error";
2502
+ jumpBtn.addEventListener("click", () => {
2503
+ textarea.focus();
2504
+ try {
2505
+ textarea.setSelectionRange(err.pos, err.pos + 1);
2506
+ }
2507
+ catch { /* */ }
2508
+ });
2509
+ errorEl.appendChild(text);
2510
+ errorEl.appendChild(jumpBtn);
2511
+ errorEl.hidden = false;
2512
+ textarea.classList.add("mailx-modal-input-error");
2513
+ saveBtn.disabled = true;
2514
+ errorLine = err.line;
2515
+ renderGutter();
2516
+ };
2517
+ let validateTimer;
2518
+ const scheduleValidate = () => {
2519
+ if (validateTimer)
2520
+ window.clearTimeout(validateTimer);
2521
+ validateTimer = window.setTimeout(() => {
2522
+ const err = validateJsonc(textarea.value);
2523
+ if (err)
2524
+ showValidation(err);
2525
+ else
2526
+ clearValidation();
2527
+ }, 600);
2528
+ };
2529
+ textarea.addEventListener("input", scheduleValidate);
2530
+ const loadFile = async () => {
2531
+ textarea.value = "Loading...";
2532
+ clearValidation();
2533
+ renderGutter();
2534
+ try {
2535
+ const r = await readJsoncFile(fileSelect.value);
2536
+ textarea.value = r?.content || "";
2537
+ renderGutter();
2538
+ scheduleValidate();
2539
+ }
2540
+ catch (e) {
2541
+ textarea.value = "";
2542
+ renderGutter();
2543
+ errorEl.textContent = `Failed to load: ${e.message}`;
2544
+ errorEl.hidden = false;
2545
+ }
2546
+ };
2547
+ await Promise.all([loadFile(), loadHelp()]);
2548
+ fileSelect.addEventListener("change", () => { loadFile(); loadHelp(); });
2549
+ const close = () => {
2550
+ if (validateTimer)
2551
+ window.clearTimeout(validateTimer);
2552
+ backdrop.remove();
2553
+ document.removeEventListener("keydown", onKey, true);
2554
+ };
2555
+ const onKey = (e) => {
2556
+ if (e.key === "Escape") {
2557
+ e.stopPropagation();
2558
+ e.preventDefault();
2559
+ close();
2560
+ }
2561
+ };
2562
+ document.addEventListener("keydown", onKey, true);
2563
+ panel.querySelector("#jsonc-close").addEventListener("click", close);
2564
+ panel.querySelectorAll(".mailx-modal-btn").forEach(btn => {
2565
+ btn.addEventListener("click", async () => {
2566
+ const action = btn.dataset.action;
2567
+ if (action === "cancel") {
2568
+ close();
2569
+ return;
2570
+ }
2571
+ if (action === "format") {
2572
+ // Reformat via the service-side jsonc-parser format() — the
2573
+ // edits are whitespace-only, so `//` and `/* */` comments
2574
+ // survive intact (which JSON.stringify(parse(...)) does not).
2575
+ btn.disabled = true;
2576
+ const orig = btn.textContent;
2577
+ btn.textContent = "Formatting…";
2578
+ try {
2579
+ const r = await formatJsonc(textarea.value);
2580
+ if (r?.content !== undefined) {
2581
+ textarea.value = r.content;
2582
+ renderGutter();
2583
+ scheduleValidate();
2584
+ }
2585
+ }
2586
+ catch (e) {
2587
+ errorEl.textContent = `Format failed: ${e.message}`;
2588
+ errorEl.hidden = false;
2589
+ }
2590
+ finally {
2591
+ btn.disabled = false;
2592
+ btn.textContent = orig || "Format";
2593
+ }
2594
+ return;
2595
+ }
2596
+ if (action === "save") {
2597
+ // Final sync-check; refuse to save if it doesn't parse
2598
+ const err = validateJsonc(textarea.value);
2599
+ if (err) {
2600
+ showValidation(err);
2601
+ return;
2602
+ }
2603
+ errorEl.hidden = true;
2604
+ btn.disabled = true;
2605
+ btn.textContent = "Saving...";
2606
+ try {
2607
+ await writeJsoncFile(fileSelect.value, textarea.value);
2608
+ close();
2609
+ const statusSync = document.getElementById("status-sync");
2610
+ if (statusSync)
2611
+ statusSync.textContent = `Saved ${fileSelect.value} — restart mailx to apply`;
2612
+ }
2613
+ catch (e) {
2614
+ errorEl.textContent = `${e.message}`;
2615
+ errorEl.hidden = false;
2616
+ btn.disabled = false;
2617
+ btn.textContent = "Save";
2618
+ }
2619
+ }
2620
+ });
2621
+ });
2622
+ backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
2623
+ close(); });
2624
+ }
2625
+ // JSONC validator — strips comments + trailing commas (preserving source positions
2626
+ // by replacing stripped chars with spaces/newlines) and runs JSON.parse. Reports
2627
+ // only the *first* error; cascading errors are suppressed.
2628
+ function validateJsonc(src) {
2629
+ const stripped = stripJsoncPreservingPositions(src);
2630
+ if (stripped.error) {
2631
+ const { pos, line, col } = offsetToLineCol(src, stripped.error.pos);
2632
+ return { message: stripped.error.message, pos, line, col };
2633
+ }
2634
+ if (stripped.text.trim() === "")
2635
+ return null; // empty file: treat as valid (settings code handles)
2636
+ try {
2637
+ JSON.parse(stripped.text);
2638
+ return null;
2639
+ }
2640
+ catch (e) {
2641
+ const msg = String(e?.message || "parse error");
2642
+ const m = msg.match(/at position (\d+)/i);
2643
+ const pos = m ? Math.min(parseInt(m[1], 10), src.length - 1) : 0;
2644
+ const lc = offsetToLineCol(src, pos);
2645
+ return { message: msg.replace(/\s*at position \d+/i, ""), pos: lc.pos, line: lc.line, col: lc.col };
2646
+ }
2647
+ }
2648
+ function stripJsoncPreservingPositions(src) {
2649
+ const out = new Array(src.length);
2650
+ let i = 0;
2651
+ const n = src.length;
2652
+ while (i < n) {
2653
+ const c = src[i];
2654
+ const next = src[i + 1];
2655
+ if (c === '"') {
2656
+ out[i] = c;
2657
+ i++;
2658
+ while (i < n) {
2659
+ const ch = src[i];
2660
+ out[i] = ch;
2661
+ i++;
2662
+ if (ch === "\\" && i < n) {
2663
+ out[i] = src[i];
2664
+ i++;
2665
+ continue;
2666
+ }
2667
+ if (ch === '"')
2668
+ break;
2669
+ if (ch === "\n")
2670
+ return { text: out.join(""), error: { message: "unterminated string", pos: i - 1 } };
2671
+ }
2672
+ }
2673
+ else if (c === "/" && next === "/") {
2674
+ while (i < n && src[i] !== "\n") {
2675
+ out[i] = " ";
2676
+ i++;
2677
+ }
2678
+ }
2679
+ else if (c === "/" && next === "*") {
2680
+ const start = i;
2681
+ out[i] = " ";
2682
+ out[i + 1] = " ";
2683
+ i += 2;
2684
+ let closed = false;
2685
+ while (i < n) {
2686
+ if (src[i] === "*" && src[i + 1] === "/") {
2687
+ out[i] = " ";
2688
+ out[i + 1] = " ";
2689
+ i += 2;
2690
+ closed = true;
2691
+ break;
2692
+ }
2693
+ out[i] = src[i] === "\n" ? "\n" : " ";
2694
+ i++;
2695
+ }
2696
+ if (!closed)
2697
+ return { text: out.join(""), error: { message: "unterminated block comment", pos: start } };
2698
+ }
2699
+ else if (c === ",") {
2700
+ // trailing comma before } or ] → replace with space
2701
+ let j = i + 1;
2702
+ while (j < n && /\s/.test(src[j]))
2703
+ j++;
2704
+ if (j < n && (src[j] === "}" || src[j] === "]")) {
2705
+ out[i] = " ";
2706
+ i++;
2707
+ }
2708
+ else {
2709
+ out[i] = c;
2710
+ i++;
2711
+ }
2712
+ }
2713
+ else {
2714
+ out[i] = c;
2715
+ i++;
2716
+ }
2717
+ }
2718
+ return { text: out.join("") };
2719
+ }
2720
+ function offsetToLineCol(src, pos) {
2721
+ pos = Math.max(0, Math.min(pos, src.length));
2722
+ let line = 1, col = 1;
2723
+ for (let i = 0; i < pos; i++) {
2724
+ if (src[i] === "\n") {
2725
+ line++;
2726
+ col = 1;
2727
+ }
2728
+ else
2729
+ col++;
2730
+ }
2731
+ return { pos, line, col };
2732
+ }
2733
+ // Minimal markdown renderer for the help panel. Supports: headings, fenced code blocks,
2734
+ // inline code, bold, italic, links, bullet lists, paragraphs. HTML is escaped first.
2735
+ function renderMarkdown(md) {
2736
+ const esc = (s) => s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
2737
+ // Pull fenced code blocks out first so their contents aren't processed as markdown.
2738
+ const blocks = [];
2739
+ let src = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
2740
+ const i = blocks.length;
2741
+ blocks.push(`<pre class="mailx-help-code"><code>${esc(code)}</code></pre>`);
2742
+ return `\u0000BLOCK${i}\u0000`;
2743
+ });
2744
+ const lines = src.split(/\r?\n/);
2745
+ const out = [];
2746
+ let inList = false;
2747
+ let para = [];
2748
+ const flushPara = () => {
2749
+ if (para.length) {
2750
+ out.push(`<p>${inline(para.join(" "))}</p>`);
2751
+ para = [];
2752
+ }
2753
+ };
2754
+ const closeList = () => { if (inList) {
2755
+ out.push("</ul>");
2756
+ inList = false;
2757
+ } };
2758
+ function inline(s) {
2759
+ s = esc(s);
2760
+ s = s.replace(/`([^`]+)`/g, (_m, c) => `<code>${c}</code>`);
2761
+ s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
2762
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
2763
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
2764
+ return s;
2765
+ }
2766
+ for (const raw of lines) {
2767
+ const blockMatch = /^\u0000BLOCK(\d+)\u0000$/.exec(raw);
2768
+ if (blockMatch) {
2769
+ flushPara();
2770
+ closeList();
2771
+ out.push(blocks[parseInt(blockMatch[1], 10)]);
2772
+ continue;
2773
+ }
2774
+ const h = /^(#{1,6})\s+(.+)$/.exec(raw);
2775
+ if (h) {
2776
+ flushPara();
2777
+ closeList();
2778
+ const lvl = h[1].length;
2779
+ out.push(`<h${lvl}>${inline(h[2])}</h${lvl}>`);
2780
+ continue;
2781
+ }
2782
+ const bullet = /^\s*[-*]\s+(.+)$/.exec(raw);
2783
+ if (bullet) {
2784
+ flushPara();
2785
+ if (!inList) {
2786
+ out.push("<ul>");
2787
+ inList = true;
2788
+ }
2789
+ out.push(`<li>${inline(bullet[1])}</li>`);
2790
+ continue;
2791
+ }
2792
+ if (raw.trim() === "") {
2793
+ flushPara();
2794
+ closeList();
2795
+ continue;
2796
+ }
2797
+ para.push(raw);
2798
+ }
2799
+ flushPara();
2800
+ closeList();
2801
+ return out.join("\n");
2802
+ }
2803
+ // ── About dialog ──
2804
+ document.getElementById("btn-about")?.addEventListener("click", () => {
2805
+ const settingsDropdown = document.getElementById("settings-dropdown");
2806
+ if (settingsDropdown)
2807
+ settingsDropdown.hidden = true;
2808
+ openAboutDialog();
2809
+ });
2810
+ // Clicking the version string (toolbar in wide mode, status bar in narrow mode) also opens About
2811
+ document.querySelectorAll(".app-version").forEach(el => {
2812
+ el.style.cursor = "pointer";
2813
+ el.addEventListener("click", openAboutDialog);
2814
+ });
2815
+ async function openAboutDialog() {
2816
+ const backdrop = document.createElement("div");
2817
+ backdrop.className = "mailx-modal-backdrop";
2818
+ const panel = document.createElement("div");
2819
+ panel.className = "mailx-modal";
2820
+ panel.innerHTML = `
2821
+ <div class="mailx-modal-title">
2822
+ <span class="mailx-modal-title-text">About mailx</span>
2823
+ <button type="button" class="mailx-modal-close" id="about-x" title="Close (Esc)" aria-label="Close">&times;</button>
2824
+ </div>
2825
+ <div class="mailx-about" id="about-body">Loading...</div>
2826
+ <div class="mailx-modal-buttons">
2827
+ <span class="mailx-modal-spacer"></span>
2828
+ <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="close">Close</button>
2829
+ </div>`;
2830
+ backdrop.appendChild(panel);
2831
+ document.body.appendChild(panel.parentElement);
2832
+ const body = panel.querySelector("#about-body");
2833
+ const close = () => {
2834
+ backdrop.remove();
2835
+ document.removeEventListener("keydown", onKey, true);
2836
+ };
2837
+ const onKey = (e) => {
2838
+ if (e.key === "Escape") {
2839
+ e.stopPropagation();
2840
+ e.preventDefault();
2841
+ close();
2842
+ }
2843
+ };
2844
+ document.addEventListener("keydown", onKey, true);
2845
+ panel.querySelector('[data-action="close"]')
2846
+ .addEventListener("click", close);
2847
+ panel.querySelector("#about-x")
2848
+ .addEventListener("click", close);
2849
+ backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
2850
+ close(); });
2851
+ try {
2852
+ const [v, accounts] = await Promise.all([
2853
+ getVersion().catch(() => ({})),
2854
+ getAccounts().catch(() => []),
2855
+ ]);
2856
+ const storage = v.storage || {};
2857
+ const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
2858
+ const platform = isApp ? (mailxapi?.platform || "app") : "browser";
2859
+ const versionText = v.version ? `v${v.version}` : "unknown";
2860
+ const versionHtml = v.version
2861
+ ? `<a href="https://github.com/BobFrankston/mailx/releases/tag/v${v.version}" target="_blank" rel="noopener">${versionText}</a>`
2862
+ : versionText;
2863
+ const rows = [
2864
+ ["Version", versionHtml],
2865
+ ["Platform", platform],
2866
+ ["Storage", storage.provider || "local"],
2867
+ ];
2868
+ if (storage.cloudPath)
2869
+ rows.push(["Cloud path", `My Drive/${storage.cloudPath}/`]);
2870
+ if (storage.mode)
2871
+ rows.push(["Storage mode", storage.mode]);
2872
+ rows.push(["Accounts", String((accounts || []).length)]);
2873
+ rows.push(["User agent", navigator.userAgent]);
2874
+ rows.push(["Screen", `${screen.width}×${screen.height}`]);
2875
+ rows.push(["Window", `${window.innerWidth}×${window.innerHeight}`]);
2876
+ // Version row contains an anchor tag; all other rows are plain text
2877
+ // and must be escaped. Treat row[0]==="Version" as pre-formatted HTML.
2878
+ body.innerHTML = `
2879
+ <dl class="mailx-about-dl">
2880
+ ${rows.map(([k, val]) => `<dt>${k}</dt><dd>${k === "Version" ? val : escapeHtml(val)}</dd>`).join("")}
2881
+ </dl>
2882
+ ${(accounts || []).length ? `
2883
+ <div class="mailx-about-accounts">
2884
+ <div class="mailx-about-section">Accounts</div>
2885
+ <ul>
2886
+ ${accounts.map(a => `<li>${escapeHtml(a.email || a.id)}${a.name ? ` — ${escapeHtml(a.name)}` : ""}</li>`).join("")}
2887
+ </ul>
2888
+ </div>` : ""}
2889
+ <div class="mailx-about-foot">mailx — local-first mail client</div>`;
2890
+ }
2891
+ catch (e) {
2892
+ body.textContent = `Failed to load: ${e.message}`;
2893
+ }
2894
+ }
2895
+ function escapeHtml(s) {
2896
+ return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
2897
+ }
2898
+ // Threaded view toggle
2899
+ optThreaded?.addEventListener("change", () => {
2900
+ const body = document.getElementById("ml-body");
2901
+ if (optThreaded.checked) {
2902
+ body?.classList.add("threaded");
2903
+ }
2904
+ else {
2905
+ body?.classList.remove("threaded");
2906
+ }
2907
+ localStorage.setItem("mailx-threaded", String(optThreaded.checked));
2908
+ reloadCurrentFolder();
2909
+ });
2910
+ // Flagged-only filter — keeps the CSS-level hiding for instant feedback on
2911
+ // the current page AND re-queries the folder so flagged messages that live
2912
+ // outside the currently-loaded page show up.
2913
+ optFlagged?.addEventListener("change", () => {
2914
+ const body = document.getElementById("ml-body");
2915
+ if (optFlagged.checked)
2916
+ body?.classList.add("flagged-only");
2917
+ else
2918
+ body?.classList.remove("flagged-only");
2919
+ localStorage.setItem("mailx-flagged", String(optFlagged.checked));
2920
+ reloadCurrentFolder();
2921
+ });
2922
+ // Folder counts toggle
2923
+ optFolderCounts?.addEventListener("change", () => {
2924
+ const tree = document.getElementById("folder-tree");
2925
+ if (optFolderCounts.checked) {
2926
+ tree?.classList.add("show-folder-counts");
2927
+ }
2928
+ else {
2929
+ tree?.classList.remove("show-folder-counts");
2930
+ }
2931
+ localStorage.setItem("mailx-folder-counts", String(optFolderCounts.checked));
2932
+ });
2933
+ // Q52: Reset column widths — clears persisted list/viewer splitter and
2934
+ // restores the default CSS-var value. Currently only the list/viewer split
2935
+ // is user-resizable; if per-column drag-resize lands later, add its keys to
2936
+ // the cleanup list below.
2937
+ document.getElementById("btn-reset-widths")?.addEventListener("click", () => {
2938
+ localStorage.removeItem("mailx-split");
2939
+ document.documentElement.style.removeProperty("--list-viewer-split");
2940
+ if (viewDropdown)
2941
+ viewDropdown.hidden = true;
2942
+ });
2943
+ // ── Settings menu ──
2944
+ const settingsBtn = document.getElementById("btn-settings");
2945
+ const settingsDropdown = document.getElementById("settings-dropdown");
2946
+ const optEditorQuill = document.getElementById("opt-editor-quill");
2947
+ const optEditorTiptap = document.getElementById("opt-editor-tiptap");
2948
+ settingsBtn?.addEventListener("click", (e) => {
2949
+ e.stopPropagation();
2950
+ if (viewDropdown)
2951
+ viewDropdown.hidden = true;
2952
+ const restartDd = document.getElementById("restart-dropdown");
2953
+ if (restartDd)
2954
+ restartDd.hidden = true;
2955
+ if (settingsDropdown)
2956
+ settingsDropdown.hidden = !settingsDropdown.hidden;
2957
+ });
2958
+ // Close handled by the shared document click handler above
2959
+ // Load current editor setting from server
2960
+ getSettings().then((s) => {
2961
+ const ed = s.ui?.editor || "quill";
2962
+ if (optEditorQuill)
2963
+ optEditorQuill.checked = ed === "quill";
2964
+ if (optEditorTiptap)
2965
+ optEditorTiptap.checked = ed === "tiptap";
2966
+ }).catch(() => { });
2967
+ // Save editor choice to server settings
2968
+ function saveEditorSetting(editor) {
2969
+ getSettings().then((settings) => {
2970
+ settings.ui = { ...settings.ui, editor };
2971
+ saveSettings(settings);
2972
+ }).catch(() => { });
2973
+ }
2974
+ optEditorQuill?.addEventListener("change", () => {
2975
+ if (optEditorQuill.checked)
2976
+ saveEditorSetting("quill");
2977
+ });
2978
+ optEditorTiptap?.addEventListener("change", () => {
2979
+ if (optEditorTiptap.checked)
2980
+ saveEditorSetting("tiptap");
2981
+ });
2982
+ // External editor preference (Edit-in-Word handoff target). Stored under
2983
+ // settings.externalEditor so the service can read it via loadSettings().
2984
+ // "auto" tries Word → LibreOffice → OS default; explicit values force
2985
+ // that editor (still falling back to OS default if it isn't installed).
2986
+ const optExtEditAuto = document.getElementById("opt-extedit-auto");
2987
+ const optExtEditWord = document.getElementById("opt-extedit-word");
2988
+ const optExtEditLibre = document.getElementById("opt-extedit-libre");
2989
+ getSettings().then((s) => {
2990
+ const v = s.externalEditor || "auto";
2991
+ if (optExtEditAuto)
2992
+ optExtEditAuto.checked = v === "auto";
2993
+ if (optExtEditWord)
2994
+ optExtEditWord.checked = v === "word";
2995
+ if (optExtEditLibre)
2996
+ optExtEditLibre.checked = v === "libreoffice";
2997
+ }).catch(() => { });
2998
+ function saveExtEditor(v) {
2999
+ getSettings().then((settings) => {
3000
+ settings.externalEditor = v;
3001
+ saveSettings(settings);
3002
+ }).catch(() => { });
3003
+ }
3004
+ optExtEditAuto?.addEventListener("change", () => { if (optExtEditAuto.checked)
3005
+ saveExtEditor("auto"); });
3006
+ optExtEditWord?.addEventListener("change", () => { if (optExtEditWord.checked)
3007
+ saveExtEditor("word"); });
3008
+ optExtEditLibre?.addEventListener("change", () => { if (optExtEditLibre.checked)
3009
+ saveExtEditor("libreoffice"); });
3010
+ // ── AI feature toggles ──
3011
+ // One umbrella settings record (AutocompleteSettings) holds the provider config
3012
+ // + per-feature on/off flags. All features default OFF — user must opt into
3013
+ // each AI behavior individually. Per user preference (2026-04-21).
3014
+ const optAutocomplete = document.getElementById("opt-autocomplete");
3015
+ const optAiTranslate = document.getElementById("opt-ai-translate");
3016
+ const optAiProofread = document.getElementById("opt-ai-proofread");
3017
+ getAutocompleteSettings().then((ac) => {
3018
+ if (optAutocomplete)
3019
+ optAutocomplete.checked = !!ac.enabled;
3020
+ if (optAiTranslate)
3021
+ optAiTranslate.checked = !!ac.translateEnabled;
3022
+ if (optAiProofread)
3023
+ optAiProofread.checked = !!ac.proofreadEnabled;
3024
+ }).catch(() => { });
3025
+ function persistAi(mutator) {
3026
+ getAutocompleteSettings().then((ac) => {
3027
+ mutator(ac);
3028
+ saveAutocompleteSettings(ac);
3029
+ }).catch(() => { });
3030
+ }
3031
+ optAutocomplete?.addEventListener("change", () => persistAi((ac) => { ac.enabled = optAutocomplete.checked; }));
3032
+ optAiTranslate?.addEventListener("change", () => persistAi((ac) => { ac.translateEnabled = optAiTranslate.checked; }));
3033
+ optAiProofread?.addEventListener("change", () => {
3034
+ persistAi((ac) => { ac.proofreadEnabled = optAiProofread.checked; });
3035
+ // Mirror to localStorage so the compose editor (separate page/iframe with
3036
+ // its own getSettings cycle) can read it synchronously.
3037
+ try {
3038
+ localStorage.setItem("mailx-ai-proofread-enabled", String(optAiProofread.checked));
3039
+ }
3040
+ catch { /* */ }
3041
+ });
3042
+ // Sender reputation check (Spamhaus DBL). Stored at top-level settings so
3043
+ // the service can read it cheaply without going through autocomplete config.
3044
+ // Off by default — enabling it leaks read-recipient domains to Spamhaus's
3045
+ // DNS infra, which the user should opt into knowingly.
3046
+ const optCheckReputation = document.getElementById("opt-check-reputation");
3047
+ getSettings().then((s) => {
3048
+ if (optCheckReputation)
3049
+ optCheckReputation.checked = !!s.checkDomainReputation;
3050
+ }).catch(() => { });
3051
+ optCheckReputation?.addEventListener("change", () => {
3052
+ getSettings().then((settings) => {
3053
+ settings.checkDomainReputation = !!optCheckReputation.checked;
3054
+ saveSettings(settings);
3055
+ }).catch(() => { });
3056
+ });
3057
+ // Auto mark-as-read settings (per-device localStorage; the viewer reads
3058
+ // these directly when showing a message). Default on with a 2s delay so
3059
+ // scrolling through a folder doesn't mark every glanced-at message as
3060
+ // read, but a deliberate read still gets recorded.
3061
+ const optAutomarkRead = document.getElementById("opt-automark-read");
3062
+ const optAutomarkDelay = document.getElementById("opt-automark-delay");
3063
+ try {
3064
+ if (optAutomarkRead)
3065
+ optAutomarkRead.checked = localStorage.getItem("mailx-automark-read") !== "false";
3066
+ if (optAutomarkDelay)
3067
+ optAutomarkDelay.value = localStorage.getItem("mailx-automark-delay") || "2";
3068
+ }
3069
+ catch { /* private mode */ }
3070
+ optAutomarkRead?.addEventListener("change", () => {
3071
+ try {
3072
+ localStorage.setItem("mailx-automark-read", String(optAutomarkRead.checked));
3073
+ }
3074
+ catch { /* */ }
3075
+ });
3076
+ optAutomarkDelay?.addEventListener("change", () => {
3077
+ const v = parseFloat(optAutomarkDelay.value);
3078
+ if (Number.isFinite(v) && v >= 0) {
3079
+ try {
3080
+ localStorage.setItem("mailx-automark-delay", String(v));
3081
+ }
3082
+ catch { /* */ }
3083
+ }
3084
+ });
3085
+ const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
3086
+ // Wait for server ready signal, then fetch version
3087
+ const versionPromise = getVersion();
3088
+ versionPromise.then((d) => {
3089
+ const els = document.querySelectorAll(".app-version");
3090
+ const storage = d.storage || {};
3091
+ const storageLabel = storage.provider && storage.provider !== "local"
3092
+ ? ` [${storage.provider}]`
3093
+ : "";
3094
+ const text = `mailx v${d.version}${storageLabel}${isApp ? "" : " [browser]"}`;
3095
+ const tip = storage.provider && storage.provider !== "local"
3096
+ ? (storage.cloudPath ? `My Drive/${storage.cloudPath}/` : storage.provider)
3097
+ : "";
3098
+ for (const el of els) {
3099
+ el.textContent = text;
3100
+ if (tip)
3101
+ el.title = tip;
3102
+ }
3103
+ if (d.settingsError) {
3104
+ showAlert(d.settingsError, "settings-error");
3105
+ // Add repair button to the banner
3106
+ const banner = document.getElementById("alert-banner");
3107
+ if (banner && !banner.querySelector(".repair-btn")) {
3108
+ const btn = document.createElement("button");
3109
+ btn.className = "repair-btn status-action";
3110
+ btn.textContent = "Repair: restore accounts from cache";
3111
+ btn.style.cssText = "margin-left:1rem;padding:0.25rem 0.75rem;background:#a6e3a1;color:#1e1e2e;border:none;border-radius:4px;cursor:pointer;font-weight:bold";
3112
+ btn.onclick = async () => {
3113
+ btn.textContent = "Restoring...";
3114
+ btn.disabled = true;
3115
+ try {
3116
+ const data = await repairAccounts();
3117
+ if (data.ok) {
3118
+ hideAlert();
3119
+ setTimeout(() => location.reload(), 1000);
3120
+ }
3121
+ else {
3122
+ btn.textContent = `Failed: ${data.error}`;
3123
+ }
3124
+ }
3125
+ catch (e) {
3126
+ btn.textContent = `Error: ${e.message}`;
3127
+ }
3128
+ };
3129
+ banner.querySelector("#alert-text")?.after(btn);
3130
+ }
3131
+ }
3132
+ else if (storage.cloudError) {
3133
+ showAlert(`Cloud storage error: ${storage.cloudError}`, "cloud-error");
3134
+ }
3135
+ }).catch((e) => {
3136
+ // Version fetch failed
3137
+ const els = document.querySelectorAll(".app-version");
3138
+ const text = isApp ? `mailx [version error: ${e.message}]` : "mailx [server offline]";
3139
+ for (const el of els)
3140
+ el.textContent = text;
3141
+ });
3142
+ // ── Sync pending indicator + server health check (HTTP mode only) ──
3143
+ let serverDown = false;
3144
+ if (isApp) {
3145
+ // IPC mode: events come via push, no polling needed
3146
+ }
3147
+ else
3148
+ setInterval(async () => {
3149
+ try {
3150
+ const data = await getSyncPending();
3151
+ const el = document.getElementById("status-pending");
3152
+ if (el) {
3153
+ el.textContent = data.pending > 0 ? `↻ ${data.pending} pending` : "";
3154
+ el.style.color = data.pending > 0 ? "oklch(0.75 0.15 60)" : "";
3155
+ }
3156
+ // Server is back — reload if it was down
3157
+ if (serverDown) {
3158
+ serverDown = false;
3159
+ const statusEl = document.getElementById("status-sync");
3160
+ if (statusEl)
3161
+ statusEl.textContent = "Server reconnected";
3162
+ location.reload();
3163
+ }
3164
+ }
3165
+ catch {
3166
+ if (!serverDown) {
3167
+ serverDown = true;
3168
+ const statusEl = document.getElementById("status-sync");
3169
+ if (statusEl) {
3170
+ statusEl.textContent = "SERVER OFFLINE";
3171
+ statusEl.style.color = "oklch(0.65 0.2 25)";
3172
+ }
3173
+ }
3174
+ }
3175
+ }, 5000);
3176
+ // ── Outbox queue indicator (status-queue span) ──
3177
+ // Event-driven in IPC mode (service pushes outboxStatus on every mutation).
3178
+ // Plus a 15s poll safety net for both modes so a missed event doesn't leave
3179
+ // the user staring at stale numbers. Idempotent — renderOutboxStatus just
3180
+ // overwrites the text.
3181
+ function renderOutboxStatus(s) {
3182
+ // Feed the folder-tree synthesized "Send-pending" row. Idempotent —
3183
+ // it no-ops when the presence state and count haven't changed.
3184
+ setOutboxTotal(s?.total || 0);
3185
+ const el = document.getElementById("status-queue");
3186
+ if (!el)
3187
+ return;
3188
+ if (!s || !s.total || s.total === 0) {
3189
+ el.textContent = "";
3190
+ el.title = "";
3191
+ el.style.color = "";
3192
+ return;
3193
+ }
3194
+ const parts = [`✉ ${s.total} queued`];
3195
+ if (s.claimed > 0)
3196
+ parts.push(`${s.claimed} sending`);
3197
+ if (s.retrying > 0)
3198
+ parts.push(`${s.retrying} retrying (×${s.maxAttempts})`);
3199
+ if (s.oldestAgeSec >= 60) {
3200
+ const age = s.oldestAgeSec >= 3600
3201
+ ? `${Math.floor(s.oldestAgeSec / 3600)}h`
3202
+ : `${Math.floor(s.oldestAgeSec / 60)}m`;
3203
+ parts.push(`oldest ${age}`);
3204
+ }
3205
+ el.textContent = parts.join(" · ");
3206
+ const perAcct = s.perAccount || {};
3207
+ const detail = Object.keys(perAcct).sort().map(a => `${a}: ${perAcct[a].total} total, ${perAcct[a].claimed} sending, ${perAcct[a].retrying} retrying`).join("\n");
3208
+ el.title = detail || "";
3209
+ // Orange when retrying, red when stuck >5min, else muted.
3210
+ el.style.color = s.oldestAgeSec > 300 ? "oklch(0.65 0.2 25)"
3211
+ : s.retrying > 0 ? "oklch(0.75 0.15 60)"
3212
+ : "";
3213
+ }
3214
+ setInterval(async () => {
3215
+ try {
3216
+ const { getOutboxStatus, getDiagnostics } = await import("./lib/api-client.js");
3217
+ renderOutboxStatus(await getOutboxStatus());
3218
+ renderDiagnosticsBadge(await getDiagnostics());
3219
+ }
3220
+ catch { /* service unreachable */ }
3221
+ }, 15000);
3222
+ // First read on startup so the bar isn't blank.
3223
+ (async () => {
3224
+ try {
3225
+ const { getOutboxStatus, getDiagnostics } = await import("./lib/api-client.js");
3226
+ renderOutboxStatus(await getOutboxStatus());
3227
+ renderDiagnosticsBadge(await getDiagnostics());
3228
+ }
3229
+ catch { /* */ }
3230
+ })();
3231
+ /** Render the ⚠ "something's wrong" badge next to status-sync. Shown when
3232
+ * any account has non-zero diagnostic counters (inactivity timeouts,
3233
+ * connection-cap hits, rate-limit waits). Tooltip breaks down per-account. */
3234
+ function renderDiagnosticsBadge(snapshot) {
3235
+ const host = document.getElementById("status-diag");
3236
+ if (!host)
3237
+ return;
3238
+ const issues = (snapshot || []).filter(d => d.inactivityTimeouts > 0 || d.connCapHits > 0 || d.rateLimitWaits > 0);
3239
+ if (issues.length === 0) {
3240
+ host.hidden = true;
3241
+ host.textContent = "";
3242
+ host.title = "";
3243
+ return;
3244
+ }
3245
+ host.hidden = false;
3246
+ host.textContent = "⚠";
3247
+ const totalTimeouts = issues.reduce((a, d) => a + d.inactivityTimeouts, 0);
3248
+ const totalCapHits = issues.reduce((a, d) => a + d.connCapHits, 0);
3249
+ const totalRateLimits = issues.reduce((a, d) => a + d.rateLimitWaits, 0);
3250
+ const summary = [
3251
+ totalTimeouts > 0 ? `${totalTimeouts} IMAP inactivity timeout${totalTimeouts === 1 ? "" : "s"}` : null,
3252
+ totalCapHits > 0 ? `${totalCapHits} conn-cap rejection${totalCapHits === 1 ? "" : "s"}` : null,
3253
+ totalRateLimits > 0 ? `${totalRateLimits} rate-limit wait${totalRateLimits === 1 ? "" : "s"}` : null,
3254
+ ].filter(Boolean).join("; ");
3255
+ const detail = issues.map(d => {
3256
+ const parts = [
3257
+ d.inactivityTimeouts > 0 ? `${d.inactivityTimeouts} timeout${d.inactivityTimeouts === 1 ? "" : "s"}` : null,
3258
+ d.connCapHits > 0 ? `${d.connCapHits} conn-cap` : null,
3259
+ d.rateLimitWaits > 0 ? `${d.rateLimitWaits} rate-limit` : null,
3260
+ ].filter(Boolean).join(", ");
3261
+ const last = d.lastCommand ? `\n last: ${d.lastCommand}` : "";
3262
+ return `${d.accountId}: ${parts}${last}`;
3263
+ }).join("\n");
3264
+ host.title = `Connection issues — ${summary}\n\n${detail}`;
3265
+ }
3266
+ // Q64: pop-out a message into a floating overlay (real-OS-window pending C44).
3267
+ document.addEventListener("mailx-popout-message", (async (e) => {
3268
+ const { accountId, uid, folderId, subject } = e.detail || {};
3269
+ if (!accountId || !uid)
3270
+ return;
3271
+ const { getMessage } = await import("./lib/api-client.js");
3272
+ let msg;
3273
+ try {
3274
+ msg = await getMessage(accountId, uid, false, folderId);
3275
+ }
3276
+ catch (err) {
3277
+ alert(`Couldn't load message: ${err?.message || err}`);
3278
+ return;
3279
+ }
3280
+ const wrapper = document.createElement("div");
3281
+ wrapper.className = "popout-overlay";
3282
+ wrapper.style.cssText = "position:fixed;top:5vh;right:5vw;width:min(900px,60vw);height:min(800px,80vh);z-index:1500;background:var(--color-bg);border:1px solid var(--color-border);border-radius:6px;box-shadow:0 8px 32px rgba(0,0,0,0.3);display:flex;flex-direction:column;resize:both;overflow:hidden;";
3283
+ const header = document.createElement("div");
3284
+ header.style.cssText = "display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--color-bg-surface);border-bottom:1px solid var(--color-border);font-weight:600;cursor:move;";
3285
+ const title = document.createElement("span");
3286
+ title.textContent = subject || "(no subject)";
3287
+ title.style.cssText = "flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;";
3288
+ const closeBtn = document.createElement("button");
3289
+ closeBtn.textContent = "×";
3290
+ closeBtn.style.cssText = "background:none;border:none;font-size:1.4rem;cursor:pointer;padding:0 8px;";
3291
+ closeBtn.addEventListener("click", () => wrapper.remove());
3292
+ header.appendChild(title);
3293
+ header.appendChild(closeBtn);
3294
+ const meta = document.createElement("div");
3295
+ meta.style.cssText = "padding:8px 12px;border-bottom:1px solid var(--color-border);font-size:0.9rem;color:var(--color-text-muted);";
3296
+ meta.innerHTML = `<div><b>From:</b> ${escapeHtmlBasic(msg.from?.name || "")} &lt;${escapeHtmlBasic(msg.from?.address || "")}&gt;</div>
3297
+ <div><b>To:</b> ${(msg.to || []).map((a) => escapeHtmlBasic(`${a.name || ""} <${a.address}>`)).join(", ")}</div>
3298
+ ${msg.cc?.length ? `<div><b>Cc:</b> ${msg.cc.map((a) => escapeHtmlBasic(`${a.name || ""} <${a.address}>`)).join(", ")}</div>` : ""}
3299
+ <div><b>Date:</b> ${new Date(msg.date).toLocaleString()}</div>`;
3300
+ const body = document.createElement("iframe");
3301
+ body.style.cssText = "flex:1;border:none;width:100%;background:#fff;";
3302
+ body.sandbox.add("allow-same-origin");
3303
+ wrapper.appendChild(header);
3304
+ wrapper.appendChild(meta);
3305
+ wrapper.appendChild(body);
3306
+ document.body.appendChild(wrapper);
3307
+ body.srcdoc = msg.bodyHtml || `<pre style="white-space:pre-wrap;font-family:ui-sans-serif">${escapeHtmlBasic(msg.bodyText || "(no body)")}</pre>`;
3308
+ // Drag-to-move.
3309
+ let dragX = 0, dragY = 0, dragging = false;
3310
+ header.addEventListener("mousedown", (de) => {
3311
+ if (de.target.tagName === "BUTTON")
3312
+ return;
3313
+ dragging = true;
3314
+ const rect = wrapper.getBoundingClientRect();
3315
+ dragX = de.clientX - rect.left;
3316
+ dragY = de.clientY - rect.top;
3317
+ de.preventDefault();
3318
+ });
3319
+ document.addEventListener("mousemove", (de) => {
3320
+ if (!dragging)
3321
+ return;
3322
+ wrapper.style.left = `${de.clientX - dragX}px`;
3323
+ wrapper.style.top = `${de.clientY - dragY}px`;
3324
+ wrapper.style.right = "auto";
3325
+ });
3326
+ document.addEventListener("mouseup", () => { dragging = false; });
3327
+ }));
3328
+ function escapeHtmlBasic(s) {
3329
+ return (s || "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]));
3330
+ }
3331
+ // Click the status-queue pill to open the outbox view (pink-row list).
3332
+ document.getElementById("status-queue")?.addEventListener("click", async () => {
3333
+ try {
3334
+ const { openOutboxView } = await import("./components/outbox-view.js");
3335
+ openOutboxView();
3336
+ }
3337
+ catch (e) {
3338
+ console.error("Outbox view failed:", e);
3339
+ }
3340
+ });
3341
+ // Make it look clickable.
3342
+ (() => {
3343
+ const el = document.getElementById("status-queue");
3344
+ if (el) {
3345
+ el.style.cursor = "pointer";
3346
+ el.title = "Click to view queued messages";
3347
+ }
3348
+ })();
3349
+ console.log("mailx client initialized, location:", location.href);
3350
+ updateNewMessageCount();
3351
+ // Offline indicator — show/hide based on navigator.onLine. Doesn't gate any
3352
+ // functionality (the store is local-first; edits queue and replay on
3353
+ // reconnect regardless) but tells the user their queued actions are stacking
3354
+ // up for a later push rather than hitting the server now.
3355
+ const offlineEl = document.getElementById("status-offline");
3356
+ function refreshOfflineIndicator() {
3357
+ if (!offlineEl)
3358
+ return;
3359
+ offlineEl.hidden = navigator.onLine;
3360
+ }
3361
+ window.addEventListener("online", refreshOfflineIndicator);
3362
+ window.addEventListener("offline", refreshOfflineIndicator);
3363
+ refreshOfflineIndicator();
3364
+ // ── Midnight refresh — update date display when day changes ──
3365
+ function scheduleMiddnightRefresh() {
3366
+ const now = new Date();
3367
+ const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
3368
+ const ms = midnight.getTime() - now.getTime();
3369
+ setTimeout(() => {
3370
+ reloadCurrentFolder();
3371
+ scheduleMiddnightRefresh();
3372
+ }, ms + 1000); // 1s after midnight
3373
+ }
3374
+ scheduleMiddnightRefresh();
3375
+ // ── Apply theme from settings ──
3376
+ versionPromise.then((d) => {
3377
+ if (d.theme === "dark")
3378
+ document.documentElement.classList.add("theme-dark");
3379
+ else if (d.theme === "light")
3380
+ document.documentElement.classList.add("theme-light");
3381
+ }).catch(() => { });
3382
+ // ── Save window geometry on close (IPC mode only) ──
3383
+ // Sends window position and size so the next launch restores them.
3384
+ if (isApp) {
3385
+ const ipcApi = window.mailxapi;
3386
+ function sendGeometry() {
3387
+ if (!ipcApi?.saveWindowGeometry)
3388
+ return;
3389
+ ipcApi.saveWindowGeometry({
3390
+ x: window.screenX,
3391
+ y: window.screenY,
3392
+ width: window.outerWidth,
3393
+ height: window.outerHeight,
3394
+ }).catch(() => { });
3395
+ }
3396
+ // Save on unload (window close) and periodically as a safety net
3397
+ window.addEventListener("beforeunload", sendGeometry);
3398
+ setInterval(sendGeometry, 60_000);
3399
+ }
3400
+ //# sourceMappingURL=app.js.map