@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,1231 +0,0 @@
1
- /**
2
- * Compose window entry point.
3
- * Opened as a popup from the main mailx window.
4
- * Receives init data via window.opener.postMessage or URL params.
5
- */
6
-
7
- import { createEditor, type MailxEditor } from "./editor.js";
8
- import { getVersion, getSettings, getAccounts, searchContacts, sendMessage, saveDraft as apiSaveDraft, deleteDraft, logClientEvent, addPreferredContact, addToDenylist, openInWord, closeWordEdit, onEvent } from "../lib/api-client.js";
9
- import { showContextMenu } from "../components/context-menu.js";
10
-
11
- // Very first line the iframe runs — if this doesn't reach Node, the iframe
12
- // itself isn't loading or the bridge is completely broken.
13
- logClientEvent("compose-module-loaded", { href: location.href, version: (window as any).mailxVersion || "?" });
14
-
15
- /** Close compose window */
16
- function closeCompose(): void {
17
- logClientEvent("compose-close");
18
- // S61: Android WebView's window.close() override is unreliable inside
19
- // iframes — compose overlay sometimes stays visible after Send. Primary
20
- // path is a parent postMessage; window.close() is a fallback that also
21
- // works on desktop/msger where the override DOES fire reliably.
22
- try { parent.postMessage({ type: "mailx-compose-close" }, "*"); } catch { /* */ }
23
- try { window.close(); } catch { /* */ }
24
- }
25
-
26
- interface ComposeInit {
27
- mode: string;
28
- accountId: string;
29
- to: { name: string; address: string }[];
30
- cc: { name: string; address: string }[];
31
- subject: string;
32
- bodyHtml: string;
33
- inReplyTo: string;
34
- references: string[];
35
- accounts: { id: string; name: string; email: string }[];
36
- fromAddress?: string;
37
- draftUid?: number;
38
- draftFolderId?: number;
39
- }
40
-
41
- // ── Load editor scripts dynamically ──
42
-
43
- function loadScript(src: string): Promise<void> {
44
- return new Promise((resolve, reject) => {
45
- const s = document.createElement("script");
46
- s.src = src;
47
- s.onload = () => resolve();
48
- s.onerror = () => reject(new Error(`Failed to load ${src}`));
49
- document.head.appendChild(s);
50
- });
51
- }
52
-
53
- function loadCSS(href: string): void {
54
- const link = document.createElement("link");
55
- link.rel = "stylesheet";
56
- link.href = href;
57
- document.head.appendChild(link);
58
- }
59
-
60
- async function loadEditorAssets(type: "quill" | "tiptap"): Promise<void> {
61
- if (type === "tiptap") {
62
- // tiptap UMD bundles from CDN
63
- const cdn = "https://cdn.jsdelivr.net/npm";
64
- await loadScript(`${cdn}/@tiptap/core@2/dist/index.umd.js`);
65
- await Promise.all([
66
- loadScript(`${cdn}/@tiptap/starter-kit@2/dist/index.umd.js`),
67
- loadScript(`${cdn}/@tiptap/extension-link@2/dist/index.umd.js`),
68
- loadScript(`${cdn}/@tiptap/extension-image@2/dist/index.umd.js`),
69
- loadScript(`${cdn}/@tiptap/extension-underline@2/dist/index.umd.js`),
70
- loadScript(`${cdn}/@tiptap/extension-placeholder@2/dist/index.umd.js`),
71
- ]);
72
- } else {
73
- // Quill
74
- loadCSS("https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css");
75
- await loadScript("https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js");
76
- }
77
- }
78
-
79
- // ── Determine editor type from settings ──
80
- //
81
- // Compose must open fast. The previous flow awaited getVersion() then
82
- // getSettings() sequentially before the editor was even loaded — any
83
- // service-side stall (busy sync, slow IMAP, hung OAuth refresh) turned
84
- // "click Reply" into a multi-second / multi-minute wait with a blank
85
- // compose window. Local-first: read the editor-type preference from a
86
- // tiny localStorage cache that we update whenever getSettings succeeds
87
- // in the background. Default to quill on first run / cache miss.
88
- let editorType: "quill" | "tiptap" = "quill";
89
- let appSettings: any = null;
90
- try {
91
- const cached = localStorage.getItem("mailx-editor-type");
92
- if (cached === "tiptap" || cached === "quill") editorType = cached;
93
- } catch { /* private-mode / SecurityError — default quill */ }
94
- // Refresh the cache asynchronously — doesn't block compose open.
95
- (async () => {
96
- try {
97
- appSettings = await getSettings();
98
- const next = appSettings?.ui?.editor === "tiptap" ? "tiptap" : "quill";
99
- try { localStorage.setItem("mailx-editor-type", next); } catch { /* */ }
100
- // Note: we don't hot-swap the editor if the preference changed while
101
- // compose was opening — the old type is already instantiated. Next
102
- // compose open will pick up the new preference.
103
- } catch { /* non-fatal */ }
104
- })();
105
-
106
- // Whatever happens in editor init, surface failures to the mailx log
107
- // AND fall through to a plain-contenteditable fallback so the rest of
108
- // the compose script (Discard, X, Send, save-draft) still runs. Earlier
109
- // versions re-threw asset-load failures, which left compose with dead
110
- // buttons and the user with no recourse — exactly the "Reply window
111
- // won't close" symptom we hit when Quill's CDN was unreachable.
112
- let editor: MailxEditor;
113
- let editorAssetError: any = null;
114
- try {
115
- await loadEditorAssets(editorType);
116
- } catch (e: any) {
117
- editorAssetError = e;
118
- logClientEvent("compose-editor-assets-failed", { type: editorType, error: String(e?.message || e) });
119
- }
120
- const container = document.getElementById("compose-editor")!;
121
- container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
122
- try {
123
- if (editorAssetError) throw editorAssetError;
124
- editor = await createEditor(container, editorType);
125
- } catch (e: any) {
126
- logClientEvent("compose-editor-create-failed", { type: editorType, error: String(e?.message || e) });
127
- // Render a minimal contenteditable fallback so the user can still type
128
- // SOMETHING. Without this, an editor failure leaves the compose form
129
- // half-functional (To/Cc/Bcc work, body doesn't) and the user doesn't
130
- // know why. The fallback is a plain div — no toolbar, no rich text.
131
- container.innerHTML = `<div class="compose-fallback-editor" contenteditable="true" style="border:1px solid #c00;padding:8px;min-height:200px;background:#fff" data-fallback="true"></div>`;
132
- const fallback = container.querySelector<HTMLElement>(".compose-fallback-editor")!;
133
- editor = {
134
- root: fallback,
135
- setHtml: (html: string) => { fallback.innerHTML = html; },
136
- getHtml: () => fallback.innerHTML,
137
- getText: () => fallback.innerText,
138
- focus: () => fallback.focus(),
139
- setCursor: () => { /* no-op */ },
140
- getScrollContainer: () => fallback,
141
- onContentChange: (handler: () => void) => { fallback.addEventListener("input", handler); },
142
- onKeyDown: (handler: (e: KeyboardEvent) => void) => { fallback.addEventListener("keydown", handler); },
143
- insertTextAtCursor: (text: string) => {
144
- const sel = window.getSelection();
145
- if (sel && sel.rangeCount > 0) {
146
- const range = sel.getRangeAt(0);
147
- range.deleteContents();
148
- range.insertNode(document.createTextNode(text));
149
- } else {
150
- fallback.append(document.createTextNode(text));
151
- }
152
- },
153
- };
154
- // Surface the failure to the user in the status bar so they know
155
- // why the toolbar is missing and the editor is plain.
156
- setTimeout(() => showDraftStatus(`Editor failed to load (${editorType}). Plain-text fallback in use. Open log for details.`, true), 0);
157
- }
158
-
159
- // Ctrl+scroll / Ctrl+= / Ctrl+- / Ctrl+0 zoom for the compose editor body.
160
- // Persists per-session in localStorage so zoom survives window pop/close cycles.
161
- (() => {
162
- const STORAGE_KEY = "mailx.compose.zoom";
163
- const MIN = 0.5, MAX = 3, STEP = 0.1;
164
- let zoom = parseFloat(localStorage.getItem(STORAGE_KEY) || "1") || 1;
165
- const applyZoom = () => {
166
- container.style.fontSize = `${zoom}em`;
167
- localStorage.setItem(STORAGE_KEY, String(zoom));
168
- };
169
- applyZoom();
170
- container.addEventListener("wheel", (e: WheelEvent) => {
171
- if (!e.ctrlKey) return;
172
- e.preventDefault();
173
- const delta = e.deltaY < 0 ? STEP : -STEP;
174
- zoom = Math.min(MAX, Math.max(MIN, Math.round((zoom + delta) * 10) / 10));
175
- applyZoom();
176
- }, { passive: false });
177
- document.addEventListener("keydown", (e: KeyboardEvent) => {
178
- if (!(e.ctrlKey || e.metaKey)) return;
179
- if (e.key === "=" || e.key === "+") { zoom = Math.min(MAX, zoom + STEP); applyZoom(); e.preventDefault(); }
180
- else if (e.key === "-") { zoom = Math.max(MIN, zoom - STEP); applyZoom(); e.preventDefault(); }
181
- else if (e.key === "0") { zoom = 1; applyZoom(); e.preventDefault(); }
182
- });
183
- })();
184
-
185
- // ── Populate from init data ──
186
-
187
- // From field is a free-text input with a <datalist> of known accounts. The
188
- // user can pick a preset or type an arbitrary "Name <addr@domain>" — no
189
- // separate "Other..." escape hatch, no hidden custom input toggle.
190
- const fromInput = document.getElementById("compose-from-input") as HTMLInputElement;
191
- const fromOptions = document.getElementById("compose-from-options") as HTMLDataListElement;
192
- const toInput = document.getElementById("compose-to") as HTMLInputElement;
193
- const ccInput = document.getElementById("compose-cc") as HTMLInputElement;
194
- const bccInput = document.getElementById("compose-bcc") as HTMLInputElement;
195
- const subjectInput = document.getElementById("compose-subject") as HTMLInputElement;
196
-
197
- /** Registered accounts — populated once at init time, used to map the From
198
- * input value back to an account id on send. */
199
- interface ComposeAccount { id: string; name: string; label?: string; email: string; defaultSend?: boolean; }
200
- let knownAccounts: ComposeAccount[] = [];
201
-
202
- // ── AI ghost text autocomplete ──
203
- if (appSettings?.autocomplete?.enabled && appSettings.autocomplete.provider !== "off") {
204
- import("./ghost-text.js").then(({ initGhostText }) => {
205
- initGhostText(editor, {
206
- getSubject: () => subjectInput.value,
207
- getTo: () => toInput.value,
208
- }, { debounceMs: appSettings.autocomplete.debounceMs || 600 });
209
- }).catch(() => { /* autocomplete unavailable */ });
210
- }
211
-
212
- /** Format an account for the From field: "Name <email>". */
213
- function formatAccountFrom(acct: ComposeAccount): string {
214
- return `${acct.name} <${acct.email}>`;
215
- }
216
-
217
- const FROM_HISTORY_KEY = "mailx-from-history"; // up to 20 recent manual From entries
218
- const FROM_HISTORY_MAX = 20;
219
-
220
- function loadFromHistory(): string[] {
221
- try { return JSON.parse(localStorage.getItem(FROM_HISTORY_KEY) || "[]"); } catch { return []; }
222
- }
223
- function recordFromHistory(value: string): void {
224
- const v = (value || "").trim();
225
- if (!v) return;
226
- try {
227
- const list = loadFromHistory().filter(x => x !== v);
228
- list.unshift(v);
229
- localStorage.setItem(FROM_HISTORY_KEY, JSON.stringify(list.slice(0, FROM_HISTORY_MAX)));
230
- } catch { /* private mode */ }
231
- }
232
-
233
- /** Populate the From <datalist> with one entry per known account plus any
234
- * manually-typed addresses from localStorage history. Account entries rank
235
- * first; history entries get an "(used before)" label so the user can tell
236
- * which ones are real accounts vs free-form aliases. */
237
- function populateFromOptions(accounts: ComposeAccount[], selectedId?: string): void {
238
- knownAccounts = accounts;
239
- fromOptions.innerHTML = "";
240
- const seenValues = new Set<string>();
241
- for (const acct of accounts) {
242
- const opt = document.createElement("option");
243
- opt.value = formatAccountFrom(acct);
244
- const tag = acct.label || acct.name;
245
- opt.label = tag;
246
- fromOptions.appendChild(opt);
247
- seenValues.add(opt.value);
248
- }
249
- // Custom From history — addresses the user has typed before that don't
250
- // match any known account (aliases, +tag addresses, one-off identities).
251
- // Stored in localStorage because they're inherently per-device preferences;
252
- // moving them to an account profile would be a different feature.
253
- for (const value of loadFromHistory()) {
254
- if (seenValues.has(value)) continue;
255
- const opt = document.createElement("option");
256
- opt.value = value;
257
- opt.label = "(used before)";
258
- fromOptions.appendChild(opt);
259
- }
260
- if (!fromInput.value) {
261
- const selected = (selectedId && accounts.find(a => a.id === selectedId)) ||
262
- accounts.find(a => a.defaultSend) ||
263
- accounts[0];
264
- if (selected) fromInput.value = formatAccountFrom(selected);
265
- }
266
- }
267
-
268
- /** Parse the current From input into { name, address } for header building. */
269
- function parseFromInput(): { name: string; address: string } {
270
- const raw = fromInput.value.trim();
271
- const match = raw.match(/^(.+?)\s*<(.+?)>$/);
272
- if (match) return { name: match[1].trim(), address: match[2].trim() };
273
- return { name: "", address: raw };
274
- }
275
-
276
- /** Match the From input's address against the known accounts table and
277
- * return that account's id. Used by send() / saveDraft() to decide which
278
- * account to send through. Falls back to defaultSend, then first account. */
279
- function getFromAccountId(): string {
280
- const { address } = parseFromInput();
281
- const lower = address.toLowerCase();
282
- // Exact match wins
283
- const exact = knownAccounts.find(a => a.email.toLowerCase() === lower);
284
- if (exact) return exact.id;
285
- // Same-domain match — handles +tag aliases and identity addresses
286
- const domain = lower.split("@")[1] || "";
287
- if (domain) {
288
- const sameDomain = knownAccounts.find(a => a.email.toLowerCase().endsWith("@" + domain));
289
- if (sameDomain) return sameDomain.id;
290
- }
291
- // Give up — use default send account or the first account
292
- const def = knownAccounts.find(a => a.defaultSend) || knownAccounts[0];
293
- return def?.id || "";
294
- }
295
-
296
- /** Get the raw From header string ("Name <addr>"). */
297
- function getFromAddress(): string {
298
- return fromInput.value.trim();
299
- }
300
-
301
- /** Smart tab — skip to next empty field, ending at body */
302
- function smartTab(current: HTMLInputElement): void {
303
- const fields = [toInput, ccInput, bccInput, subjectInput];
304
- const currentIdx = fields.indexOf(current);
305
- // Look for next empty field after current
306
- for (let i = currentIdx + 1; i < fields.length; i++) {
307
- if (!fields[i].value.trim()) {
308
- fields[i].focus();
309
- return;
310
- }
311
- }
312
- // All fields filled or past the end — go to editor body
313
- editor.focus();
314
- }
315
-
316
- // ── Autocomplete ──
317
-
318
- /** Right-click on an autocomplete row → contextual actions. Two paths:
319
- * - Add to preferred (small modal: name / email / source-tag / org → write to
320
- * contacts.jsonc#preferred[])
321
- * - Never suggest this address (write to contacts.jsonc#denylist[]; the
322
- * service-side handler purges any matching discovered rows on apply) */
323
- function showAutocompleteContextMenu(
324
- e: MouseEvent,
325
- row: { name: string; email: string; source: string },
326
- ): void {
327
- showContextMenu(e.clientX, e.clientY, [
328
- {
329
- label: "Add to preferred…",
330
- action: () => openAddToPreferredModal(row),
331
- },
332
- {
333
- label: "Never suggest this address",
334
- action: async () => {
335
- try {
336
- await addToDenylist(row.email);
337
- } catch (err: any) {
338
- alert(`Failed to add to denylist: ${err?.message || err}`);
339
- }
340
- },
341
- },
342
- ]);
343
- }
344
-
345
- function openAddToPreferredModal(prefill: { name: string; email: string; source: string }): void {
346
- const overlay = document.createElement("div");
347
- overlay.className = "modal-overlay";
348
- overlay.innerHTML = `
349
- <div class="modal" role="dialog" aria-label="Add to preferred contacts">
350
- <h3>Add to preferred</h3>
351
- <p class="muted">Saved to <code>contacts.jsonc</code> on your shared drive.</p>
352
- <label>Name <input type="text" id="pf-name" /></label>
353
- <label>Email <input type="email" id="pf-email" /></label>
354
- <label>Source tag <input type="text" id="pf-source" placeholder="(optional — e.g. work, family)" /></label>
355
- <label>Organization <input type="text" id="pf-org" placeholder="(optional)" /></label>
356
- <div class="modal-actions">
357
- <button id="pf-cancel">Cancel</button>
358
- <button id="pf-save" class="primary">Save</button>
359
- </div>
360
- </div>
361
- `;
362
- document.body.appendChild(overlay);
363
- (overlay.querySelector("#pf-name") as HTMLInputElement).value = prefill.name || "";
364
- (overlay.querySelector("#pf-email") as HTMLInputElement).value = prefill.email || "";
365
- // Pre-fill source from existing tag if it's already a custom one (not a system source).
366
- const sysSources = new Set(["google", "discovered", "preferred", ""]);
367
- const initSource = sysSources.has(prefill.source || "") ? "" : prefill.source;
368
- (overlay.querySelector("#pf-source") as HTMLInputElement).value = initSource;
369
- const close = () => overlay.remove();
370
- overlay.querySelector("#pf-cancel")!.addEventListener("click", close);
371
- overlay.addEventListener("click", (ev) => { if (ev.target === overlay) close(); });
372
- overlay.querySelector("#pf-save")!.addEventListener("click", async () => {
373
- const name = (overlay.querySelector("#pf-name") as HTMLInputElement).value.trim();
374
- const email = (overlay.querySelector("#pf-email") as HTMLInputElement).value.trim();
375
- const source = (overlay.querySelector("#pf-source") as HTMLInputElement).value.trim();
376
- const org = (overlay.querySelector("#pf-org") as HTMLInputElement).value.trim();
377
- if (!email) { alert("Email is required."); return; }
378
- try {
379
- await addPreferredContact({ name, email, source, organization: org });
380
- close();
381
- } catch (err: any) {
382
- alert(`Failed to save: ${err?.message || err}`);
383
- }
384
- });
385
- (overlay.querySelector("#pf-name") as HTMLInputElement).focus();
386
- }
387
-
388
- function setupAutocomplete(input: HTMLInputElement): void {
389
- let dropdown: HTMLDivElement | null = null;
390
- let activeIndex = -1;
391
- let debounce: ReturnType<typeof setTimeout>;
392
-
393
- function closeDropdown(): void {
394
- if (dropdown) { dropdown.remove(); dropdown = null; }
395
- activeIndex = -1;
396
- }
397
-
398
- function getLastToken(): string {
399
- const val = input.value;
400
- const lastComma = val.lastIndexOf(",");
401
- return val.substring(lastComma + 1).trim();
402
- }
403
-
404
- function replaceLastToken(replacement: string): void {
405
- const val = input.value;
406
- const lastComma = val.lastIndexOf(",");
407
- const prefix = lastComma >= 0 ? val.substring(0, lastComma + 1) + " " : "";
408
- input.value = prefix + replacement + ", ";
409
- closeDropdown();
410
- input.focus();
411
- }
412
-
413
- input.addEventListener("input", () => {
414
- clearTimeout(debounce);
415
- const token = getLastToken();
416
- if (token.length < 1) { closeDropdown(); return; }
417
-
418
- debounce = setTimeout(() => {
419
- // rAF yield before hitting the DB — S60 mitigation, same reason
420
- // as the draft-save path. The 200 ms timer already deferred past
421
- // the input burst; this extra frame lets the last keystroke paint.
422
- requestAnimationFrame(async () => {
423
- try {
424
- const results = await searchContacts(token) as { name: string; email: string; source: string; useCount: number }[];
425
- if (results.length === 0) { closeDropdown(); return; }
426
-
427
- closeDropdown();
428
- dropdown = document.createElement("div");
429
- dropdown.className = "ac-dropdown";
430
- activeIndex = 0; // first item highlighted by default
431
-
432
- for (let i = 0; i < results.length; i++) {
433
- const item = document.createElement("div");
434
- item.className = `ac-item${i === 0 ? " ac-active" : ""}`;
435
-
436
- const nameEl = document.createElement("span");
437
- nameEl.className = "ac-item-name";
438
- nameEl.textContent = results[i].name || results[i].email;
439
-
440
- const emailEl = document.createElement("span");
441
- emailEl.className = "ac-item-email";
442
- emailEl.textContent = results[i].email;
443
-
444
- // Source badge — shows where this row came from. Custom
445
- // user tags from contacts.jsonc preferred entries
446
- // (`source: "work"`, `source: "family"`) flow through
447
- // verbatim; system sources show as 'google' / 'discovered'
448
- // / 'preferred'. Helps disambiguate two rows with the
449
- // same email but different names (Bob's wife vs Bob Smith).
450
- const sourceEl = document.createElement("span");
451
- sourceEl.className = "ac-item-source";
452
- sourceEl.textContent = results[i].source || "";
453
-
454
- item.appendChild(nameEl);
455
- if (results[i].name) item.appendChild(emailEl);
456
- if (results[i].source) item.appendChild(sourceEl);
457
-
458
- item.addEventListener("mousedown", (e) => {
459
- e.preventDefault();
460
- const display = results[i].name
461
- ? `${results[i].name} <${results[i].email}>`
462
- : results[i].email;
463
- replaceLastToken(display);
464
- });
465
-
466
- // Right-click → contextual actions on this autocomplete
467
- // row. Two paths: promote to preferred (writes to
468
- // contacts.jsonc#preferred[]) or denylist (writes to
469
- // contacts.jsonc#denylist[] and purges any matching
470
- // discovered rows). Both round-trip through cloudWrite.
471
- item.addEventListener("contextmenu", (e) => {
472
- e.preventDefault();
473
- e.stopPropagation();
474
- showAutocompleteContextMenu(e as MouseEvent, results[i]);
475
- });
476
-
477
- dropdown.appendChild(item);
478
- }
479
-
480
- input.parentElement!.appendChild(dropdown);
481
- } catch { /* ignore */ }
482
- });
483
- }, 200);
484
- });
485
-
486
- input.addEventListener("keydown", (e) => {
487
- if (!dropdown) return;
488
- const items = dropdown.querySelectorAll(".ac-item");
489
-
490
- if (e.key === "ArrowDown") {
491
- e.preventDefault();
492
- activeIndex = Math.min(activeIndex + 1, items.length - 1);
493
- items.forEach((el, i) => el.classList.toggle("ac-active", i === activeIndex));
494
- } else if (e.key === "ArrowUp") {
495
- e.preventDefault();
496
- activeIndex = Math.max(activeIndex - 1, 0);
497
- items.forEach((el, i) => el.classList.toggle("ac-active", i === activeIndex));
498
- } else if (e.key === "Tab" || e.key === "Enter") {
499
- if (items.length > 0) {
500
- e.preventDefault();
501
- const idx = activeIndex >= 0 ? activeIndex : 0;
502
- (items[idx] as HTMLElement).dispatchEvent(new MouseEvent("mousedown"));
503
- // Stay in field — user may want to add more addresses
504
- return;
505
- }
506
- } else if (e.key === "Escape") {
507
- closeDropdown();
508
- }
509
- });
510
-
511
- input.addEventListener("blur", () => {
512
- setTimeout(closeDropdown, 150);
513
- });
514
- }
515
-
516
- setupAutocomplete(toInput);
517
- setupAutocomplete(ccInput);
518
- setupAutocomplete(bccInput);
519
-
520
- function formatAddrs(addrs: { name: string; address: string }[]): string {
521
- return addrs.map(a => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
522
- }
523
-
524
- function parseAddrs(s: string): { name: string; address: string }[] {
525
- if (!s.trim()) return [];
526
- // Split on commas and drop empty segments. This handles trailing commas
527
- // ("foo@x.com,") and stray whitespace ("foo@x.com, ,bar@y.com") without
528
- // producing phantom empty addresses that fail validation on send.
529
- return s.split(",")
530
- .map(p => p.trim())
531
- .filter(p => p.length > 0)
532
- .map(part => {
533
- const match = part.match(/^(.+?)\s*<(.+?)>$/);
534
- if (match) return { name: match[1].trim(), address: match[2].trim() };
535
- return { name: "", address: part };
536
- });
537
- }
538
-
539
- function applyInit(init: ComposeInit): void {
540
- // Populate the From datalist with known accounts
541
- populateFromOptions(init.accounts, init.accountId);
542
-
543
- // If the reply has a specific identity address (alias / +tag), set it
544
- // as the From value directly — overrides the account default.
545
- if (init.fromAddress) {
546
- const account = init.accounts.find(a => a.id === init.accountId);
547
- const displayName = account?.name || "";
548
- fromInput.value = displayName ? `${displayName} <${init.fromAddress}>` : init.fromAddress;
549
- }
550
-
551
- toInput.value = formatAddrs(init.to);
552
- ccInput.value = formatAddrs(init.cc);
553
- subjectInput.value = init.subject;
554
-
555
- // Auto-expand Cc row if the init already has Cc content (reply-all, draft-with-cc)
556
- if (ccInput.value.trim()) {
557
- const ccRowEl = document.getElementById("compose-cc-row");
558
- const ccBtn = document.getElementById("btn-toggle-cc");
559
- if (ccRowEl) ccRowEl.hidden = false;
560
- if (ccBtn) ccBtn.classList.add("active");
561
- } else if (init.to && init.to.length === 1) {
562
- // Q49: heuristic auto-expand — when replying/composing to a single
563
- // recipient, check sent-history. If the user has previously Cc'd or
564
- // Bcc'd anyone on a message to this recipient, expand the matching
565
- // row (empty, just visible) so they're prompted to fill it.
566
- // Fire-and-forget; if the service call fails or the user starts
567
- // typing manually before it resolves, the answer doesn't matter.
568
- const firstEmail = init.to[0]?.address || "";
569
- if (firstEmail) {
570
- import("../lib/api-client.js").then(({ hasCcHistoryTo, hasBccHistoryTo }) => {
571
- hasCcHistoryTo(firstEmail)
572
- .then(res => {
573
- if (!res?.hasCc) return;
574
- const ccRowEl = document.getElementById("compose-cc-row");
575
- const ccBtn = document.getElementById("btn-toggle-cc");
576
- if (ccRowEl?.hidden && !ccInput.value) {
577
- ccRowEl.hidden = false;
578
- ccBtn?.classList.add("active");
579
- }
580
- })
581
- .catch(() => { /* non-fatal hint */ });
582
- hasBccHistoryTo(firstEmail)
583
- .then(res => {
584
- if (!res?.hasBcc) return;
585
- const bccRowEl = document.getElementById("compose-bcc-row");
586
- const bccBtn = document.getElementById("btn-toggle-bcc");
587
- if (bccRowEl?.hidden && !bccInput.value) {
588
- bccRowEl.hidden = false;
589
- bccBtn?.classList.add("active");
590
- }
591
- })
592
- .catch(() => { /* non-fatal hint */ });
593
- });
594
- }
595
- }
596
-
597
- // C42: append the account's signature (if configured) BEFORE rendering
598
- // the body. Two sources, in priority order:
599
- // 1. New `sig: { text, html? }` object — applied to NEW messages only.
600
- // `text` is HTML-escaped (newlines → <br>) unless `html: true` is set
601
- // (reserved for future use; currently `html` is ignored and text is
602
- // always escaped).
603
- // 2. Legacy `signature: string` — HTML, applied to new + reply + forward.
604
- //
605
- // Drafts are skipped — the signature is already baked into the saved body.
606
- // Editing an existing draft also skipped.
607
- let bodyToRender = init.bodyHtml || "";
608
- const acct: any = init.accounts.find(a => a.id === init.accountId);
609
- const isNew = init.mode !== "reply" && init.mode !== "replyAll"
610
- && init.mode !== "forward" && init.mode !== "draft" && !init.draftUid;
611
- const isReplyForward = init.mode === "reply" || init.mode === "replyAll"
612
- || init.mode === "forward";
613
-
614
- if (isNew && acct?.sig?.text) {
615
- const sigText = acct.sig.html
616
- ? acct.sig.text // future: trust as raw HTML
617
- : escapeHtml(acct.sig.text).replace(/\n/g, "<br>");
618
- bodyToRender = `${bodyToRender}<br><br>-- <br>${sigText}`;
619
- } else if (acct?.signature && init.mode !== "draft" && !init.draftUid) {
620
- const sigBlock = `<br><br>--<br>${acct.signature}`;
621
- bodyToRender = isReplyForward
622
- ? `<br>${sigBlock}<br>${bodyToRender}` // sig above the quote
623
- : `${bodyToRender}${sigBlock}`; // sig at the end for new
624
- }
625
- if (bodyToRender) {
626
- editor.setHtml(bodyToRender);
627
- editor.setCursor(0);
628
- }
629
-
630
- // If resuming a draft, track its UID for deletion after send
631
- if (init.draftUid) {
632
- draftUid = init.draftUid;
633
- }
634
-
635
- setComposeTitle(init.subject || "");
636
-
637
- // Focus first empty field: To → Subject → body
638
- if (!toInput.value) toInput.focus();
639
- else if (!subjectInput.value) subjectInput.focus();
640
- else editor.focus();
641
- }
642
-
643
- // Q68: dirty marker (•) in the window title until the next successful save.
644
- let composeDirty = false;
645
- function setComposeTitle(subject: string): void {
646
- const base = subject ? `${subject} - Compose` : "Compose - mailx";
647
- document.title = composeDirty ? `• ${base}` : base;
648
- }
649
- function markComposeDirty(): void {
650
- if (composeDirty) return;
651
- composeDirty = true;
652
- setComposeTitle(subjectInput?.value || "");
653
- }
654
- function markComposeClean(): void {
655
- if (!composeDirty) return;
656
- composeDirty = false;
657
- setComposeTitle(subjectInput?.value || "");
658
- }
659
-
660
- // ── Compose state (declared before init so the async IIFE can reference them) ──
661
-
662
- const DRAFT_INPUT_DEBOUNCE_MS = 1500; // save ~1.5s after the last keystroke
663
- const DRAFT_INTERVAL_MS = 5000; // safety-net interval save
664
- let draftUid: number | null = null;
665
- let draftId: string | null = null; // stable ID for dedup when APPENDUID unavailable
666
- let draftTimer: ReturnType<typeof setInterval> | null = null;
667
- let draftDebounceTimer: ReturnType<typeof setTimeout> | null = null;
668
- let lastDraftContent = "";
669
- let draftSaving = false; // prevent concurrent saves
670
- let draftSaveFailed = false; // surfaced in the compose status tag
671
-
672
- interface PendingAttachment {
673
- filename: string;
674
- mimeType: string;
675
- size: number;
676
- dataBase64: string;
677
- }
678
- const attachments: PendingAttachment[] = [];
679
-
680
- function showDraftStatus(text: string, isError: boolean): void {
681
- const status = document.getElementById("compose-status");
682
- if (!status) return;
683
- status.textContent = text;
684
- status.classList.toggle("compose-status-error", isError);
685
- }
686
-
687
- async function saveDraft(): Promise<void> {
688
- if (draftSaving) return; // previous save still in flight
689
- const content = editor.getHtml() + subjectInput.value + toInput.value;
690
- if (content === lastDraftContent) return; // no changes
691
- if (!editor.getText().trim() && !subjectInput.value && !toInput.value) return; // empty
692
- // Expose to window for blur-handler.
693
- (window as any).__mailxSaveDraft = saveDraft;
694
- lastDraftContent = content;
695
- draftSaving = true;
696
-
697
- try {
698
- const data = await apiSaveDraft({
699
- accountId: getFromAccountId(),
700
- subject: subjectInput.value,
701
- bodyHtml: editor.getHtml(),
702
- bodyText: editor.getText(),
703
- to: toInput.value,
704
- cc: ccInput.value,
705
- previousDraftUid: draftUid,
706
- draftId: draftId,
707
- });
708
- if (data?.draftUid) draftUid = data.draftUid;
709
- if (data?.draftId) draftId = data.draftId;
710
- if (draftSaveFailed) { draftSaveFailed = false; showDraftStatus("Draft saved", false); }
711
- else showDraftStatus(`Draft saved ${new Date().toLocaleTimeString()}`, false);
712
- markComposeClean();
713
- } catch (e: any) {
714
- // Surface the error — silent failures are how drafts get lost on IMAP hiccups.
715
- // The local editing/ checkpoint already exists server-side regardless.
716
- console.error("[draft] save failed:", e);
717
- draftSaveFailed = true;
718
- showDraftStatus(`Draft save failed: ${e?.message || e}`, true);
719
- // Clear lastDraftContent so the next tick retries the same content
720
- lastDraftContent = "";
721
- }
722
- finally { draftSaving = false; }
723
- }
724
-
725
- /** Schedule a debounced save on user input — fires ~1.5s after the last
726
- * keystroke, then yields one animation frame before actually writing so
727
- * the browser can paint any keystroke in the mean time. S60 mitigation:
728
- * wa-sqlite writes are synchronous on Android; this keeps the typing
729
- * experience responsive by never running the write in the same task as
730
- * an input event. */
731
- function scheduleDraftSave(): void {
732
- markComposeDirty();
733
- if (draftDebounceTimer) clearTimeout(draftDebounceTimer);
734
- draftDebounceTimer = setTimeout(() => {
735
- draftDebounceTimer = null;
736
- // rAF yield — lets any pending keystroke render before we block on
737
- // the DB write. A no-op when the tab is hidden (rAF is throttled),
738
- // which is fine because the user isn't typing then either.
739
- requestAnimationFrame(() => { saveDraft(); });
740
- }, DRAFT_INPUT_DEBOUNCE_MS);
741
- }
742
-
743
- // ── Initialize: local-first population.
744
- //
745
- // Reply / Reply-All / Forward callers pre-populate `init.accounts` with the
746
- // full account list (app.ts:openCompose). In that common case we do NOT need
747
- // to call getAccounts() — everything required to fill the compose form is
748
- // already in sessionStorage and reads synchronously. That turns "click Reply"
749
- // into an instant-open instead of "wait for getAccounts IPC to respond,
750
- // which can take >120s when the service is busy syncing / hung on IMAP".
751
- //
752
- // getAccounts is still called (non-blocking) to refresh the dropdown with
753
- // the freshest data — and it IS awaited only in the fallback path where
754
- // init doesn't have an account list (message-viewer's Edit Draft passes
755
- // init.accounts=[]).
756
-
757
- (async () => {
758
- const stored = sessionStorage.getItem("composeInit");
759
- if (stored) {
760
- sessionStorage.removeItem("composeInit");
761
- const init = JSON.parse(stored) as ComposeInit;
762
- if (init.accounts && init.accounts.length > 0) {
763
- // Happy path — init is complete. Apply immediately. Kick
764
- // getAccounts in the background to refresh the dropdown if the
765
- // user keeps compose open long enough for the result.
766
- applyInit(init);
767
- getAccounts().then((fresh: any[]) => {
768
- if (Array.isArray(fresh) && fresh.length > 0) {
769
- init.accounts = fresh;
770
- // Re-populate the From dropdown only — don't clobber
771
- // anything the user may have already typed.
772
- try { populateFromOptions(fresh); } catch { /* */ }
773
- }
774
- }).catch(() => { /* non-fatal */ });
775
- } else {
776
- // Edit Draft / other callers that didn't pre-fill accounts.
777
- // Have to wait on getAccounts here — the From dropdown needs it.
778
- let fresh: any[] = [];
779
- try { fresh = await getAccounts(); } catch (e: any) { console.error("Failed to load accounts:", e); }
780
- init.accounts = fresh;
781
- applyInit(init);
782
- }
783
- } else {
784
- let accounts: any[] = [];
785
- try { accounts = await getAccounts(); } catch (e: any) { console.error("Failed to load accounts:", e); }
786
- populateFromOptions(accounts);
787
- toInput.focus();
788
- }
789
-
790
- // Wire debounced saves to input events — checkpoint ~1.5s after the last
791
- // keystroke instead of waiting up to 5s for the interval tick.
792
- toInput.addEventListener("input", scheduleDraftSave);
793
- ccInput.addEventListener("input", scheduleDraftSave);
794
- bccInput.addEventListener("input", scheduleDraftSave);
795
- subjectInput.addEventListener("input", scheduleDraftSave);
796
- editor.onContentChange(scheduleDraftSave);
797
-
798
- // Safety-net interval: even with no user input, catch any edge cases.
799
- draftTimer = setInterval(saveDraft, DRAFT_INTERVAL_MS);
800
-
801
- // Flush the draft on window close so the last-typed content lands in
802
- // editing/ even if the interval tick hasn't fired yet. navigator.sendBeacon
803
- // is synchronous enough to survive unload; callNode IPC would be dropped.
804
- window.addEventListener("beforeunload", () => {
805
- if (draftDebounceTimer) { clearTimeout(draftDebounceTimer); draftDebounceTimer = null; }
806
- // fire-and-forget — can't await during unload
807
- saveDraft();
808
- });
809
- })();
810
-
811
- // ── Send ──
812
-
813
- // Q55: Ctrl+Enter (or Cmd+Enter on macOS) anywhere in compose triggers send.
814
- document.addEventListener("keydown", (e: KeyboardEvent) => {
815
- if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
816
- e.preventDefault();
817
- document.getElementById("btn-send")?.click();
818
- }
819
- });
820
- // Q59: autosave when the window loses focus (in addition to debounce + interval).
821
- window.addEventListener("blur", () => {
822
- // Use the same saveDraft path as the 5s interval.
823
- try { (window as any).__mailxSaveDraft?.(); } catch { /* */ }
824
- });
825
-
826
- document.getElementById("btn-send")?.addEventListener("click", () => {
827
- // Loud tracing through the whole send pipeline. Every step ships a
828
- // `[client] compose-send-*` event to the Node log so a "vanished message"
829
- // report can be traced end-to-end without devtools. If the log stops
830
- // at any step, that's where the pipeline broke.
831
- logClientEvent("compose-send-click");
832
- const body = {
833
- from: getFromAccountId(),
834
- fromAddress: getFromAddress(),
835
- to: parseAddrs(toInput.value),
836
- cc: parseAddrs(ccInput.value),
837
- bcc: parseAddrs(bccInput.value),
838
- subject: subjectInput.value,
839
- bodyHtml: editor.getHtml(),
840
- bodyText: editor.getText(),
841
- attachments: attachments.map(a => ({ filename: a.filename, mimeType: a.mimeType, dataBase64: a.dataBase64 })),
842
- };
843
- logClientEvent("compose-send-body-built", { from: body.from, toCount: body.to.length, subjectLen: (body.subject || "").length, bodyHtmlLen: (body.bodyHtml || "").length, atts: body.attachments.length });
844
- // Local validity (one missing-To check) — must run before close so the
845
- // user gets an inline error instead of silent loss. Anything else (real
846
- // address validation, MIME assembly, disk write) happens server-side.
847
- if (!body.to.length) {
848
- logClientEvent("compose-send-rejected-no-to");
849
- alert("Please add at least one To recipient.");
850
- return;
851
- }
852
- console.log(`[compose] Send clicked: from=${body.from} to=${JSON.stringify(body.to)} subject="${body.subject}" attachments=${body.attachments.length}`);
853
- // Wait for the IPC round-trip before closing compose. The IPC is supposed
854
- // to be <100ms (Node validates + disk-writes synchronously inside send()
855
- // then returns). If it throws OR takes too long, the user keeps their
856
- // typed message and sees the error inline instead of losing it to a
857
- // fire-and-forget that never landed. Earlier fire-and-forget version lost
858
- // messages silently when anything upstream of disk write broke.
859
- const sendBtn = document.getElementById("btn-send") as HTMLButtonElement | null;
860
- if (sendBtn) { sendBtn.disabled = true; sendBtn.textContent = "Sending…"; }
861
- const statusEl = document.getElementById("compose-status");
862
- if (statusEl) statusEl.textContent = "";
863
- const sendStart = Date.now();
864
- logClientEvent("compose-send-pre-ipc");
865
- // Parent-window relay for send. Empirical observation: Android (direct
866
- // in-process SMTP) sends reliably; desktop (iframe → parent.mailxapi →
867
- // msger → Node → service) has sendMessage IPCs failing to reach Node
868
- // for reasons still unknown (iframe bridge behaves differently from the
869
- // top frame in msger's WebView2 for this specific call). Meanwhile the
870
- // parent window's bridge is proven — getAccounts / getOutboxStatus run
871
- // through it every few seconds with no failures.
872
- //
873
- // Fix: the iframe doesn't touch the bridge at all for send. It posts
874
- // a request to the parent, and the parent calls the real sendMessage
875
- // from its own frame. Parent posts the result back. This bypasses
876
- // whatever is wrong with iframe-scoped IPC.
877
- const ipcPromise = new Promise<void>((resolve, reject) => {
878
- const reqId = `send-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
879
- const timer = setTimeout(() => {
880
- window.removeEventListener("message", onMsg);
881
- reject(new Error("parent-relay send timeout (120s)"));
882
- }, 120000);
883
- const onMsg = (ev: MessageEvent) => {
884
- if (!ev.data || ev.data.type !== "mailx-compose-send-result" || ev.data.id !== reqId) return;
885
- clearTimeout(timer);
886
- window.removeEventListener("message", onMsg);
887
- if (ev.data.ok) resolve();
888
- else reject(new Error(ev.data.error || "unknown"));
889
- };
890
- window.addEventListener("message", onMsg);
891
- try {
892
- parent.postMessage({ type: "mailx-compose-send", id: reqId, body }, "*");
893
- logClientEvent("compose-send-ipc-invoked", { via: "parent-relay", reqId });
894
- } catch (e: any) {
895
- clearTimeout(timer);
896
- window.removeEventListener("message", onMsg);
897
- reject(e);
898
- }
899
- });
900
- Promise.resolve(ipcPromise)
901
- .then(() => {
902
- logClientEvent("compose-send-ipc-resolved", { ms: Date.now() - sendStart });
903
- console.log(`[compose] Send IPC returned OK in ${Date.now() - sendStart}ms`);
904
- // Record From-address history on successful send. Only manual
905
- // values worth keeping — skip anything that exactly matches a
906
- // known account (already in the dropdown), and skip obviously
907
- // invalid inputs. Populated dropdown surfaces this next time.
908
- try {
909
- const raw = fromInput.value.trim();
910
- const known = knownAccounts.some(a => formatAccountFrom(a) === raw);
911
- if (raw && !known && /@.+\./.test(raw)) recordFromHistory(raw);
912
- } catch { /* */ }
913
- // Stop autosave only after ACK — if send threw we want the draft
914
- // autosave to keep the message safe.
915
- if (draftTimer) { clearInterval(draftTimer); draftTimer = null; }
916
- if (draftUid || draftId) {
917
- deleteDraft(getFromAccountId(), draftUid || 0, draftId || "").catch(() => {});
918
- }
919
- closeCompose();
920
- })
921
- .catch((e: any) => {
922
- const msg: string = e?.message || String(e);
923
- logClientEvent("compose-send-ipc-rejected", { error: msg, ms: Date.now() - sendStart });
924
- console.error(`[compose] Send IPC failed after ${Date.now() - sendStart}ms: ${msg}`);
925
- if (sendBtn) { sendBtn.disabled = false; sendBtn.textContent = "Send"; }
926
- if (statusEl) statusEl.textContent = `Send failed: ${msg}`;
927
- try {
928
- parent.postMessage({ type: "mailx-send-error", message: msg, accountId: body.from }, "*");
929
- } catch { /* */ }
930
- });
931
- });
932
-
933
- // ── Close handling ──
934
-
935
- /** True if the compose has anything worth asking about. */
936
- function composeHasContent(): boolean {
937
- return !!(editor.getText().trim() || toInput.value.trim() || ccInput.value.trim() || bccInput.value.trim() || subjectInput.value.trim());
938
- }
939
-
940
- /** Ask Save/Discard/Cancel. Returns "save" | "discard" | "cancel".
941
- * Uses an in-page modal so all three choices are presented at once — the
942
- * native confirm() flow forced the user through two sequential dialogs and
943
- * hid Discard behind a Cancel click, which was confusing. */
944
- function promptSaveOrDiscard(): Promise<"save" | "discard" | "cancel"> {
945
- return new Promise(resolve => {
946
- const overlay = document.createElement("div");
947
- overlay.className = "compose-modal-overlay";
948
- const box = document.createElement("div");
949
- box.className = "compose-modal";
950
- const msg = document.createElement("div");
951
- msg.className = "compose-modal-msg";
952
- msg.textContent = "Save this message as a draft?";
953
- const btnRow = document.createElement("div");
954
- btnRow.className = "compose-modal-buttons";
955
-
956
- const mkBtn = (label: string, choice: "save" | "discard" | "cancel", primary: boolean): HTMLButtonElement => {
957
- const b = document.createElement("button");
958
- b.type = "button";
959
- b.textContent = label;
960
- b.className = primary ? "compose-modal-btn primary" : "compose-modal-btn";
961
- b.addEventListener("click", () => { cleanup(); resolve(choice); });
962
- return b;
963
- };
964
-
965
- const cleanup = (): void => {
966
- document.removeEventListener("keydown", onKey);
967
- overlay.remove();
968
- };
969
- const onKey = (e: KeyboardEvent): void => {
970
- if (e.key === "Escape") { e.preventDefault(); cleanup(); resolve("cancel"); }
971
- else if (e.key === "Enter") { e.preventDefault(); cleanup(); resolve("save"); }
972
- };
973
- document.addEventListener("keydown", onKey);
974
-
975
- btnRow.appendChild(mkBtn("Save draft", "save", true));
976
- btnRow.appendChild(mkBtn("Discard", "discard", false));
977
- btnRow.appendChild(mkBtn("Cancel", "cancel", false));
978
- box.appendChild(msg);
979
- box.appendChild(btnRow);
980
- overlay.appendChild(box);
981
- document.body.appendChild(overlay);
982
- (btnRow.firstChild as HTMLButtonElement).focus();
983
- });
984
- }
985
-
986
- /** Handle any "close the compose" action (Discard button, Escape, X, window close). */
987
- async function handleCloseRequest(): Promise<boolean> {
988
- if (!composeHasContent()) { closeCompose(); return true; }
989
- const choice = await promptSaveOrDiscard();
990
- if (choice === "cancel") return false;
991
- // Stop auto-save so it can't race with our explicit save/discard.
992
- if (draftDebounceTimer) { clearTimeout(draftDebounceTimer); draftDebounceTimer = null; }
993
- if (draftTimer) { clearInterval(draftTimer); draftTimer = null; }
994
- if (choice === "save") {
995
- try { await saveDraft(); } catch { /* already logged */ }
996
- } else {
997
- // Discard: if we have a tracked draft, delete it so the orphan doesn't stick around.
998
- if (draftUid || draftId) {
999
- try { await deleteDraft(getFromAccountId(), draftUid || 0, draftId || ""); } catch { /* ignore */ }
1000
- }
1001
- }
1002
- closeCompose();
1003
- return true;
1004
- }
1005
-
1006
- document.getElementById("btn-discard")?.addEventListener("click", () => {
1007
- handleCloseRequest();
1008
- });
1009
-
1010
- // ── Cc / Bcc toggle ──
1011
- const ccRow = document.getElementById("compose-cc-row") as HTMLElement;
1012
- const bccRow = document.getElementById("compose-bcc-row") as HTMLElement;
1013
- const toggleCcBtn = document.getElementById("btn-toggle-cc") as HTMLButtonElement;
1014
- const toggleBccBtn = document.getElementById("btn-toggle-bcc") as HTMLButtonElement;
1015
-
1016
- function setCcVisible(visible: boolean): void {
1017
- ccRow.hidden = !visible;
1018
- toggleCcBtn.classList.toggle("active", visible);
1019
- if (visible) ccInput.focus();
1020
- else ccInput.value = "";
1021
- }
1022
- function setBccVisible(visible: boolean): void {
1023
- bccRow.hidden = !visible;
1024
- toggleBccBtn.classList.toggle("active", visible);
1025
- if (visible) bccInput.focus();
1026
- else bccInput.value = "";
1027
- }
1028
- toggleCcBtn?.addEventListener("click", () => setCcVisible(ccRow.hidden));
1029
- toggleBccBtn?.addEventListener("click", () => setBccVisible(bccRow.hidden));
1030
- // Q49 deferred: should be derived from the address book / sent-history DB,
1031
- // not a parallel localStorage store. Pending: extend contacts schema or
1032
- // query messages table on To-input change (debounced); auto-expand Cc/Bcc
1033
- // when this recipient's history shows ≥N past uses.
1034
-
1035
- // ── Attachments ──
1036
- const fileInput = document.getElementById("compose-file") as HTMLInputElement;
1037
- const attEl = document.getElementById("compose-attachments") as HTMLElement;
1038
-
1039
- function renderAttachmentChips(): void {
1040
- attEl.innerHTML = "";
1041
- if (attachments.length === 0) { attEl.hidden = true; return; }
1042
- attEl.hidden = false;
1043
- for (let i = 0; i < attachments.length; i++) {
1044
- const a = attachments[i];
1045
- const chip = document.createElement("span");
1046
- chip.className = "compose-att-chip";
1047
- chip.innerHTML = `\uD83D\uDCCE ${escapeHtml(a.filename)} (${formatSize(a.size)}) `;
1048
- const rm = document.createElement("button");
1049
- rm.type = "button";
1050
- rm.title = "Remove attachment";
1051
- rm.textContent = "\u2715";
1052
- rm.addEventListener("click", () => {
1053
- attachments.splice(i, 1);
1054
- renderAttachmentChips();
1055
- });
1056
- chip.appendChild(rm);
1057
- attEl.appendChild(chip);
1058
- }
1059
- }
1060
- function escapeHtml(s: string): string {
1061
- return s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!));
1062
- }
1063
- function formatSize(n: number): string {
1064
- if (n < 1024) return `${n} B`;
1065
- if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
1066
- return `${(n / (1024 * 1024)).toFixed(1)} MB`;
1067
- }
1068
-
1069
- /** Set when the user clicks Attach — the native file picker eats the Esc
1070
- * press, but on Windows WebView2 the keydown can still spill to the page
1071
- * and trip the document-level Esc-closes-compose handler. While this flag
1072
- * is set, that handler short-circuits. Cleared shortly after click. */
1073
- let attachJustClicked = 0;
1074
- document.getElementById("btn-attach")?.addEventListener("click", () => {
1075
- attachJustClicked = Date.now();
1076
- fileInput?.click();
1077
- });
1078
-
1079
- // ── Edit in Word (external editor handoff) ──
1080
- //
1081
- // Click writes the current body to a temp file, opens it in Word (or the
1082
- // platform fallback), and watches the file. When Word saves, the service
1083
- // emits `wordEditUpdated` and we replace the editor's HTML with the new
1084
- // content. The editId is per-compose-window — closeWordEdit cleans up the
1085
- // temp file when the window closes or the message is sent.
1086
- let wordEditId: string | null = null;
1087
- document.getElementById("btn-edit-in-word")?.addEventListener("click", async () => {
1088
- if (!wordEditId) wordEditId = `compose-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1089
- showDraftStatus("Opening in Word…", false);
1090
- try {
1091
- const result = await openInWord(wordEditId, editor.getHtml());
1092
- if (!result.ok || result.opener === "none") {
1093
- showDraftStatus("Couldn't launch an editor. Install Word, LibreOffice, or set a default for .html.", true);
1094
- return;
1095
- }
1096
- const label =
1097
- result.opener === "word" ? "Word" :
1098
- result.opener === "libreoffice" ? "LibreOffice" :
1099
- "your default editor";
1100
- showDraftStatus(`Editing in ${label} — saves there will reload here.`, false);
1101
- } catch (e: any) {
1102
- showDraftStatus(`Edit-in-Word failed: ${e?.message || e}`, true);
1103
- }
1104
- });
1105
-
1106
- // Listen for external-editor saves. Only react to events for this compose's
1107
- // editId — multiple compose windows can be open and should not stomp each
1108
- // other's bodies.
1109
- onEvent((ev: any) => {
1110
- if (ev?.type !== "wordEditUpdated") return;
1111
- if (!wordEditId || ev.editId !== wordEditId) return;
1112
- try {
1113
- editor.setHtml(ev.html || "");
1114
- showDraftStatus("Reloaded edits from external editor.", false);
1115
- scheduleDraftSave();
1116
- } catch (e: any) {
1117
- showDraftStatus(`Reload failed: ${e?.message || e}`, true);
1118
- }
1119
- });
1120
- window.addEventListener("beforeunload", () => {
1121
- if (wordEditId) closeWordEdit(wordEditId).catch(() => { /* */ });
1122
- });
1123
-
1124
- async function ingestFiles(files: FileList | File[]): Promise<void> {
1125
- for (const file of Array.from(files)) {
1126
- const buf = await file.arrayBuffer();
1127
- // base64 the whole thing — mailx-service builds the multipart/mixed
1128
- let binary = "";
1129
- const bytes = new Uint8Array(buf);
1130
- for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
1131
- const dataBase64 = btoa(binary);
1132
- attachments.push({
1133
- filename: file.name,
1134
- mimeType: file.type || "application/octet-stream",
1135
- size: file.size,
1136
- dataBase64,
1137
- });
1138
- }
1139
- renderAttachmentChips();
1140
- scheduleDraftSave();
1141
- }
1142
-
1143
- fileInput?.addEventListener("change", async () => {
1144
- if (!fileInput.files) return;
1145
- await ingestFiles(fileInput.files);
1146
- fileInput.value = "";
1147
- });
1148
-
1149
- // Drag-and-drop: dropping files anywhere on the compose window attaches them.
1150
- // Highlights a subtle overlay while dragging so the target is obvious. The
1151
- // editor iframe swallows drag events internally so we attach to the compose
1152
- // document root; Quill's own paste/drop handling doesn't fight us because
1153
- // files-with-no-HTML-or-text dragover never hits Quill's clipboard module.
1154
- (() => {
1155
- let dragDepth = 0;
1156
- const root = document.body;
1157
- const overlay = document.createElement("div");
1158
- overlay.id = "compose-drop-overlay";
1159
- // Toggle `display` directly — can't use the `hidden` attribute here
1160
- // because the inline `display` property in cssText outranks it, which is
1161
- // why the overlay showed permanently when I used `overlay.hidden = true`
1162
- // (user-reported 2026-04-24 with screenshot — blue tint + dashed border
1163
- // were visible before any drag started).
1164
- const baseStyle = "position:fixed;inset:0;background:oklch(0.6 0.18 250 / 0.15);border:3px dashed oklch(0.55 0.2 250);z-index:9999;pointer-events:none;align-items:center;justify-content:center;font-size:1.5rem;font-weight:500;color:oklch(0.35 0.2 250)";
1165
- overlay.style.cssText = baseStyle + ";display:none";
1166
- overlay.textContent = "Drop files to attach";
1167
- root.appendChild(overlay);
1168
- const show = () => { overlay.style.display = "flex"; };
1169
- const hide = () => { overlay.style.display = "none"; };
1170
-
1171
- const hasFiles = (e: DragEvent) =>
1172
- Array.from(e.dataTransfer?.types || []).includes("Files");
1173
-
1174
- root.addEventListener("dragenter", (e) => {
1175
- if (!hasFiles(e)) return;
1176
- dragDepth++;
1177
- show();
1178
- });
1179
- root.addEventListener("dragleave", (e) => {
1180
- if (!hasFiles(e)) return;
1181
- dragDepth = Math.max(0, dragDepth - 1);
1182
- if (dragDepth === 0) hide();
1183
- });
1184
- root.addEventListener("dragover", (e) => {
1185
- if (!hasFiles(e)) return;
1186
- e.preventDefault(); // required so drop fires
1187
- if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
1188
- });
1189
- root.addEventListener("drop", async (e) => {
1190
- if (!hasFiles(e)) return;
1191
- e.preventDefault();
1192
- dragDepth = 0;
1193
- hide();
1194
- const files = e.dataTransfer?.files;
1195
- if (files && files.length > 0) await ingestFiles(files);
1196
- });
1197
- })();
1198
-
1199
- // ── Save and close (X button from parent) ──
1200
- window.addEventListener("compose-save-and-close", () => {
1201
- handleCloseRequest();
1202
- });
1203
-
1204
- // ── Keyboard shortcuts ──
1205
-
1206
- document.addEventListener("keydown", (e) => {
1207
- if (e.ctrlKey && e.key === "Enter") {
1208
- document.getElementById("btn-send")?.click();
1209
- }
1210
- if (e.key === "Escape") {
1211
- // If the user just clicked Attach, the native file picker is up.
1212
- // The picker swallows the Esc that dismissed it, but the keydown can
1213
- // still bubble here on WebView2 — closing the whole compose. Suppress
1214
- // for a short window after the attach click.
1215
- if (Date.now() - attachJustClicked < 1500) return;
1216
- e.preventDefault();
1217
- handleCloseRequest();
1218
- }
1219
- // Ctrl+K in an address field = trigger address completion.
1220
- // NOTE: Ctrl+K is ALSO the editor's "insert link" shortcut. Scope this handler
1221
- // strictly to the to/cc/bcc inputs so it doesn't shadow the editor binding when
1222
- // focus is in the body.
1223
- if (e.ctrlKey && (e.key === "k" || e.key === "K")) {
1224
- const active = document.activeElement as HTMLElement;
1225
- const addressFields: HTMLElement[] = [toInput, ccInput, bccInput];
1226
- if (addressFields.includes(active)) {
1227
- e.preventDefault();
1228
- (active as HTMLInputElement).dispatchEvent(new Event("input"));
1229
- }
1230
- }
1231
- });