@bobfrankston/mailx 1.0.465 → 1.0.500

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