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