@bobfrankston/mailx 1.0.466 → 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,3652 +0,0 @@
1
- /**
2
- * @bobfrankston/mailx-imap
3
- * Multi-account IMAP management wrapping iflow.
4
- * Syncs messages to local store, emits events for new mail.
5
- */
6
-
7
- import { createAutoImapConfig, CompatImapClient } from "@bobfrankston/iflow-direct";
8
- import type { TransportFactory } from "@bobfrankston/tcp-transport";
9
- import { authenticateOAuth } from "@bobfrankston/oauthsupport";
10
- import { MailxDB, FileMessageStore } from "@bobfrankston/mailx-store";
11
- import { loadSettings, getStorePath, getConfigDir, getHistoryDays, getPrefetch } from "@bobfrankston/mailx-settings";
12
- import type { AccountConfig, MessageEnvelope, EmailAddress, Folder } from "@bobfrankston/mailx-types";
13
- import { EventEmitter } from "node:events";
14
- import * as fs from "node:fs";
15
- import * as path from "node:path";
16
- import { simpleParser } from "mailparser";
17
- import { GmailApiProvider } from "./providers/gmail-api.js";
18
- import type { MailProvider, ProviderMessage } from "./providers/types.js";
19
- import { SmtpClient } from "@bobfrankston/smtp-direct";
20
- import * as os from "node:os";
21
- import { fileURLToPath } from "node:url";
22
-
23
- // Well-known ports — no magic numbers
24
- const SMTP_PORT_STARTTLS = 587;
25
- const SMTP_PORT_IMPLICIT_TLS = 465;
26
-
27
- type OpsTask = () => Promise<void>;
28
- interface OpsQueue {
29
- fast: OpsTask[];
30
- slow: OpsTask[];
31
- running: boolean;
32
- }
33
- interface HostSemaphore {
34
- permits: number;
35
- waiters: Array<() => void>;
36
- }
37
-
38
- /** Per-message SMTP retry delay: if a send attempt fails, wait this long before
39
- * the same file is retried. Gives the server time to settle so a retry after a
40
- * lost-ack doesn't arrive while the first copy is still being processed. */
41
- const OUTBOX_RETRY_DELAY_MS = 60000;
42
-
43
- /** Parse X-Mailx-Retry* tracking headers from a raw RFC822 message. */
44
- function parseRetryInfo(raw: string): { attemptCount: number; nextAttemptAt: number } {
45
- const headerEnd = raw.search(/\r?\n\r?\n/);
46
- const headers = headerEnd >= 0 ? raw.slice(0, headerEnd) : raw;
47
- const attemptCount = (headers.match(/^X-Mailx-Retry:/gmi) || []).length;
48
- const nextMatch = headers.match(/^X-Mailx-Retry-After:\s*(.+)$/mi);
49
- const parsed = nextMatch ? Date.parse(nextMatch[1].trim()) : NaN;
50
- return { attemptCount, nextAttemptAt: Number.isFinite(parsed) ? parsed : 0 };
51
- }
52
-
53
- /** Remove every occurrence of a header field from a raw RFC822 message. */
54
- function stripHeaderField(raw: string, name: string): string {
55
- const re = new RegExp(`^${name.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}:.*\\r?\\n`, "gmi");
56
- return raw.replace(re, "");
57
- }
58
-
59
- /** Insert a header line just before the header/body blank line. Preserves CRLF vs LF. */
60
- function insertHeaderBeforeBody(raw: string, line: string): string {
61
- const m = raw.match(/\r?\n\r?\n/);
62
- if (!m) return raw + "\r\n" + line + "\r\n";
63
- const nl = m[0].startsWith("\r\n") ? "\r\n" : "\n";
64
- return raw.slice(0, m.index!) + nl + line + raw.slice(m.index!);
65
- }
66
-
67
- /** Error thrown when a message body can't be fetched because the server says
68
- * the message is gone (deleted from another device, expunged, etc.). The
69
- * caller uses this to remove the stale local row instead of showing a
70
- * generic "fetch failed" error to the user. */
71
- function makeNotFoundError(accountId: string, folderId: number, uid: number): Error {
72
- const err = new Error(`Message ${accountId}/${folderId}/${uid} not found on server`);
73
- (err as any).isNotFound = true;
74
- return err;
75
- }
76
-
77
- /** Extract full error detail with provenance */
78
- function imapError(err: any): string {
79
- const msg = err.message || err.reason || err.code || (typeof err === "string" ? err : "");
80
- const parts: string[] = [];
81
- if (msg) parts.push(msg);
82
- if (err.responseText) parts.push(err.responseText);
83
- if (err.responseStatus) parts.push(`[${err.responseStatus}]`);
84
- if (err.code && err.code !== msg) parts.push(`[${err.code}]`);
85
- if (parts.length === 0) parts.push(`Unexpected error: ${JSON.stringify(err).slice(0, 200)}`);
86
- // Add source location if available
87
- if (err.stack) {
88
- const frame = err.stack.split("\n").find((l: string) => l.includes("imap") || l.includes("transport") || l.includes("compat"));
89
- if (frame) parts.push(`(${frame.trim().replace(/^at\s+/, "")})`);
90
- }
91
- return parts.join(" — ");
92
- }
93
-
94
- /** Events emitted by the IMAP manager */
95
- export interface ImapManagerEvents {
96
- newMessage: (accountId: string, folderId: number, envelope: MessageEnvelope) => void;
97
- syncProgress: (accountId: string, phase: string, progress: number) => void;
98
- syncComplete: (accountId: string) => void;
99
- syncError: (accountId: string, error: string) => void;
100
- folderCountsChanged: (accountId: string, counts: Record<number, { total: number; unread: number }>) => void;
101
- accountError: (accountId: string, error: string, hint: string, isOAuth: boolean) => void;
102
- configChanged: (filename: string) => void;
103
- /** Fired after a message body has been written to the local store — lets
104
- * the UI flip a row's "not-downloaded" indicator without re-rendering. */
105
- bodyCached: (accountId: string, uid: number) => void;
106
- syncActionFailed: (accountId: string, action: string, uid: number, error: string) => void;
107
- /** Fired whenever the outbox queue depth or state changes (file added,
108
- * file sent and removed, retry attempted). Lets the UI show a persistent
109
- * queue-status indicator without polling. Aggregate status across all
110
- * accounts is included so the listener doesn't have to reassemble it. */
111
- outboxStatus: (status: OutboxStatus) => void;
112
- /** Per-account health counter update. Fires when an inactivity timeout,
113
- * connection-cap hit, or rate-limit wait happens. */
114
- diagnostics: (accountId: string, snapshot: AccountDiagnostics) => void;
115
- }
116
-
117
- /** Per-account diagnostic counters — tracks "something's wrong" events the
118
- * user should be able to see without opening the log. */
119
- export interface AccountDiagnostics {
120
- accountId: string;
121
- inactivityTimeouts: number; // IMAP server dropped the socket mid-command
122
- connCapHits: number; // "Maximum number of connections" rejections
123
- rateLimitWaits: number; // Gmail API 429 cooldowns
124
- lastTimeoutAt: number; // epoch ms of most recent inactivity timeout
125
- lastCommand: string; // IMAP command that last timed out (e.g. "A471 UID STORE 4956119 +FLAGS.SILENT")
126
- lastError: string; // full error message (truncated)
127
- }
128
-
129
- /** Per-account outbox queue breakdown, plus totals for the UI. */
130
- export interface OutboxStatus {
131
- total: number; // messages waiting to send across all accounts
132
- retrying: number; // subset of total that have at least one X-Mailx-Retry attempt
133
- claimed: number; // subset of total currently in flight (*.sending-host-pid)
134
- oldestAgeSec: number; // age of the oldest pending file, 0 if none
135
- maxAttempts: number; // highest attempt count across all pending files
136
- perAccount: Record<string, { total: number; retrying: number; claimed: number }>;
137
- }
138
-
139
- /** Convert iflow address objects to our EmailAddress */
140
- function toEmailAddress(addr: { name?: string; address?: string }): EmailAddress {
141
- return {
142
- name: addr?.name || "",
143
- address: addr?.address || ""
144
- };
145
- }
146
-
147
- /** Convert array of iflow addresses */
148
- function toEmailAddresses(addrs: { name?: string; address?: string }[]): EmailAddress[] {
149
- if (!addrs) return [];
150
- return addrs.map(toEmailAddress);
151
- }
152
-
153
- /** Decode HTML entities (&#8199; &amp; etc.) to plain characters */
154
- function decodeEntities(text: string): string {
155
- return text
156
- .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
157
- .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
158
- .replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
159
- .replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&nbsp;/g, " ");
160
- }
161
-
162
- /** Extract a plain-text preview from message source */
163
- async function extractPreview(source: string): Promise<{ bodyHtml: string; bodyText: string; preview: string; hasAttachments: boolean }> {
164
- try {
165
- const parsed = await simpleParser(source);
166
- const bodyText = parsed.text || "";
167
- const bodyHtml = parsed.html || "";
168
- // Use text part; fall back to stripping HTML tags if text is empty
169
- let raw = bodyText || bodyHtml.replace(/<[^>]+>/g, " ");
170
- const preview = decodeEntities(raw).replace(/\s+/g, " ").trim().slice(0, 200);
171
- const hasAttachments = (parsed.attachments?.length || 0) > 0;
172
- return { bodyHtml, bodyText, preview, hasAttachments };
173
- } catch {
174
- return { bodyHtml: "", bodyText: "", preview: "", hasAttachments: false };
175
- }
176
- }
177
-
178
- /** Race a promise against a timeout. On timeout, forcibly logout the client to prevent hanging. */
179
- async function withTimeout<T>(promise: Promise<T>, ms: number, client: any, label: string): Promise<T> {
180
- let timer: ReturnType<typeof setTimeout>;
181
- const timeout = new Promise<never>((_, reject) => {
182
- timer = setTimeout(() => {
183
- // Force-close the client to unblock the hanging promise
184
- try { client.logout?.(); } catch { /* ignore */ }
185
- reject(new Error(`${label} timeout (${ms / 1000}s)`));
186
- }, ms);
187
- });
188
- try {
189
- return await Promise.race([promise, timeout]);
190
- } finally {
191
- clearTimeout(timer!);
192
- }
193
- }
194
-
195
- export class ImapManager extends EventEmitter {
196
- private configs: Map<string, ReturnType<typeof createAutoImapConfig>> = new Map();
197
- private watchers: Map<string, () => void> = new Map();
198
- private fetchClients: Map<string, any> = new Map();
199
- private db: MailxDB;
200
- private bodyStore: FileMessageStore;
201
- private syncIntervals: Map<string, ReturnType<typeof setInterval>> = new Map();
202
- /** Track which accounts have already shown an error banner — only emit once per session */
203
- private accountErrorShown = new Set<string>();
204
- private syncing = false;
205
- private inboxSyncing = false;
206
- /** Use native IMAP client instead of imapflow. Set to true to enable. */
207
- useNativeClient = false;
208
- // Connection management: see withConnection() below.
209
- // Cap-hit backoff machinery removed — bounded per-account concurrency
210
- // (one ops socket + one IDLE socket) keeps mailx well under any
211
- // reasonable server cap, so the recovery timer was dead weight that
212
- // mostly served to lock the UI for minutes after a transient failure.
213
-
214
- /** Per-account health counters. Incremented when the server misbehaves
215
- * in ways that suggest a problem the user should know about (inactivity
216
- * timeouts, connection-cap hits, rate-limit waits). Surfaced via a
217
- * `diagnostics` event + `getDiagnostics` IPC so the UI can show a ⚠
218
- * badge instead of burying the issue in the log. */
219
- private diagnostics = new Map<string, AccountDiagnostics>();
220
-
221
- private getDiagnosticsEntry(accountId: string): AccountDiagnostics {
222
- let d = this.diagnostics.get(accountId);
223
- if (!d) {
224
- d = { accountId, inactivityTimeouts: 0, connCapHits: 0, rateLimitWaits: 0, lastTimeoutAt: 0, lastCommand: "", lastError: "" };
225
- this.diagnostics.set(accountId, d);
226
- }
227
- return d;
228
- }
229
-
230
- /** Classify an error message and bump the relevant counter; emit the
231
- * updated diagnostics snapshot. Call this from every catch in the sync
232
- * paths so the UI can count "something's wrong" in real time. */
233
- private recordError(accountId: string, errMsg: string): void {
234
- const d = this.getDiagnosticsEntry(accountId);
235
- if (/inactivity timeout/i.test(errMsg)) {
236
- d.inactivityTimeouts++;
237
- d.lastTimeoutAt = Date.now();
238
- const m = errMsg.match(/A\d+ [A-Z ]+.*$/);
239
- if (m) d.lastCommand = m[0].slice(0, 120);
240
- } else if (/UNAVAILABLE|Maximum number of connections|too many connections/i.test(errMsg)) {
241
- d.connCapHits++;
242
- } else if (/429|rate limit/i.test(errMsg)) {
243
- d.rateLimitWaits++;
244
- } else {
245
- return; // not a known diagnostic class — don't emit
246
- }
247
- d.lastError = errMsg.slice(0, 200);
248
- this.emit("diagnostics", accountId, { ...d });
249
- }
250
-
251
- /** Public read for the IPC surface: snapshot of all account diagnostics. */
252
- getDiagnosticsSnapshot(): AccountDiagnostics[] {
253
- return Array.from(this.diagnostics.values()).map(d => ({ ...d }));
254
- }
255
-
256
- private transportFactory: TransportFactory;
257
-
258
- constructor(db: MailxDB, transportFactory: TransportFactory) {
259
- super();
260
- this.db = db;
261
- this.transportFactory = transportFactory;
262
- const storePath = getStorePath();
263
- this.bodyStore = new FileMessageStore(storePath);
264
- }
265
-
266
- /** Get OAuth access token for an account (for SMTP auth) */
267
- async getOAuthToken(accountId: string): Promise<string | null> {
268
- const config = this.configs.get(accountId);
269
- if (!config || !config.tokenProvider) return null;
270
- return config.tokenProvider();
271
- }
272
-
273
- /** Accounts currently re-authenticating — all operations skip these */
274
- private reauthenticating = new Set<string>();
275
- /** Last reauth attempt timestamp per account — prevents reauth loops (5 min cooldown) */
276
- private lastReauthAttempt = new Map<string, number>();
277
-
278
- /** Force re-authentication for an OAuth account — deletes cached IMAP token, triggers browser consent */
279
- async reauthenticate(accountId: string): Promise<boolean> {
280
- if (this.reauthenticating.has(accountId)) return false; // already in progress
281
- this.reauthenticating.add(accountId);
282
-
283
- try {
284
- const settings = loadSettings();
285
- const account = settings.accounts.find(a => a.id === accountId);
286
- if (!account) return false;
287
-
288
- // Stop IDLE watcher for this account
289
- const stopWatcher = this.watchers.get(accountId);
290
- if (stopWatcher) { try { await stopWatcher(); } catch { /* */ } this.watchers.delete(accountId); }
291
-
292
- // Delete only the IMAP token (not contacts — separate scope, separate consent)
293
- const accountDir = account.imap.user.replace(/[@.]/g, "_");
294
- const tokenDir = path.join(getConfigDir(), "tokens", accountDir);
295
- const tokenPath = path.join(tokenDir, "token.json");
296
- if (fs.existsSync(tokenPath)) { fs.unlinkSync(tokenPath); console.log(` [reauth] Deleted ${tokenPath}`); }
297
-
298
- // Re-register the account to get a fresh config with new tokenProvider
299
- this.configs.delete(accountId);
300
- await this.addAccount(account);
301
-
302
- // addAccount already pre-validates the token (opens browser if needed)
303
- const config = this.configs.get(accountId);
304
- if (config?.tokenProvider) {
305
- console.log(` [reauth] ${accountId}: success`);
306
- this.accountErrorShown.delete(accountId);
307
- this.syncInbox().catch(() => {});
308
- return true;
309
- }
310
- } catch (e: any) {
311
- console.error(` [reauth] ${accountId}: ${e.message}`);
312
- } finally {
313
- this.reauthenticating.delete(accountId);
314
- }
315
- return false;
316
- }
317
-
318
- /** Delete a message directly on the IMAP server (for stuck outbox messages not in local DB) */
319
- async deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void> {
320
- return this.withConnection(accountId, async (client) => {
321
- await client.deleteMessageByUid(folderPath, uid);
322
- console.log(` Deleted UID ${uid} from ${folderPath} on server`);
323
- });
324
- }
325
-
326
- /** Search messages on the IMAP server — returns matching UIDs */
327
- async searchOnServer(accountId: string, mailboxPath: string, criteria: any): Promise<number[]> {
328
- return this.withConnection(accountId, async (client) => {
329
- return await client.searchMessages(mailboxPath, criteria);
330
- });
331
- }
332
-
333
- /** Server-side search that also materializes any UIDs we don't yet have
334
- * locally. Returns the full result after upsert, so the caller can
335
- * render hits that fall outside the history window. The fetch loop
336
- * can be long for big hit-sets, so this runs on the slow lane and
337
- * yields between chunks (each chunk is a separate withConnection)
338
- * so an interactive body fetch can interleave. */
339
- async searchAndFetchOnServer(accountId: string, folderId: number, mailboxPath: string, criteria: any): Promise<number[]> {
340
- const uids = await this.withConnection(accountId, async (client) => {
341
- return await client.searchMessages(mailboxPath, criteria) as number[];
342
- });
343
- if (uids.length === 0) return [];
344
- const have = new Set(this.db.getUidsForFolder(accountId, folderId));
345
- const missing = uids.filter(u => !have.has(u));
346
- if (missing.length > 0) {
347
- const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
348
- if (folder) {
349
- const CHUNK = 500;
350
- for (let i = 0; i < missing.length; i += CHUNK) {
351
- const range = missing.slice(i, i + CHUNK).join(",");
352
- await this.withConnection(accountId, async (client) => {
353
- const fetched = await (client as any).fetchMessages(mailboxPath, range, { source: false });
354
- if (fetched?.length) {
355
- await this.storeMessages(accountId, folderId, folder, fetched, 0);
356
- }
357
- }, { slow: true });
358
- }
359
- this.db.recalcFolderCounts(folderId);
360
- }
361
- }
362
- return uids;
363
- }
364
-
365
- /** Create a fresh IMAP client for an account (public access for API endpoints) */
366
- async createPublicClient(accountId: string): Promise<any> {
367
- return this.createClient(accountId);
368
- }
369
-
370
- // Legacy fallback disabled — was doubling connections without helping.
371
- // To re-enable: uncomment legacyFallbacks logic in createClient and _syncAll.
372
- // private legacyFallbacks = new Set<string>();
373
-
374
- // ── Connection management: one persistent connection per account ──
375
- // All operations on an account are serialized through an operation queue.
376
- // No semaphore, no pool, no per-operation connect/disconnect.
377
- // IDLE uses a separate connection (see startWatching).
378
-
379
- /** Persistent operational connections — one per account, reused for all operations.
380
- * Body fetch, sync, prefetch, outbox-append, flag/move all serialize through
381
- * this single client per account via withConnection(). The priority lane
382
- * in the queue lets interactive clicks jump ahead of background prefetch. */
383
- private opsClients = new Map<string, any>();
384
- /** Two-lane operation queue per account — interactive ops (body fetch on
385
- * click, flag toggle) drain before background ops (sync, prefetch). FIFO
386
- * within each lane. The single ops connection means there's never a race
387
- * on which folder is SELECTed; commands run strictly sequentially. */
388
- private opsQueues = new Map<string, OpsQueue>();
389
- /** Per-host semaphore — caps simultaneous IMAP socket opens to one server.
390
- * Defensive guardrail: with the single-ops-per-account model an individual
391
- * user's mailx never hits more than (#accounts × 2) sockets per host, well
392
- * under any reasonable server cap. Exists for the multi-account-on-one-host
393
- * case (e.g. bobma + bobma2 both on imap.iecc.com). */
394
- private hostSemaphores = new Map<string, HostSemaphore>();
395
- private static readonly HOST_PERMITS = 4;
396
-
397
- /** Get (or create) the persistent operational connection for an account.
398
- * logout() is wrapped as a no-op so legacy callers don't close it. */
399
- private async getOpsClient(accountId: string): Promise<any> {
400
- let client = this.opsClients.get(accountId);
401
- if (client) {
402
- // C38: health-check the cached client before returning. If the
403
- // underlying socket is dead (Dovecot silently dropped IDLE after
404
- // the inactivity timeout, or we lost connectivity), the next
405
- // command would fail with "Not connected" — and nothing would
406
- // recover it until an explicit reconnectOps was called from the
407
- // catch handler. Cheap pre-check here catches it earlier.
408
- const sock = client?.native?.transport?.socket;
409
- const dead = sock?.destroyed || sock?.readyState === "closed" || client?._dead;
410
- if (!dead) return client;
411
- try { await (client._realLogout || client.logout)(); } catch { /* */ }
412
- this.opsClients.delete(accountId);
413
- console.log(` [conn] ${accountId}: stale ops client detected in getOpsClient — reconnecting`);
414
- client = undefined as any;
415
- }
416
- client = await this.newClient(accountId, "ops");
417
- // Wrap logout as no-op — this is a persistent connection. The
418
- // newClient wrapper's close-counter runs on `_realLogout`.
419
- const realLogout = client.logout.bind(client);
420
- client.logout = async () => { /* no-op — persistent connection */ };
421
- client._realLogout = realLogout;
422
- this.opsClients.set(accountId, client);
423
- return client;
424
- }
425
-
426
- /** Run an operation on the account's connection — queued, sequential, no concurrency */
427
- /** Run an operation against the account's single ops connection. Tasks
428
- * queue strictly sequentially per account — only one IMAP command in
429
- * flight at a time. This eliminates the SELECT-races and "stale client
430
- * recovery" paths the old multi-client design needed.
431
- *
432
- * Default lane is `fast` — covers virtually everything (body fetch,
433
- * flag toggle, move, incremental sync). Pass `slow: true` only for
434
- * operations the caller knows will take a long time and shouldn't
435
- * block the user (multi-folder prefetch batches, large backfills).
436
- * When both lanes have tasks, fast drains first.
437
- *
438
- * Within a lane, FIFO. The running task always finishes — IMAP can't
439
- * preempt a command mid-flight. */
440
- async withConnection<T>(
441
- accountId: string,
442
- fn: (client: any) => Promise<T>,
443
- opts: { slow?: boolean; timeoutMs?: number } = {},
444
- ): Promise<T> {
445
- let queue = this.opsQueues.get(accountId);
446
- if (!queue) {
447
- queue = { fast: [], slow: [], running: false };
448
- this.opsQueues.set(accountId, queue);
449
- }
450
- // Per-task wall-clock cap. Without one, a wedged IMAP command (TCP
451
- // half-open, server stalled mid-FETCH) keeps the queue's running flag
452
- // set forever and every subsequent fast-lane task — including the
453
- // retry button the user just hit — waits behind it. Default is
454
- // generous; callers driving user-visible reads pass a tighter value.
455
- const timeoutMs = opts.timeoutMs ?? 90_000;
456
- return new Promise<T>((resolve, reject) => {
457
- const task: OpsTask = async () => {
458
- let timer: any;
459
- try {
460
- const client = await this.getOpsClient(accountId);
461
- const result = await Promise.race<T>([
462
- fn(client),
463
- new Promise<T>((_, rej) => {
464
- timer = setTimeout(() => rej(new Error(`ops timeout after ${Math.round(timeoutMs / 1000)}s — discarding client`)), timeoutMs);
465
- }),
466
- ]);
467
- clearTimeout(timer);
468
- resolve(result);
469
- } catch (e: any) {
470
- clearTimeout(timer);
471
- // Discard client on any error — keeping a half-broken
472
- // socket poisoned every subsequent request. Destroy
473
- // synchronously kills the in-flight command's socket so
474
- // the underlying promise rejects and stops holding state.
475
- const stale = this.opsClients.get(accountId);
476
- this.opsClients.delete(accountId);
477
- if (stale) {
478
- try { await (stale._realLogout || stale.logout)(); } catch { /* */ }
479
- try { stale.destroy?.(); } catch { /* */ }
480
- }
481
- reject(e);
482
- }
483
- };
484
- (opts.slow ? queue!.slow : queue!.fast).push(task);
485
- this.drainOpsQueue(accountId);
486
- });
487
- }
488
-
489
- /** Run the next queued task. Fast lane drains before slow.
490
- * Idempotent — safe to call after each task completes; the running
491
- * flag prevents reentrant draining. */
492
- private drainOpsQueue(accountId: string): void {
493
- const queue = this.opsQueues.get(accountId);
494
- if (!queue || queue.running) return;
495
- const next = queue.fast.shift() || queue.slow.shift();
496
- if (!next) return;
497
- queue.running = true;
498
- next().finally(() => {
499
- queue.running = false;
500
- this.drainOpsQueue(accountId);
501
- });
502
- }
503
-
504
- /** Acquire one slot of the per-host connection semaphore. Returns a release
505
- * function — call exactly once when the socket is closed. Used by
506
- * newClient to cap simultaneous IMAP connections to a single server
507
- * across all mailx accounts. */
508
- private acquireHostSlot(host: string): Promise<() => void> {
509
- let sem = this.hostSemaphores.get(host);
510
- if (!sem) {
511
- sem = { permits: ImapManager.HOST_PERMITS, waiters: [] };
512
- this.hostSemaphores.set(host, sem);
513
- }
514
- const semRef = sem;
515
- return new Promise<() => void>(resolve => {
516
- const grant = () => {
517
- semRef.permits--;
518
- let released = false;
519
- resolve(() => {
520
- if (released) return;
521
- released = true;
522
- semRef.permits++;
523
- const next = semRef.waiters.shift();
524
- if (next) next();
525
- });
526
- };
527
- if (semRef.permits > 0) grant();
528
- else semRef.waiters.push(grant);
529
- });
530
- }
531
-
532
- /** Open IMAP clients per account, used to trace who's opening sockets
533
- * when we hit the Dovecot per-user+IP connection cap. */
534
- private openClients: Map<string, Set<any>> = new Map();
535
-
536
- /** Create a new IMAP client (internal — callers use getOpsClient or withConnection).
537
- * Acquires one slot of the per-host semaphore before constructing the
538
- * client; the slot is released when logout() or destroy() runs.
539
- * `purpose` is a short tag printed alongside the `[conn+]` log so we can
540
- * tell which code path (ops/idle/etc.) opened each connection. */
541
- private async newClient(accountId: string, purpose = "?"): Promise<any> {
542
- if (this.reauthenticating.has(accountId)) throw new Error(`Account ${accountId} is re-authenticating`);
543
- const config = this.configs.get(accountId);
544
- if (!config) throw new Error(`No config for account ${accountId}`);
545
- const host = config.server || accountId;
546
- const releaseHostSlot = await this.acquireHostSlot(host);
547
- let client: any;
548
- try {
549
- client = new CompatImapClient(config, this.transportFactory);
550
- } catch (e) {
551
- releaseHostSlot();
552
- throw e;
553
- }
554
- let open = this.openClients.get(accountId);
555
- if (!open) { open = new Set(); this.openClients.set(accountId, open); }
556
- open.add(client);
557
- console.log(` [conn+] ${accountId} (${purpose}) — ${open.size} open`);
558
- let closed = false;
559
- const markClosed = (how: string) => {
560
- if (closed) return;
561
- closed = true;
562
- open!.delete(client);
563
- releaseHostSlot();
564
- console.log(` [conn-] ${accountId} (${purpose}/${how}) — ${open!.size} open`);
565
- };
566
- const origLogout = client.logout?.bind(client);
567
- if (origLogout) {
568
- client.logout = async () => {
569
- try { await origLogout(); }
570
- finally { markClosed("logout"); }
571
- };
572
- }
573
- const origDestroy = client.destroy?.bind(client);
574
- if (origDestroy) {
575
- client.destroy = () => {
576
- try { origDestroy(); }
577
- finally { markClosed("destroy"); }
578
- };
579
- }
580
- return client;
581
- }
582
-
583
- /** Force-close every IMAP socket for an account — ops + any lingering
584
- * ones in openClients (e.g. an IDLE watcher in flight). Used during
585
- * account removal and disconnectOps so the server's connection slots
586
- * free immediately rather than waiting for socket idle timeouts. */
587
- async closeAllClients(accountId: string): Promise<void> {
588
- const ops = this.opsClients.get(accountId);
589
- this.opsClients.delete(accountId);
590
- if (ops) { try { await (ops._realLogout || ops.logout)(); } catch { /* */ } try { ops.destroy?.(); } catch { /* */ } }
591
- const open = this.openClients.get(accountId);
592
- if (open) {
593
- for (const c of Array.from(open)) {
594
- try { await (c._realLogout || c.logout)?.(); } catch { /* */ }
595
- try { c.destroy?.(); } catch { /* */ }
596
- }
597
- open.clear();
598
- }
599
- }
600
-
601
- /** Disconnect the persistent operational connection for an account */
602
- async disconnectOps(accountId: string): Promise<void> {
603
- const client = this.opsClients.get(accountId);
604
- this.opsClients.delete(accountId);
605
- if (client) {
606
- // Force-close: don't wait for LOGOUT on a possibly dead socket
607
- try {
608
- const timeout = new Promise(r => setTimeout(r, 2000));
609
- await Promise.race([(client._realLogout || client.logout)(), timeout]);
610
- } catch { /* */ }
611
- // Destroy underlying socket if still open
612
- try { client.destroy?.(); } catch { /* */ }
613
- console.log(` [conn] ${accountId}: disconnected`);
614
- }
615
- }
616
-
617
- /** Legacy entry: returns the shared persistent ops client. Most callers
618
- * should be using `withConnection()` instead — that gives proper
619
- * queueing and lets fast operations jump ahead of slow ones. */
620
- async createClientWithLimit(accountId: string): Promise<any> {
621
- return this.getOpsClient(accountId);
622
- }
623
- /** Disposable fresh client — only used by the IDLE watcher, which holds
624
- * its own socket so the fast/slow ops queue isn't blocked by IDLE
625
- * parking the connection in a wait-for-server state. */
626
- private async createClient(accountId: string, purpose = "misc"): Promise<any> {
627
- return this.newClient(accountId, purpose);
628
- }
629
- private trackLogout(_accountId: string): void { /* no-op — connection stays alive */ }
630
-
631
- /** Number of registered IMAP accounts */
632
- getAccountCount(): number { return this.configs.size; }
633
-
634
- /** Register an account */
635
- async addAccount(account: AccountConfig): Promise<void> {
636
- if (this.configs.has(account.id)) return;
637
-
638
- // createAutoImapConfig auto-detects Gmail from server/username and sets up OAuth
639
- // For OAuth accounts, provide a tokenProvider using oauthsupport
640
- let tokenProvider: (() => Promise<string>) | undefined;
641
- if (account.imap.auth === "oauth2" || (!account.imap.password && account.imap.host?.includes("gmail"))) {
642
- // Find Google OAuth credentials — check ~/.mailx first, then iflow-direct package
643
- let credPath = path.join(getConfigDir(), "google-credentials.json");
644
- if (!fs.existsSync(credPath)) {
645
- try {
646
- // Use fileURLToPath, NOT string-replace on "file://" — on Linux,
647
- // file:///usr/local/... loses its leading slash via .replace("file:///",
648
- // "") and becomes relative, so fs.existsSync silently fails.
649
- const pkgDir = path.dirname(fileURLToPath(import.meta.resolve("@bobfrankston/iflow-direct")));
650
- for (const name of ["iflow-credentials.json"]) {
651
- const p = path.join(pkgDir, name);
652
- if (fs.existsSync(p)) { credPath = p; break; }
653
- }
654
- } catch { /* iflow-direct not resolvable */ }
655
- }
656
- const tokenDir = path.join(getConfigDir(), "tokens", account.imap.user.replace(/[@.]/g, "_"));
657
- tokenProvider = async () => {
658
- // Wall-clock timeout on OAuth. Use a longer timeout when no
659
- // cached token exists (first auth needs user to click through
660
- // the browser consent screen — 30s is too tight).
661
- const hasToken = fs.existsSync(path.join(tokenDir, "oauth-token.json"));
662
- const TOKEN_FETCH_TIMEOUT_MS = hasToken ? 30_000 : 120_000;
663
- const authPromise = authenticateOAuth(credPath, {
664
- // Scope set covers two-way sync of all mailx-managed local
665
- // stores: mail (mail.google.com), contacts (full, not
666
- // readonly — we write edits back), calendar (full), tasks
667
- // (full), drive (for shared accounts.jsonc).
668
- scope: "https://mail.google.com/ https://www.googleapis.com/auth/contacts https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/tasks https://www.googleapis.com/auth/drive",
669
- tokenDirectory: tokenDir,
670
- credentialsKey: "installed",
671
- loginHint: account.imap.user,
672
- });
673
- const timeoutPromise = new Promise<never>((_, reject) =>
674
- setTimeout(() => reject(new Error(`OAuth token fetch timeout (${TOKEN_FETCH_TIMEOUT_MS / 1000}s)`)), TOKEN_FETCH_TIMEOUT_MS)
675
- );
676
- const result = await Promise.race([authPromise, timeoutPromise]);
677
- return result?.access_token || "";
678
- };
679
- }
680
- // Non-Gmail accounts (typically Dovecot / generic IMAP) get a smaller
681
- // fetch chunk size (10 vs 25) and longer inactivity timeout (300s) so
682
- // multi-body FETCH batches don't trip the connection-dead detector on
683
- // slow servers. Gmail stays at the defaults since it's fast and has
684
- // its own rate limits to respect.
685
- const isGmail = account.imap.host?.includes("gmail") || account.email.endsWith("@gmail.com");
686
- const config = createAutoImapConfig({
687
- server: account.imap.host,
688
- port: account.imap.port,
689
- username: account.imap.user,
690
- password: account.imap.password,
691
- tokenProvider,
692
- inactivityTimeout: isGmail ? 60000 : 300000,
693
- fetchChunkSize: isGmail ? 25 : 10,
694
- fetchChunkSizeMax: isGmail ? 500 : 100,
695
- });
696
-
697
- this.configs.set(account.id, config);
698
-
699
- // Register account in DB
700
- this.db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
701
-
702
- // Pre-validate OAuth token (so browser consent happens now, not during a timed sync)
703
- if (config.tokenProvider) {
704
- try {
705
- await config.tokenProvider();
706
- console.log(` [auth] ${account.id}: token valid`);
707
- } catch (e: any) {
708
- const errMsg = imapError(e);
709
- console.error(` [auth] ${account.id}: ${errMsg}`);
710
- const isTransient = /timeout|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH/i.test(errMsg);
711
- if (isTransient) {
712
- console.log(` [transient] ${account.id}: ${errMsg} — will retry on first sync`);
713
- } else if (!this.accountErrorShown.has(account.id)) {
714
- this.accountErrorShown.add(account.id);
715
- const config = this.configs.get(account.id);
716
- this.emit("accountError", account.id, errMsg, errMsg, !!config?.tokenProvider);
717
- }
718
- }
719
- }
720
- }
721
-
722
- /** Check if an account uses Gmail (should use API instead of IMAP) */
723
- private isGmailAccount(accountId: string): boolean {
724
- const settings = loadSettings();
725
- const account = settings.accounts.find(a => a.id === accountId);
726
- if (!account) return false;
727
- return account.imap?.host?.includes("gmail") || account.email?.endsWith("@gmail.com") || false;
728
- }
729
-
730
- /** Get a Gmail API provider for an account (reuses tokenProvider from IMAP config) */
731
- private getGmailProvider(accountId: string): GmailApiProvider {
732
- const config = this.configs.get(accountId);
733
- if (!config?.tokenProvider) throw new Error(`No tokenProvider for ${accountId}`);
734
- return new GmailApiProvider(config.tokenProvider);
735
- }
736
-
737
- /** Convert ProviderMessage to the shape expected by storeMessages/upsertMessage */
738
- private providerMsgToLocal(msg: ProviderMessage): any {
739
- return {
740
- uid: msg.uid,
741
- messageId: msg.messageId,
742
- date: msg.date || new Date(),
743
- subject: msg.subject,
744
- from: msg.from,
745
- to: msg.to,
746
- cc: msg.cc,
747
- seen: msg.seen,
748
- flagged: msg.flagged,
749
- answered: msg.answered,
750
- draft: msg.draft,
751
- size: msg.size,
752
- source: msg.source,
753
- providerId: msg.providerId,
754
- };
755
- }
756
-
757
- /** Sync folder list for an account */
758
- async syncFolders(accountId: string, client?: any): Promise<Folder[]> {
759
- if (!client) client = await this.getOpsClient(accountId);
760
-
761
- this.emit("syncProgress", accountId, "folders", 0);
762
-
763
- const t0 = Date.now();
764
- console.log(` [diag] ${accountId}: getFolderList starting...`);
765
- const folders = await client.getFolderList();
766
- console.log(` [diag] ${accountId}: getFolderList done in ${Date.now() - t0}ms (${folders.length} folders)`);
767
- const specialFolders = client.getSpecialFolders(folders);
768
-
769
- // Collect server paths so we can prune anything the server no longer
770
- // has (user-renamed / -deleted / case-flipped a folder from another
771
- // client). IMAP paths are case-sensitive, so "Foo" → "foo" is a real
772
- // delete+create of two distinct mailboxes.
773
- const serverPaths = new Set<string>();
774
-
775
- for (const folder of folders) {
776
- // Skip non-selectable folders (virtual parents like "Added", "Added2")
777
- const flags = (folder as any).flags as Set<string> | string[] | undefined;
778
- const flagArr = flags instanceof Set ? [...flags] : (flags || []);
779
- if (flagArr.some((f: string) => f.toLowerCase() === "\\noselect" || f.toLowerCase() === "\\nonexistent")) continue;
780
-
781
- let specialUse: string = null as any;
782
- if (specialFolders.inbox === folder.path) specialUse = "inbox";
783
- else if (specialFolders.sent === folder.path) specialUse = "sent";
784
- else if (specialFolders.trash === folder.path) specialUse = "trash";
785
- else if (specialFolders.drafts === folder.path) specialUse = "drafts";
786
- else if (specialFolders.spam === folder.path || specialFolders.junk === folder.path) specialUse = "junk";
787
- else if (specialFolders.archive === folder.path) specialUse = "archive";
788
-
789
- this.db.upsertFolder(
790
- accountId,
791
- folder.path,
792
- folder.name || folder.path.split(folder.delimiter || "/").pop() || folder.path,
793
- specialUse,
794
- folder.delimiter || "/"
795
- );
796
- serverPaths.add(folder.path);
797
- }
798
-
799
- // Prune: any local folder whose exact path (case-sensitive) isn't in
800
- // the server's list has been deleted or renamed server-side. Safety
801
- // rails: only prune when the server returned a non-empty list (empty
802
- // result is more likely a transient protocol / auth error than "all
803
- // your folders were deleted"). Never prune INBOX under any
804
- // circumstances — even a broken server response shouldn't make us
805
- // drop the account's primary mailbox. All other special-use folders
806
- // ARE prunable: if the user actually deleted Sent on the server,
807
- // we should reflect that locally, and the next sync will re-detect
808
- // the server's real Sent folder and re-upsert.
809
- if (folders.length > 0) {
810
- const localFolders = this.db.getFolders(accountId);
811
- const stale = localFolders.filter(f =>
812
- !serverPaths.has(f.path) &&
813
- f.specialUse !== "inbox"
814
- );
815
- for (const f of stale) {
816
- console.log(` [sync] ${accountId}: pruning stale folder "${f.path}" (id=${f.id}) — no longer on server`);
817
- try { this.db.deleteFolder(f.id); }
818
- catch (e: any) { console.error(` [sync] ${accountId}: prune failed for "${f.path}": ${e.message}`); }
819
- }
820
- if (stale.length > 0) {
821
- this.emit("folderCountsChanged", accountId, {});
822
- }
823
- }
824
-
825
- this.emit("syncProgress", accountId, "folders", 100);
826
- // Notify UI that folder structure changed — triggers tree re-render
827
- const dbFolders = this.db.getFolders(accountId);
828
- this.emit("folderCountsChanged", accountId, {});
829
- return dbFolders;
830
- }
831
-
832
- /** Store a batch of messages to DB immediately — used by onChunk for incremental sync */
833
- private async storeMessages(accountId: string, folderId: number, folder: any, msgs: any[], highestUid: number): Promise<number> {
834
- let stored = 0;
835
- this.db.beginTransaction();
836
- try {
837
- for (const msg of msgs) {
838
- // Debug: log subjects with non-ASCII to trace encoding issues
839
- if (msg.subject && /[^\x00-\x7F]/.test(msg.subject)) {
840
- const hex = Buffer.from(msg.subject, "utf-8").subarray(0, 40).toString("hex");
841
- console.log(` [encoding] subject: "${msg.subject.substring(0, 60)}" hex: ${hex}`);
842
- }
843
- if (msg.uid <= highestUid) continue; // already have it
844
- // Tombstone check: if the user locally deleted this Message-ID,
845
- // don't re-import it. Server-side EXPUNGE may lag, or reconcile
846
- // may find the message in an old list snapshot. Without this,
847
- // deleted messages reappear on the next sync pass.
848
- if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
849
- console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} (locally deleted)`);
850
- continue;
851
- }
852
- const source = msg.source || "";
853
- let bodyPath = "";
854
- let preview = "";
855
- let hasAttachments = false;
856
- if (source) {
857
- bodyPath = await this.bodyStore.putMessage(
858
- accountId, folderId, msg.uid,
859
- Buffer.from(source, "utf-8")
860
- );
861
- const parsed = await extractPreview(source);
862
- preview = parsed.preview;
863
- hasAttachments = parsed.hasAttachments;
864
- }
865
- const flags: string[] = [];
866
- if (msg.seen) flags.push("\\Seen");
867
- if (msg.flagged) flags.push("\\Flagged");
868
- if (msg.answered) flags.push("\\Answered");
869
- if (msg.draft) flags.push("\\Draft");
870
- this.db.upsertMessage({
871
- accountId, folderId, uid: msg.uid,
872
- messageId: msg.messageId || "",
873
- inReplyTo: (msg as any).inReplyTo || "",
874
- references: [],
875
- date: msg.date instanceof Date ? msg.date.getTime() : (typeof msg.date === "number" ? msg.date : Date.now()),
876
- subject: msg.subject || "",
877
- from: toEmailAddress(msg.from?.[0] || {}),
878
- to: toEmailAddresses(msg.to || []),
879
- cc: toEmailAddresses(msg.cc || []),
880
- flags, size: msg.size || 0, hasAttachments, preview, bodyPath
881
- });
882
- stored++;
883
- }
884
- this.db.commitTransaction();
885
- } catch (e: any) {
886
- this.db.rollbackTransaction();
887
- console.error(` storeMessages error: ${e.message}`);
888
- }
889
- return stored;
890
- }
891
-
892
- /** Sync messages for a specific folder */
893
- async syncFolder(accountId: string, folderId: number, client?: any): Promise<number> {
894
- if (!client) client = await this.getOpsClient(accountId);
895
- const prefetch = getPrefetch();
896
-
897
- const folders = this.db.getFolders(accountId);
898
- const folder = folders.find(f => f.id === folderId);
899
- if (!folder) throw new Error(`Folder ${folderId} not found`);
900
-
901
- this.emit("syncProgress", accountId, `sync:${folder.path}`, 0);
902
-
903
- // Get the highest UID we already have for this folder
904
- const highestUid = this.db.getHighestUid(accountId, folderId);
905
- console.log(` [sync] ${accountId}/${folder.path}: highestUid=${highestUid}, fetching...`);
906
-
907
- let messages: any[];
908
- const firstSync = highestUid === 0;
909
- const historyDays = getHistoryDays(accountId);
910
- // historyDays=0 means "all". On first sync we still cap at 30 days
911
- // so the UI isn't empty for minutes while SEARCH SINCE 1970 runs
912
- // through a years-old mailbox. Once we have any local messages, the
913
- // backfill below extends the window in 90-day chunks per sync cycle.
914
- let effectiveDays = historyDays;
915
- if (effectiveDays === 0 && firstSync) effectiveDays = 30;
916
- const startDate = effectiveDays > 0
917
- ? new Date(Date.now() - effectiveDays * 86400000)
918
- : new Date(0);
919
-
920
- if (highestUid > 0) {
921
- // Incremental: fetch new messages — prefetch bodies for offline access
922
- const fetched = await client.fetchMessagesSinceUid(folder.path, highestUid, { source: false });
923
- // Filter out the last known message (IMAP * always returns at least one)
924
- messages = fetched.filter((m: any) => m.uid > highestUid);
925
-
926
- // Gap detection: check for missing UIDs within the range we've already synced
927
- // Only reconcile between our lowest and highest UID — don't try to fetch the entire folder history
928
- const existingUids = this.db.getUidsForFolder(accountId, folderId);
929
- if (existingUids.length > 0) {
930
- try {
931
- const lowestUid = Math.min(...existingUids);
932
- // Fetch UIDs in our known range from IMAP
933
- const rangeUids = await (client as any).getUids(folder.path);
934
- const rangeInScope = rangeUids.filter((uid: number) => uid >= lowestUid && uid <= highestUid);
935
- const existingSet = new Set(existingUids);
936
- const newSet = new Set(messages.map(m => m.uid));
937
- const missingUids = rangeInScope.filter((uid: number) => !existingSet.has(uid) && !newSet.has(uid));
938
- if (missingUids.length > 0 && missingUids.length <= 5000) {
939
- console.log(` ${folder.path}: gap detected — ${missingUids.length} missing UIDs in range ${lowestUid}..${highestUid}`);
940
- const chunkSize = 500;
941
- let recoveredTotal = 0;
942
- for (let i = 0; i < missingUids.length; i += chunkSize) {
943
- const chunk = missingUids.slice(i, i + chunkSize);
944
- const range = chunk.join(",");
945
- const recovered = await (client as any).fetchMessages(folder.path, range, { source: false });
946
- messages.push(...recovered);
947
- recoveredTotal += recovered.length;
948
- console.log(` ${folder.path}: gap-fill ${recoveredTotal}/${missingUids.length}`);
949
- }
950
- } else if (missingUids.length > 5000) {
951
- console.log(` ${folder.path}: ${missingUids.length} missing UIDs — too many, skipping reconciliation (delete DB to force full re-sync)`);
952
- }
953
- } catch (e: any) {
954
- console.error(` ${folder.path}: gap detection failed: ${e.message}`);
955
- }
956
- }
957
-
958
- // Backfill: if the history window reaches further back than our
959
- // oldest local message, fetch the gap. Chunk 90 days per sync
960
- // cycle so historyDays=0 catches up incrementally instead of
961
- // asking Dovecot for SEARCH SINCE 1970 in one go.
962
- const oldestDate = this.db.getOldestDate(accountId, folderId);
963
- if (oldestDate > 0 && startDate.getTime() < oldestDate) {
964
- try {
965
- const CHUNK_MS = 90 * 86400000;
966
- const chunkStart = new Date(Math.max(startDate.getTime(), oldestDate - CHUNK_MS));
967
- const existingUids = new Set(this.db.getUidsForFolder(accountId, folderId));
968
- const backfill = await client.fetchMessageByDate(folder.path, chunkStart, new Date(oldestDate), { source: false });
969
- const newBackfill = backfill.filter((m: any) => !existingUids.has(m.uid));
970
- if (newBackfill.length > 0) {
971
- console.log(` ${folder.path}: backfilling ${newBackfill.length} older messages (${chunkStart.toISOString().slice(0,10)} → ${new Date(oldestDate).toISOString().slice(0,10)})`);
972
- messages.push(...newBackfill);
973
- }
974
- } catch (e: any) {
975
- console.error(` ${folder.path}: backfill failed: ${e.message}`);
976
- }
977
- }
978
- } else {
979
- // First sync: fetch in chunks, store each chunk immediately for instant UI
980
- let totalStored = 0;
981
- const onChunk = async (chunk: any[]) => {
982
- const stored = await this.storeMessages(accountId, folderId, folder, chunk, highestUid);
983
- totalStored += stored;
984
- if (stored > 0) {
985
- this.db.recalcFolderCounts(folderId);
986
- this.emit("folderCountsChanged", accountId, {});
987
- }
988
- };
989
- const tomorrow = new Date(Date.now() + 86400000); // IMAP BEFORE is exclusive
990
- // First sync: metadata only for fast UI — bodies prefetched in background after
991
- messages = await (client as any).fetchMessageByDate(folder.path, startDate, tomorrow, { source: false }, onChunk);
992
- if (totalStored > 0) {
993
- console.log(` ${folder.path}: ${totalStored} messages (streamed)`);
994
- this.db.recalcFolderCounts(folderId);
995
- this.emit("folderCountsChanged", accountId, {});
996
- this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
997
- return totalStored;
998
- }
999
- }
1000
- if (messages.length > 0) console.log(` ${folder.path}: ${messages.length} new messages`);
1001
-
1002
- let newCount = 0;
1003
- const batchSize = 50;
1004
-
1005
- for (let batchStart = 0; batchStart < messages.length; batchStart += batchSize) {
1006
- const batchEnd = Math.min(batchStart + batchSize, messages.length);
1007
- this.db.beginTransaction();
1008
- try {
1009
- for (let i = batchStart; i < batchEnd; i++) {
1010
- const msg = messages[i];
1011
-
1012
- // Skip if we already have this UID
1013
- if (msg.uid <= highestUid) {
1014
- // But update flags in case they changed
1015
- const flags: string[] = [];
1016
- if (msg.seen) flags.push("\\Seen");
1017
- if (msg.flagged) flags.push("\\Flagged");
1018
- if (msg.answered) flags.push("\\Answered");
1019
- if (msg.draft) flags.push("\\Draft");
1020
- this.db.updateMessageFlags(accountId, msg.uid, flags);
1021
- continue;
1022
- }
1023
-
1024
- // Tombstone check — same reason as the streamy onChunk path
1025
- // at storeMessages: a locally-deleted message that the server
1026
- // hasn't EXPUNGEd yet would otherwise reappear on next sync.
1027
- // User-visible symptom: "I deleted it but it came back."
1028
- if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
1029
- console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} in syncFolder (locally deleted)`);
1030
- continue;
1031
- }
1032
-
1033
- // Store body
1034
- const source = msg.source || "";
1035
- let bodyPath = "";
1036
- if (source) {
1037
- bodyPath = await this.bodyStore.putMessage(
1038
- accountId, folderId, msg.uid,
1039
- Buffer.from(source, "utf-8")
1040
- );
1041
- }
1042
-
1043
- // Parse for preview and attachment info
1044
- const parsed = await extractPreview(source);
1045
-
1046
- // Build flags array
1047
- const flags: string[] = [];
1048
- if (msg.seen) flags.push("\\Seen");
1049
- if (msg.flagged) flags.push("\\Flagged");
1050
- if (msg.answered) flags.push("\\Answered");
1051
- if (msg.draft) flags.push("\\Draft");
1052
-
1053
- // Store metadata
1054
- this.db.upsertMessage({
1055
- accountId,
1056
- folderId,
1057
- uid: msg.uid,
1058
- messageId: msg.messageId || "",
1059
- inReplyTo: (msg as any).inReplyTo || "",
1060
- references: [],
1061
- date: msg.date instanceof Date ? msg.date.getTime() : (typeof msg.date === "number" ? msg.date : Date.now()),
1062
- subject: msg.subject || "",
1063
- from: toEmailAddress(msg.from?.[0] || {}),
1064
- to: toEmailAddresses(msg.to || []),
1065
- cc: toEmailAddresses(msg.cc || []),
1066
- flags,
1067
- size: msg.size || 0,
1068
- hasAttachments: parsed.hasAttachments,
1069
- preview: parsed.preview,
1070
- bodyPath
1071
- });
1072
-
1073
- newCount++;
1074
- }
1075
- this.db.commitTransaction();
1076
- } catch (e: any) {
1077
- console.error(` transaction error: ${e.message}`);
1078
- this.db.rollbackTransaction();
1079
- throw e;
1080
- }
1081
-
1082
- // Emit progress and notify client after each batch
1083
- this.emit("syncProgress", accountId, `sync:${folder.path}`,
1084
- Math.round((batchEnd / messages.length) * 100));
1085
-
1086
- // On first sync, emit folderCountsChanged per batch so newest messages appear immediately
1087
- if (firstSync && newCount > 0) {
1088
- this.db.recalcFolderCounts(folderId);
1089
- const folderInfo = this.db.getFolders(accountId).find(f => f.id === folderId);
1090
- this.emit("folderCountsChanged", accountId, {
1091
- [folderId]: { total: folderInfo?.totalCount || 0, unread: folderInfo?.unreadCount || 0 }
1092
- });
1093
- }
1094
- }
1095
- if (newCount > 0) console.log(` stored ${newCount} new messages`);
1096
-
1097
- // Remove messages deleted on the server (skip on first sync — nothing to reconcile).
1098
- //
1099
- // SAFETY (same three guards the Gmail API path uses, see ~line 1388):
1100
- // 1. Skip if server returned an empty list but we have local messages
1101
- // (transient Dovecot error / connection hiccup returning empty UID SEARCH
1102
- // must not wipe the folder).
1103
- // 2. Refuse to delete more than 50% in one pass — indicates a sync bug,
1104
- // never a real user action. User can fix with `mailx -rebuild` if real.
1105
- // 3. Log every deletion with Message-ID + subject so future reports have
1106
- // data (the "ubiquiti letter disappeared after reply" case had no trace).
1107
- let deletedCount = 0;
1108
- if (!firstSync) {
1109
- try {
1110
- const serverUidsArr = await client.getUids(folder.path);
1111
- const serverUids = new Set(serverUidsArr);
1112
- const localUids = this.db.getUidsForFolder(accountId, folderId);
1113
- const toDelete = localUids.filter(uid => !serverUids.has(uid));
1114
- if (serverUidsArr.length === 0 && localUids.length > 0) {
1115
- console.log(` [sync] ${accountId}/${folder.path}: reconcile skipped — server UID list empty but local has ${localUids.length} (treating as transient)`);
1116
- } else if (localUids.length > 0 && toDelete.length / localUids.length > 0.5) {
1117
- console.log(` [sync] ${accountId}/${folder.path}: reconcile REFUSED — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
1118
- } else {
1119
- for (const uid of toDelete) {
1120
- const env = this.db.getMessageByUid(accountId, uid);
1121
- const tag = env ? `msgid=${env.messageId || "?"} subj="${(env.subject || "").slice(0, 60)}"` : "unknown";
1122
- console.log(` [reconcile-delete] ${accountId}/${folder.path} uid=${uid} ${tag}`);
1123
- this.unlinkBodyFile(accountId, uid, folderId).catch(() => {});
1124
- this.db.deleteMessage(accountId, uid);
1125
- deletedCount++;
1126
- }
1127
- if (deletedCount > 0) console.log(` removed ${deletedCount} deleted messages`);
1128
- }
1129
- } catch (e: any) {
1130
- console.error(` deletion sync error: ${e.message}`);
1131
- }
1132
- }
1133
-
1134
- // Update folder counts from local DB (after deletions + additions)
1135
- // Use recalcFolderCounts — single SQL query instead of fetching all messages
1136
- this.db.recalcFolderCounts(folderId);
1137
-
1138
- this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
1139
-
1140
- const syncedAt = Date.now();
1141
- // Notify client to refresh if anything changed
1142
- if (newCount > 0 || deletedCount > 0) {
1143
- const updatedFolder = this.db.getFolders(accountId).find(f => f.id === folderId);
1144
- this.emit("folderCountsChanged", accountId, {
1145
- [folderId]: { total: updatedFolder?.totalCount || 0, unread: updatedFolder?.unreadCount || 0 }
1146
- });
1147
- }
1148
- this.emit("folderSynced", accountId, folderId, syncedAt);
1149
- this.db.updateLastSync(accountId, syncedAt);
1150
-
1151
- return newCount;
1152
- }
1153
-
1154
- /** Sync all folders for all accounts */
1155
- async syncAll(): Promise<void> {
1156
- if (this.syncing) return; // Prevent concurrent syncs
1157
- this.syncing = true;
1158
- try {
1159
- await Promise.race([
1160
- this._syncAll(),
1161
- new Promise<void>((_, reject) => setTimeout(() => reject(new Error("Global sync timeout (10min)")), 600000))
1162
- ]);
1163
- } catch (e: any) {
1164
- console.error(` syncAll error: ${e.message}`);
1165
- } finally { this.syncing = false; }
1166
- }
1167
-
1168
- private async _syncAll(): Promise<void> {
1169
- const priorityOrder = ["sent", "drafts", "archive", "junk", "trash"];
1170
-
1171
- // Sync all accounts in parallel — each manages its own connection.
1172
- // Prefetch runs per-account immediately after that account's sync
1173
- // completes, NOT after all accounts finish. This way a slow account
1174
- // (bobma with 300s timeouts) doesn't block prefetch for a fast account
1175
- // (Gmail). The old code put prefetch after `allSettled`, but syncAll
1176
- // has a 10-minute wall-clock timeout that killed it first — so
1177
- // prefetch never ran.
1178
- const syncAndPrefetch = async (accountId: string) => {
1179
- try {
1180
- await this.syncAccount(accountId, priorityOrder);
1181
- } catch {
1182
- // syncAccount already logs + emits syncError. Don't follow a
1183
- // failed sync with a body-prefetch storm into the same API:
1184
- // a 429 on listLabels means Gmail is in cooldown, so firing
1185
- // prefetch next would just re-trigger the same limit.
1186
- return;
1187
- }
1188
- if (getPrefetch()) {
1189
- this.prefetchBodies(accountId).catch(e =>
1190
- console.error(` [prefetch] ${accountId}: ${e.message}`)
1191
- );
1192
- }
1193
- };
1194
- const syncPromises = [...this.configs.keys()].map(syncAndPrefetch);
1195
- await Promise.allSettled(syncPromises);
1196
- }
1197
-
1198
- /** Sync a single account — manages its own connection lifecycle */
1199
- private async syncAccount(accountId: string, priorityOrder: string[]): Promise<void> {
1200
- // Gmail: use REST API instead of IMAP
1201
- if (this.isGmailAccount(accountId)) {
1202
- return this.syncAccountViaApi(accountId, priorityOrder);
1203
- }
1204
-
1205
- try {
1206
- // Step 1: Get folder list (fast — <1s typically)
1207
- let client = await this.getOpsClient(accountId);
1208
- const t0 = Date.now();
1209
- const folders = await this.syncFolders(accountId, client);
1210
- console.log(` [timing] ${accountId}: folder list ${Date.now() - t0}ms (${folders.length} folders)`);
1211
-
1212
- // Step 2: Sync INBOX first — keep retrying on failure (most important folder)
1213
- const inbox = folders.find(f => f.specialUse === "inbox");
1214
- if (inbox) {
1215
- console.log(` [sync] ${accountId}: starting INBOX sync (folder ${inbox.id})`);
1216
- const maxAttempts = 5;
1217
- let inboxDone = false;
1218
- for (let attempt = 1; attempt <= maxAttempts && !inboxDone; attempt++) {
1219
- try {
1220
- client = await this.getOpsClient(accountId);
1221
- if (attempt > 1) console.log(` [sync] ${accountId}: INBOX retry #${attempt}`);
1222
- await this.syncFolder(accountId, inbox.id, client);
1223
- console.log(` [sync] ${accountId}: INBOX sync complete`);
1224
- inboxDone = true;
1225
- // Kick off prefetch as soon as INBOX is fresh, not
1226
- // after all 105 folders finish — bobma's full sync
1227
- // can take 30+ minutes on a wide folder tree, and
1228
- // INBOX is the only folder the user is staring at.
1229
- // Uses the body client (separate connection from
1230
- // ops), so it runs concurrently with the rest of the
1231
- // folder sync without contending for the same socket.
1232
- if (getPrefetch()) {
1233
- this.prefetchBodies(accountId).catch(e =>
1234
- console.error(` [prefetch] ${accountId}: ${e.message}`)
1235
- );
1236
- }
1237
- } catch (e: any) {
1238
- console.error(` Inbox sync error for ${accountId} (attempt ${attempt}/${maxAttempts}): ${e.message}`);
1239
- await this.reconnectOps(accountId);
1240
- if (attempt < maxAttempts) {
1241
- const delay = Math.min(attempt * 5000, 15000);
1242
- console.log(` [sync] ${accountId}: waiting ${delay / 1000}s before INBOX retry`);
1243
- await new Promise(r => setTimeout(r, delay));
1244
- }
1245
- }
1246
- }
1247
- if (!inboxDone) {
1248
- console.error(` [sync] ${accountId}: INBOX failed after ${maxAttempts} attempts — will retry next sync cycle`);
1249
- this.emit("syncError", accountId, `INBOX sync failed after ${maxAttempts} attempts`);
1250
- // Even when sync failed, try to prefetch bodies for messages
1251
- // already in the local DB. Prefetch uses a separate body
1252
- // client (not the ops client that just timed out), so a
1253
- // sync timeout on SELECT/SEARCH doesn't necessarily mean
1254
- // body fetches will also fail. Without this, a server
1255
- // having a slow patch would leave every message with a
1256
- // white "not-downloaded" dot indefinitely until sync
1257
- // recovers — even though prior syncs already populated
1258
- // headers that prefetch can flesh out independently.
1259
- if (getPrefetch()) {
1260
- this.prefetchBodies(accountId).catch(e =>
1261
- console.error(` [prefetch] ${accountId}: ${e.message}`)
1262
- );
1263
- }
1264
- }
1265
- } else {
1266
- console.log(` [sync] ${accountId}: no INBOX folder found`);
1267
- }
1268
-
1269
- // Step 3: Sync remaining folders.
1270
- //
1271
- // Parallel pool (concurrency 2) with a per-folder wall-clock cap.
1272
- // Previous serial loop meant one slow Dovecot UID FETCH could park
1273
- // every other folder behind it for minutes — user observed "mailx
1274
- // says synced but 90 folders are empty" because the loop never
1275
- // progressed past the stalled FETCH before the next sync tick.
1276
- //
1277
- // Parallelism uses independent IMAP sockets from the ops-client
1278
- // pool, so one stalled socket doesn't block the others. The 60s
1279
- // timeout abandons a stalled command instead of waiting out
1280
- // Dovecot's 300s server-side inactivity timer; the next sync tick
1281
- // retries on a fresh socket.
1282
- const remaining = folders.filter(f => f.specialUse !== "inbox");
1283
- remaining.sort((a, b) => {
1284
- const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
1285
- const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
1286
- return pa - pb;
1287
- });
1288
-
1289
- const CONCURRENCY = 2;
1290
- const PER_FOLDER_TIMEOUT_MS = 60_000;
1291
- const total = remaining.length;
1292
- let done = 0;
1293
- let idx = 0;
1294
-
1295
- const syncOne = async (folder: any): Promise<void> => {
1296
- const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
1297
- const highestUid = this.db.getHighestUid(accountId, folder.id);
1298
- if (isTrashChild && highestUid === 0) return;
1299
- try {
1300
- const fresh = await this.getOpsClient(accountId);
1301
- await Promise.race([
1302
- this.syncFolder(accountId, folder.id, fresh),
1303
- new Promise((_, reject) => setTimeout(
1304
- () => reject(new Error(`per-folder timeout (${PER_FOLDER_TIMEOUT_MS / 1000}s): ${folder.path}`)),
1305
- PER_FOLDER_TIMEOUT_MS
1306
- )),
1307
- ]);
1308
- } catch (e: any) {
1309
- if (e.responseText?.includes("doesn't exist")) {
1310
- this.db.deleteFolder(folder.id);
1311
- } else {
1312
- console.error(` Skipping ${folder.path}: ${e.message}`);
1313
- this.recordError(accountId, e.message || String(e));
1314
- // A timeout or stale-socket failure — drop the ops
1315
- // client so the next iteration reconnects rather than
1316
- // inheriting the doomed socket.
1317
- await this.reconnectOps(accountId).catch(() => {});
1318
- }
1319
- }
1320
- };
1321
-
1322
- const worker = async (): Promise<void> => {
1323
- while (true) {
1324
- const myIdx = idx++;
1325
- if (myIdx >= remaining.length) return;
1326
- const folder = remaining[myIdx];
1327
- this.emit("syncProgress", accountId, `folders:${folder.path}`, Math.round((done / Math.max(total, 1)) * 100));
1328
- await syncOne(folder);
1329
- done++;
1330
- this.emit("syncProgress", accountId, `folders-done`, Math.round((done / Math.max(total, 1)) * 100));
1331
- console.log(` [sync] ${accountId}: folder ${done}/${total} done (${folder.path})`);
1332
- }
1333
- };
1334
-
1335
- await Promise.all(Array.from({ length: Math.min(CONCURRENCY, remaining.length) }, () => worker()));
1336
-
1337
- this.accountErrorShown.delete(accountId);
1338
- this.emit("syncComplete", accountId);
1339
- } catch (e: any) {
1340
- const errMsg = imapError(e);
1341
- this.emit("syncError", accountId, errMsg);
1342
- this.recordError(accountId, errMsg);
1343
- console.error(`Sync error for ${accountId}: ${errMsg}`);
1344
- this.handleSyncError(accountId, errMsg);
1345
- }
1346
- }
1347
-
1348
- /** Sync a Gmail account via REST API — no IMAP connections */
1349
- private async syncAccountViaApi(accountId: string, priorityOrder: string[]): Promise<void> {
1350
- try {
1351
- const api = this.getGmailProvider(accountId);
1352
- const t0 = Date.now();
1353
-
1354
- // Step 1: Sync folder list via API
1355
- console.log(` [api] ${accountId}: listing labels...`);
1356
- const apiFolders = await api.listFolders();
1357
- console.log(` [api] ${accountId}: ${apiFolders.length} labels in ${Date.now() - t0}ms`);
1358
-
1359
- // Store folders in DB (same as IMAP path)
1360
- for (const f of apiFolders) {
1361
- const specialUse = f.specialUse || "";
1362
- this.db.upsertFolder(accountId, f.path, f.name, specialUse, f.delimiter);
1363
- }
1364
- this.emit("folderCountsChanged", accountId, {});
1365
- const dbFolders = this.db.getFolders(accountId);
1366
-
1367
- // Step 2: Sync folders — INBOX first, then by priority
1368
- const inbox = dbFolders.find(f => f.specialUse === "inbox");
1369
- const remaining = dbFolders.filter(f => f.specialUse !== "inbox");
1370
- remaining.sort((a, b) => {
1371
- const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
1372
- const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
1373
- return pa - pb;
1374
- });
1375
-
1376
- const foldersToSync = inbox ? [inbox, ...remaining] : remaining;
1377
-
1378
- for (const folder of foldersToSync) {
1379
- try {
1380
- await this.syncFolderViaApi(accountId, folder, api);
1381
- } catch (e: any) {
1382
- console.error(` [api] ${accountId}/${folder.path}: ${e.message}`);
1383
- }
1384
- }
1385
-
1386
- await api.close();
1387
- this.accountErrorShown.delete(accountId);
1388
- this.emit("syncComplete", accountId);
1389
- } catch (e: any) {
1390
- const errMsg = e.message || String(e);
1391
- this.emit("syncError", accountId, errMsg);
1392
- console.error(` [api] Sync error for ${accountId}: ${errMsg}`);
1393
- this.handleSyncError(accountId, errMsg);
1394
- // Propagate so the caller skips the prefetch that would otherwise
1395
- // fire straight into the same 429/cooldown and make it worse.
1396
- throw e;
1397
- }
1398
- }
1399
-
1400
- /** Sync a single folder via Gmail/Outlook API */
1401
- private async syncFolderViaApi(accountId: string, folder: Folder, api: MailProvider): Promise<void> {
1402
- const highestUid = this.db.getHighestUid(accountId, folder.id);
1403
- const historyDays = getHistoryDays(accountId);
1404
- const effectiveDays = (historyDays === 0 && highestUid === 0) ? 30 : historyDays;
1405
- const startDate = effectiveDays > 0 ? new Date(Date.now() - effectiveDays * 86400000) : new Date(0);
1406
- const tomorrow = new Date(Date.now() + 86400000);
1407
-
1408
- this.emit("syncProgress", accountId, `sync:${folder.path}`, 0);
1409
- console.log(` [api] ${accountId}/${folder.path}: syncing (highestUid=${highestUid})...`);
1410
-
1411
- let messages: ProviderMessage[];
1412
- if (highestUid > 0) {
1413
- // Incremental: fetch messages since last known UID.
1414
- // Gmail "UIDs" are hashed (not chronological), so fetchSince
1415
- // returns messages in hash order — they can be from ANY date.
1416
- // Pass the date window so the provider can page the whole range
1417
- // (otherwise Gmail's default 200-id cap truncates high-volume
1418
- // inboxes to ~10 days regardless of historyDays).
1419
- const fetchOpts: any = { source: false };
1420
- if (effectiveDays > 0) fetchOpts.since = startDate;
1421
- messages = await api.fetchSince(folder.path, highestUid, fetchOpts);
1422
- if (effectiveDays > 0) {
1423
- const cutoff = startDate.getTime();
1424
- const before = messages.length;
1425
- messages = messages.filter(m => !m.date || m.date.getTime() >= cutoff);
1426
- if (messages.length < before) {
1427
- console.log(` [api] ${accountId}/${folder.path}: filtered ${before - messages.length} messages older than ${effectiveDays}d`);
1428
- }
1429
- }
1430
-
1431
- // Backfill: if the history window reaches further back than our
1432
- // oldest local message, fetch the gap. Mirrors the IMAP path —
1433
- // otherwise a user who started with historyDays=30 and later
1434
- // sets it to 0 (or 365) never actually sees older mail. Cap
1435
- // each sync cycle at 90 days so unlimited-history accounts
1436
- // catch up incrementally instead of paging the whole mailbox.
1437
- const oldestDate = this.db.getOldestDate(accountId, folder.id);
1438
- if (oldestDate > 0 && startDate.getTime() < oldestDate) {
1439
- try {
1440
- const CHUNK_MS = 90 * 86400000;
1441
- const chunkStart = new Date(Math.max(startDate.getTime(), oldestDate - CHUNK_MS));
1442
- const existingUids = new Set(this.db.getUidsForFolder(accountId, folder.id));
1443
- const backfill = await api.fetchByDate(folder.path, chunkStart, new Date(oldestDate), { source: false });
1444
- const newBackfill = backfill.filter(m => !existingUids.has(m.uid));
1445
- if (newBackfill.length > 0) {
1446
- console.log(` [api] ${accountId}/${folder.path}: backfilling ${newBackfill.length} older messages (${chunkStart.toISOString().slice(0,10)} → ${new Date(oldestDate).toISOString().slice(0,10)})`);
1447
- messages.push(...newBackfill);
1448
- }
1449
- } catch (e: any) {
1450
- console.error(` [api] ${accountId}/${folder.path}: backfill failed: ${e.message}`);
1451
- }
1452
- }
1453
- } else {
1454
- // First sync: fetch by date range
1455
- messages = await api.fetchByDate(folder.path, startDate, tomorrow, { source: false },
1456
- (chunk) => {
1457
- // Stream chunks to DB for instant UI
1458
- const stored = this.storeApiMessages(accountId, folder.id, chunk, highestUid);
1459
- if (stored > 0) {
1460
- this.db.recalcFolderCounts(folder.id);
1461
- this.emit("folderCountsChanged", accountId, {});
1462
- }
1463
- });
1464
- // First sync chunks already stored via onChunk — just update counts
1465
- this.db.recalcFolderCounts(folder.id);
1466
- this.emit("folderCountsChanged", accountId, {});
1467
- this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
1468
- if (messages.length > 0) console.log(` [api] ${accountId}/${folder.path}: ${messages.length} messages`);
1469
- return;
1470
- }
1471
-
1472
- if (messages.length > 0) {
1473
- console.log(` [api] ${accountId}/${folder.path}: ${messages.length} new messages`);
1474
- this.storeApiMessages(accountId, folder.id, messages, highestUid);
1475
- }
1476
-
1477
- // Reconcile deletions — messages present locally but not on the server.
1478
- // SAFETY: this used to silently wipe entire folders when getUids()
1479
- // returned a partial list (e.g. paginated fetch hit a rate limit and
1480
- // bailed). Multiple guards now:
1481
- // 1. getUids() flags partial results via _truncated — refuse to delete
1482
- // 2. If server list is empty but local isn't, assume a transient error
1483
- // 3. If reconcile would delete more than RECONCILE_DELETE_THRESHOLD of
1484
- // local messages, log and skip — safer to keep phantoms than to lose
1485
- // real messages. User can fix with `mailx -rebuild` if needed.
1486
- try {
1487
- const serverUidsArr = await api.getUids(folder.path);
1488
- const serverUids = new Set(serverUidsArr);
1489
- const localUids = this.db.getUidsForFolder(accountId, folder.id);
1490
- if ((serverUidsArr as any)._truncated) {
1491
- console.log(` [api] ${accountId}/${folder.path}: reconcile skipped — server list truncated (${serverUidsArr.length} ids)`);
1492
- } else if (serverUidsArr.length === 0 && localUids.length > 0) {
1493
- console.log(` [api] ${accountId}/${folder.path}: reconcile skipped — server list empty but local has ${localUids.length}`);
1494
- } else {
1495
- const toDelete = localUids.filter(uid => !serverUids.has(uid));
1496
- const RECONCILE_DELETE_THRESHOLD = 0.5; // refuse to delete >50% in one pass
1497
- if (localUids.length > 0 && toDelete.length / localUids.length > RECONCILE_DELETE_THRESHOLD) {
1498
- console.log(` [api] ${accountId}/${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
1499
- } else {
1500
- for (const uid of toDelete) {
1501
- const env = this.db.getMessageByUid(accountId, uid);
1502
- const tag = env ? `msgid=${env.messageId || "?"} subj="${(env.subject || "").slice(0, 60)}"` : "unknown";
1503
- console.log(` [reconcile-delete] ${accountId}/${folder.path} uid=${uid} ${tag}`);
1504
- this.unlinkBodyFile(accountId, uid, folder.id).catch(() => {});
1505
- this.db.deleteMessage(accountId, uid);
1506
- }
1507
- if (toDelete.length > 0) console.log(` [api] ${accountId}/${folder.path}: ${toDelete.length} deleted`);
1508
- }
1509
- }
1510
- } catch (e: any) {
1511
- console.error(` [api] ${accountId}/${folder.path}: reconciliation error: ${e.message}`);
1512
- }
1513
-
1514
- this.db.recalcFolderCounts(folder.id);
1515
- this.emit("folderCountsChanged", accountId, {});
1516
- this.emit("folderSynced", accountId, folder.id, Date.now());
1517
- this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
1518
- }
1519
-
1520
- /** Store API-fetched messages to DB */
1521
- private storeApiMessages(accountId: string, folderId: number, msgs: ProviderMessage[], highestUid: number): number {
1522
- // highestUid kept for signature compatibility but no longer used to
1523
- // filter — Gmail message IDs aren't monotonic, so `msg.uid <= highestUid`
1524
- // would drop brand-new messages whose hash happens to be smaller than
1525
- // the previous high. upsertMessage's primary-key dedup handles it.
1526
- void highestUid;
1527
- let stored = 0;
1528
- let errors = 0;
1529
- // Don't wrap the whole batch in one transaction: a single bad row
1530
- // would roll back the entire batch. E.g. a message with a malformed
1531
- // Date header gave `new Date(rawStr).getTime() === NaN`, SQLite
1532
- // coerced that to NULL, the NOT NULL constraint failed, and the
1533
- // whole Gmail sync lost 200 messages per tick. Now each row runs
1534
- // standalone — bad rows are logged and skipped.
1535
- for (const msg of msgs) {
1536
- try {
1537
- // Tombstone check — Gmail API sync was missing this so a
1538
- // locally-trashed Gmail message would reappear on the next
1539
- // listMessages tick if Gmail's eventual-consistency hadn't
1540
- // promoted the trash yet. Symmetric with the IMAP paths.
1541
- if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
1542
- continue;
1543
- }
1544
- const flags: string[] = [];
1545
- if (msg.seen) flags.push("\\Seen");
1546
- if (msg.flagged) flags.push("\\Flagged");
1547
- if (msg.answered) flags.push("\\Answered");
1548
- if (msg.draft) flags.push("\\Draft");
1549
- // Sanitize date: reject NaN (from malformed RFC 822 Date headers)
1550
- // and fall back to "now" so the message still lands in the DB.
1551
- let dateMs = Date.now();
1552
- if (msg.date instanceof Date) {
1553
- const t = msg.date.getTime();
1554
- if (Number.isFinite(t)) dateMs = t;
1555
- }
1556
- this.db.upsertMessage({
1557
- accountId, folderId, uid: msg.uid,
1558
- messageId: msg.messageId || "",
1559
- inReplyTo: msg.inReplyTo || "",
1560
- references: msg.references || [],
1561
- date: dateMs,
1562
- subject: msg.subject || "",
1563
- from: toEmailAddress(msg.from?.[0] || {}),
1564
- to: toEmailAddresses(msg.to || []),
1565
- cc: toEmailAddresses(msg.cc || []),
1566
- flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath: "",
1567
- providerId: msg.providerId || "",
1568
- });
1569
- stored++;
1570
- } catch (e: any) {
1571
- errors++;
1572
- if (errors <= 3) {
1573
- console.error(` [api] upsert ${accountId}/${folderId}/${msg.uid} (${msg.messageId}): ${e.message}`);
1574
- }
1575
- }
1576
- }
1577
- if (errors > 0) console.error(` [api] storeApiMessages: ${errors} of ${msgs.length} rows failed (${stored} stored)`);
1578
- return stored;
1579
- }
1580
-
1581
- /** Kill and recreate the persistent ops connection */
1582
- private async reconnectOps(accountId: string): Promise<void> {
1583
- const old = this.opsClients.get(accountId);
1584
- this.opsClients.delete(accountId);
1585
- if (old) { try { await (old._realLogout || old.logout)(); } catch { /* */ } }
1586
- console.log(` [conn] ${accountId}: reconnecting`);
1587
- }
1588
-
1589
- /** Handle sync errors — classify and emit appropriate UI events.
1590
- * The connection-cap branch was removed: with the unified ops queue +
1591
- * per-host semaphore, mailx alone can't exceed the server cap. If the
1592
- * cap *is* hit, that means another client (Thunderbird, phone, sibling
1593
- * process) is holding slots — punishing mailx with a multi-minute
1594
- * blackout doesn't help the user, the next sync tick will retry. */
1595
- private handleSyncError(accountId: string, errMsg: string): void {
1596
- const config = this.configs.get(accountId);
1597
- const isOAuth = !!config?.tokenProvider;
1598
- const isTransient = /timeout|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|Too many/i.test(errMsg);
1599
- const isAuth = /auth|login|credential|token|AUTHENTICATIONFAILED/i.test(errMsg);
1600
-
1601
- if (isTransient) {
1602
- console.log(` [transient] ${accountId}: ${errMsg} — will retry next cycle`);
1603
- } else if (isAuth && isOAuth) {
1604
- const lastReauth = this.lastReauthAttempt.get(accountId) || 0;
1605
- if (Date.now() - lastReauth > 300000 && !this.reauthenticating.has(accountId)) {
1606
- this.lastReauthAttempt.set(accountId, Date.now());
1607
- this.reauthenticate(accountId).catch(() => {});
1608
- }
1609
- if (!this.accountErrorShown.has(accountId)) {
1610
- this.accountErrorShown.add(accountId);
1611
- this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
1612
- }
1613
- } else if (!this.accountErrorShown.has(accountId)) {
1614
- this.accountErrorShown.add(accountId);
1615
- this.emit("accountError", accountId, errMsg, errMsg, isOAuth);
1616
- }
1617
- }
1618
-
1619
- /** Fetch ONLY new messages above highestUid for one account's INBOX —
1620
- * the IDLE callback's hot path. Skips gap detection, backfill, and the
1621
- * server reconcile (each of which fetches a full UID list — multi-second
1622
- * on a large mailbox). The 5-minute STATUS poll path still runs full
1623
- * `syncFolder` so deletions and gaps eventually reconcile. */
1624
- async syncInboxNewOnly(accountId: string): Promise<void> {
1625
- if (this.isGmailAccount(accountId)) return; // IDLE is IMAP-only
1626
- const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
1627
- if (!inbox) return;
1628
- try {
1629
- await this.withConnection(accountId, async (client: any) => {
1630
- const highestUid = this.db.getHighestUid(accountId, inbox.id);
1631
- if (highestUid === 0) {
1632
- // First sync — fall through to full path so the date-windowed
1633
- // backfill runs. `syncFolder` handles the no-highestUid case.
1634
- await this.syncFolder(accountId, inbox.id, client);
1635
- return;
1636
- }
1637
- const fetched = await client.fetchMessagesSinceUid(inbox.path, highestUid, { source: false });
1638
- const fresh = fetched.filter((m: any) => m.uid > highestUid);
1639
- if (fresh.length === 0) return;
1640
- const stored = await this.storeMessages(accountId, inbox.id, inbox, fresh, highestUid);
1641
- if (stored > 0) {
1642
- this.db.recalcFolderCounts(inbox.id);
1643
- const updated = this.db.getFolders(accountId).find(f => f.id === inbox.id);
1644
- this.emit("folderCountsChanged", accountId, {
1645
- [inbox.id]: { total: updated?.totalCount || 0, unread: updated?.unreadCount || 0 }
1646
- });
1647
- this.emit("folderSynced", accountId, inbox.id, Date.now());
1648
- console.log(` [idle-fast] ${accountId}: stored ${stored} new message(s)`);
1649
- }
1650
- });
1651
- } catch (e: any) {
1652
- console.error(` [idle-fast] ${accountId}: ${e.message}`);
1653
- }
1654
- }
1655
-
1656
- /** Sync just INBOX for each account (fast check for new mail) */
1657
- async syncInbox(): Promise<void> {
1658
- if (this.inboxSyncing) return;
1659
- this.inboxSyncing = true;
1660
- try {
1661
- for (const [accountId] of this.configs) {
1662
- try {
1663
- const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
1664
- if (!inbox) continue;
1665
- // Gmail: use REST API, NOT IMAP. Mixing paths causes UID
1666
- // mismatch — API uses hashed IDs, IMAP uses server-assigned
1667
- // UIDs. The IMAP reconcile then deletes every API-synced
1668
- // message because their UIDs don't appear in the IMAP list.
1669
- if (this.isGmailAccount(accountId)) {
1670
- const api = this.getGmailProvider(accountId);
1671
- try {
1672
- await this.syncFolderViaApi(accountId, inbox, api);
1673
- } finally {
1674
- try { await api.close(); } catch { /* ignore */ }
1675
- }
1676
- } else {
1677
- await this.withConnection(accountId, async (client) => {
1678
- await this.syncFolder(accountId, inbox.id, client);
1679
- });
1680
- }
1681
- } catch (e: any) {
1682
- console.error(` [inbox] Sync error for ${accountId}: ${e.message}`);
1683
- }
1684
- }
1685
- } finally {
1686
- this.inboxSyncing = false;
1687
- }
1688
- }
1689
-
1690
- /** Quick inbox check — per-account lightweight probe.
1691
- * If the probe value changed since last time, triggers an inbox sync.
1692
- * The marker is only advanced after a successful sync so that a failed
1693
- * sync doesn't eat the "new mail" signal and make us stop retrying. */
1694
- private lastInboxMarker = new Map<string, string | number>();
1695
- private quickCheckRunning = new Set<string>(); // per-account guard
1696
-
1697
- /** Shared quick-check skeleton: probe → compare → sync-if-changed → advance marker.
1698
- * `probe` returns the current marker value; `sync` runs only when it differs
1699
- * from the previously stored value. Marker is advanced only after sync resolves. */
1700
- private async quickCheck<T extends string | number>(
1701
- accountId: string,
1702
- probe: () => Promise<T | null>,
1703
- sync: (current: T, prev: T | undefined) => Promise<void>,
1704
- ): Promise<void> {
1705
- if (this.quickCheckRunning.has(accountId)) return;
1706
- if (this.reauthenticating.has(accountId)) return;
1707
- this.quickCheckRunning.add(accountId);
1708
- try {
1709
- const current = await probe();
1710
- if (current === null || current === "" as any) return;
1711
- const prev = this.lastInboxMarker.get(accountId) as T | undefined;
1712
- if (prev === undefined || current !== prev) {
1713
- await sync(current, prev);
1714
- }
1715
- // Only advance after sync succeeds — a thrown error skips this line
1716
- // and the next tick will see the same delta and retry.
1717
- this.lastInboxMarker.set(accountId, current);
1718
- } catch {
1719
- // Lightweight check — silently ignore errors
1720
- } finally {
1721
- this.quickCheckRunning.delete(accountId);
1722
- }
1723
- }
1724
-
1725
- /** Check a single account's inbox — uses its own connection, never blocked by sync */
1726
- async quickInboxCheckAccount(accountId: string): Promise<void> {
1727
- if (this.isGmailAccount(accountId)) return this.quickGmailCheck(accountId);
1728
- return this.quickImapCheck(accountId);
1729
- }
1730
-
1731
- private async quickImapCheck(accountId: string): Promise<void> {
1732
- const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
1733
- if (!inbox) return;
1734
- let client: any = null;
1735
- try {
1736
- await this.quickCheck<number>(
1737
- accountId,
1738
- async () => {
1739
- client = await this.newClient(accountId, "quickCheck");
1740
- return await client.getMessagesCount("INBOX");
1741
- },
1742
- async (count, prev) => {
1743
- if (prev !== undefined) console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
1744
- await this.syncFolder(accountId, inbox.id, client);
1745
- },
1746
- );
1747
- } finally {
1748
- if (client) { try { await client.logout(); } catch { /* */ } }
1749
- }
1750
- }
1751
-
1752
- private async quickGmailCheck(accountId: string): Promise<void> {
1753
- const config = this.configs.get(accountId);
1754
- if (!config?.tokenProvider) return;
1755
- const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
1756
- if (!inbox) return;
1757
- await this.quickCheck<string>(
1758
- accountId,
1759
- async () => {
1760
- const token = await config.tokenProvider!();
1761
- const res = await globalThis.fetch(
1762
- `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=in:inbox&maxResults=1`,
1763
- { headers: { "Authorization": `Bearer ${token}` } }
1764
- );
1765
- if (!res.ok) return null;
1766
- const data = await res.json();
1767
- return data.messages?.[0]?.id || null;
1768
- },
1769
- async (_topId, prev) => {
1770
- if (prev !== undefined) console.log(` [check] ${accountId} INBOX: new message detected`);
1771
- const api = this.getGmailProvider(accountId);
1772
- await this.syncFolderViaApi(accountId, inbox, api);
1773
- this.db.recalcFolderCounts(inbox.id);
1774
- this.emit("folderCountsChanged", accountId, {});
1775
- await api.close();
1776
- },
1777
- );
1778
- }
1779
-
1780
- /** Check all accounts (used by legacy callers) */
1781
- async quickInboxCheck(): Promise<void> {
1782
- for (const [accountId] of this.configs) {
1783
- await this.quickInboxCheckAccount(accountId);
1784
- }
1785
- }
1786
-
1787
- /** Start periodic sync */
1788
- startPeriodicSync(intervalMinutes: number): void {
1789
- this.stopPeriodicSync();
1790
-
1791
- // Per-account quick inbox check — adapts to server constraints.
1792
- // Accounts with IDLE running get a long interval (5 min) because IDLE
1793
- // already pushes instant notifications — the STATUS poll is just a
1794
- // safety net. Non-IDLE accounts (rare) use a shorter interval.
1795
- //
1796
- // CRITICAL: the previous value (2500ms for everyone) was hammering
1797
- // Dovecot with 24 logins per minute. That's what tripped the server
1798
- // operator's fail2ban on mail1, and was still flooding the desktop
1799
- // connection. Each STATUS poll creates a disposable connection
1800
- // (TLS + auth + STATUS + close), not a lightweight keep-alive.
1801
- for (const [accountId] of this.configs) {
1802
- // Gmail uses API sync, not IMAP STATUS. IMAP accounts use IDLE
1803
- // which gives instant push — the STATUS poll is just a fallback
1804
- // in case IDLE silently dropped.
1805
- const isGmail = this.isGmailAccount(accountId);
1806
- // IMAP accounts: IDLE gives instant push; STATUS poll is just a
1807
- // safety net for silent IDLE drops — keep it infrequent.
1808
- // Gmail accounts: no IDLE (Gmail API doesn't expose it), so the
1809
- // quick poll IS the primary path to new-mail latency. Drop to 30s
1810
- // so Gmail mail appears in ~15s average. Gmail quota budget is
1811
- // huge (250 units/sec per user, 1.2B/day) — 120 polls/hour × 5
1812
- // units ≈ 600/hour, trivial. Dovecot accounts stay at 5min to
1813
- // respect connection limits (each poll = fresh connection).
1814
- const interval = isGmail ? 30000 : 300000; // Gmail: 30s; IMAP: 5min
1815
- const timer = setInterval(() => {
1816
- this.quickInboxCheckAccount(accountId).catch(() => {});
1817
- }, interval);
1818
- this.syncIntervals.set(`quick:${accountId}`, timer);
1819
- console.log(` [periodic] ${accountId}: STATUS check every ${interval / 1000}s (${isGmail ? "API" : "IMAP+IDLE"})`);
1820
- }
1821
-
1822
- // Sync actions (sends + flags/deletes/moves) every 30 seconds — skip during active sync
1823
- const actionsInterval = setInterval(async () => {
1824
- if (this.syncing) return;
1825
- for (const [accountId] of this.configs) {
1826
- this.processSendActions(accountId).catch(() => {});
1827
- this.processSyncActions(accountId).catch(() => {});
1828
- }
1829
- }, 30000);
1830
- this.syncIntervals.set("actions", actionsInterval);
1831
-
1832
- // Body prefetch as a first-class background task — independent of
1833
- // sync success. Prefetch was previously only triggered from inside
1834
- // sync, so any account with slow/failing IMAP had its "not downloaded"
1835
- // dots stuck forever even though body fetches use a separate
1836
- // connection that might succeed. Every 60s, for every account, fire
1837
- // prefetchBodies() (cheap when body_path is already populated — just a
1838
- // DB query that returns 0 rows; the prefetchingAccounts guard
1839
- // short-circuits concurrent triggers).
1840
- if (getPrefetch()) {
1841
- const kickPrefetch = (): void => {
1842
- for (const [accountId] of this.configs) {
1843
- this.prefetchBodies(accountId).catch(e =>
1844
- console.error(` [prefetch] ${accountId}: ${e?.message || e}`)
1845
- );
1846
- }
1847
- };
1848
- // Fire once now so the "not downloaded" dots start filling in
1849
- // immediately on app start, don't make the user wait a minute.
1850
- setTimeout(kickPrefetch, 2000);
1851
- const prefetchInterval = setInterval(kickPrefetch, 60000);
1852
- this.syncIntervals.set("prefetch", prefetchInterval);
1853
- console.log(` [periodic] body prefetch every 60s (independent of sync)`);
1854
- }
1855
-
1856
- // Tombstone prune: age out local-delete records older than 30 days.
1857
- // Runs hourly — cheap (one indexed DELETE).
1858
- const TOMBSTONE_RETENTION_DAYS = 30;
1859
- const pruneTombstones = (): void => {
1860
- const cutoff = Date.now() - TOMBSTONE_RETENTION_DAYS * 86400_000;
1861
- const n = this.db.pruneTombstones(cutoff);
1862
- if (n > 0) console.log(` [tombstones] pruned ${n} older than ${TOMBSTONE_RETENTION_DAYS} days`);
1863
- };
1864
- setTimeout(pruneTombstones, 30_000); // first run after startup settles
1865
- this.syncIntervals.set("tombstone-prune", setInterval(pruneTombstones, 3600_000));
1866
-
1867
- // Full sync (all folders + IDLE restart) at configured interval
1868
- const fullInterval = setInterval(async () => {
1869
- console.log(` [periodic] Full sync at ${new Date().toLocaleTimeString()}`);
1870
- await this.syncAll();
1871
- await this.stopWatching();
1872
- await this.startWatching();
1873
- }, intervalMinutes * 60000);
1874
- this.syncIntervals.set("all", fullInterval);
1875
- }
1876
-
1877
- /** Stop periodic sync */
1878
- stopPeriodicSync(): void {
1879
- for (const [key, interval] of this.syncIntervals) {
1880
- clearInterval(interval);
1881
- }
1882
- this.syncIntervals.clear();
1883
- }
1884
-
1885
- /** Check if an account is OAuth (Gmail/Outlook — generous connection limits) */
1886
- isOAuthAccount(accountId: string): boolean {
1887
- const config = this.configs.get(accountId);
1888
- return !!config?.tokenProvider;
1889
- }
1890
-
1891
- /** Start IMAP IDLE watchers for INBOX on each account */
1892
- async startWatching(): Promise<void> {
1893
- for (const [accountId] of this.configs) {
1894
- if (this.watchers.has(accountId)) continue;
1895
- try {
1896
- // IDLE keeps its own dedicated socket — once the connection
1897
- // is parked in IDLE, it's unusable for any other command, so
1898
- // it can't share the ops queue. Counts against the per-host
1899
- // semaphore (one slot for the IDLE socket).
1900
- const watchClient = await this.createClient(accountId, "idle");
1901
- const stop = await watchClient.watchMailbox("INBOX", (newCount: number) => {
1902
- console.log(` [idle] ${accountId}: ${newCount} new message(s)`);
1903
- // Fetch only the new UIDs — the heavyweight gap/reconcile
1904
- // path runs on the 5-minute STATUS poll, so EXISTS lands
1905
- // in the UI in roughly one round-trip.
1906
- this.syncInboxNewOnly(accountId).catch(e => console.error(` [idle] sync error: ${e.message}`));
1907
- });
1908
- this.watchers.set(accountId, async () => {
1909
- await stop();
1910
- await watchClient.logout();
1911
- });
1912
- console.log(` [idle] Watching INBOX for ${accountId}`);
1913
- } catch (e: any) {
1914
- console.error(` [idle] Failed to watch ${accountId}: ${e.message}`);
1915
- }
1916
- }
1917
- }
1918
-
1919
- /** Stop all IDLE watchers */
1920
- async stopWatching(): Promise<void> {
1921
- for (const [id, stop] of this.watchers) {
1922
- try { await stop(); } catch { /* ignore */ }
1923
- }
1924
- this.watchers.clear();
1925
- }
1926
-
1927
- /** Unlink the on-disk body file for a message by reading its `body_path`
1928
- * from the DB. Safe to call either before or after `db.deleteMessage`
1929
- * — read body_path first, store it, then unlink whenever. */
1930
- private async unlinkBodyFile(accountId: string, uid: number, folderId?: number): Promise<void> {
1931
- try {
1932
- const row: any = this.db.getMessageByUid(accountId, uid, folderId);
1933
- const p = row?.bodyPath;
1934
- if (p) await this.bodyStore.unlinkByPath(p);
1935
- } catch { /* row already gone / file already gone — both fine */ }
1936
- }
1937
-
1938
- /** Fetch a single message body on demand, caching in the store.
1939
- *
1940
- * Cache lookup is folder-agnostic: when a UID exists in multiple folders
1941
- * (Gmail labels, copy-instead-of-move) the prefetcher may have populated
1942
- * body_path on only one row. Looking up by (account, uid) without the
1943
- * folder filter finds the cached `.eml` regardless of which folder
1944
- * context the UI passed.
1945
- *
1946
- * Server fetch goes through the unified ops queue on the fast lane —
1947
- * the user clicked, they're waiting, this jumps ahead of any background
1948
- * prefetch sitting in the slow lane. */
1949
- async fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Buffer | null> {
1950
- const envelope: any = this.db.getMessageByUid(accountId, uid, folderId);
1951
- let storedPath = envelope?.bodyPath || "";
1952
- if (!storedPath) storedPath = this.db.getMessageBodyPath(accountId, uid) || "";
1953
- if (storedPath && await this.bodyStore.hasByPath(storedPath)) {
1954
- return this.bodyStore.readByPath(storedPath);
1955
- }
1956
-
1957
- if (!this.configs.has(accountId)) return null;
1958
-
1959
- const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
1960
- if (!folder) return null;
1961
-
1962
- // Gmail: REST API, no IMAP connection involved.
1963
- if (this.isGmailAccount(accountId)) {
1964
- return this.fetchMessageBodyViaApi(accountId, folderId, uid, folder.path);
1965
- }
1966
-
1967
- // IMAP: fast lane on the ops queue. One try; if the socket is stale,
1968
- // withConnection's discard-on-error logic drops the client so the
1969
- // next attempt (caller-driven retry) gets a fresh one.
1970
- try {
1971
- const raw = await this.withConnection(accountId, async (client) => {
1972
- const msg: any = await client.fetchMessageByUid(folder.path, uid, { source: true });
1973
- if (!msg) throw makeNotFoundError(accountId, folderId, uid);
1974
- if (!msg.source) return null;
1975
- return Buffer.from(msg.source, "utf-8");
1976
- });
1977
- if (!raw) return null;
1978
- const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
1979
- this.db.updateBodyPath(accountId, uid, bodyPath);
1980
- this.emit("bodyCached", accountId, uid);
1981
- return raw;
1982
- } catch (e: any) {
1983
- if (e?.isNotFound) throw e;
1984
- console.error(` Body fetch error (${accountId}/${uid}): ${e?.message || e}`);
1985
- return null;
1986
- }
1987
- }
1988
-
1989
- /** Fetch message body via Gmail/Outlook API.
1990
- * Throws `MessageNotFoundError` when the server says the message is gone
1991
- * (deleted from another device, for example). The caller uses that to
1992
- * delete the stale row locally instead of showing a generic error. */
1993
- private async fetchMessageBodyViaApi(accountId: string, folderId: number, uid: number, folderPath: string): Promise<Buffer | null> {
1994
- try {
1995
- const api = this.getGmailProvider(accountId);
1996
- // Read provider_id from the local row so fetchOne can skip the
1997
- // listMessageIds pagination (the dominant Gmail rate-limit cost).
1998
- const env = this.db.getMessageByUid(accountId, uid, folderId);
1999
- const providerId = env?.providerId;
2000
- const msg = await api.fetchOne(folderPath, uid, { source: true, providerId });
2001
- await api.close();
2002
- if (!msg) {
2003
- // fetchOne returned null — message doesn't exist on the server anymore
2004
- throw makeNotFoundError(accountId, folderId, uid);
2005
- }
2006
- if (!msg.source) {
2007
- // Gmail returned a message object but no raw bytes. Seen when:
2008
- // (a) the message exists but is larger than the format=raw cap (~10MB),
2009
- // (b) UID→Gmail-ID resolution picked a collision and the target
2010
- // exists only as a stub, or (c) the listMessageIds top-1000
2011
- // didn't include our UID and fetchOne returned null above —
2012
- // wait, that would hit the !msg branch. So (a)/(b) remain.
2013
- // Log enough to distinguish; surface the reason up via a non-null
2014
- // return so the UI stops showing a generic "fetch returned nothing".
2015
- console.error(` [api] Body fetch empty source (${accountId}/${uid}): Gmail returned no raw body — likely too-large-for-format-raw or UID hash collision`);
2016
- return null;
2017
- }
2018
- const raw = Buffer.from(msg.source, "utf-8");
2019
- const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
2020
- this.db.updateBodyPath(accountId, uid, bodyPath);
2021
- this.emit("bodyCached", accountId, uid);
2022
- return raw;
2023
- } catch (e: any) {
2024
- // Gmail API 404 → the message was deleted on the server
2025
- if ((e as any)?.isNotFound || /Gmail API 404|404|not found/i.test(e?.message || "")) {
2026
- throw makeNotFoundError(accountId, folderId, uid);
2027
- }
2028
- console.error(` [api] Body fetch error (${accountId}/${uid}): ${e.message}`);
2029
- return null;
2030
- }
2031
- }
2032
-
2033
- /** Background body prefetch — download bodies for messages that don't have them.
2034
- * Server-side deletions (isNotFound) aren't errors here: we delete the
2035
- * stale row locally and keep going. Only unrelated errors (network,
2036
- * auth, rate limits) count against the error budget, and the budget is
2037
- * generous so a few transient failures don't kill the whole run. */
2038
- /** Guard against concurrent prefetchBodies for the same account — mirror of
2039
- * `sendingAccounts`. Without this, every periodic-sync tick spawns a new
2040
- * prefetch session alongside any still in flight, blowing through Gmail's
2041
- * per-minute quota and racing on disk writes. One prefetch per account. */
2042
- private prefetchingAccounts = new Set<string>();
2043
-
2044
- /** Per-folder error cooldowns — `accountId:folderPath` → [timestamps].
2045
- * Used to skip folders that repeatedly time out (Dovecot on slow shared
2046
- * hosting SELECTs big `_Spam` / archive folders at 300s+ latency; the
2047
- * prefetch error budget was burning out on a handful of bad folders
2048
- * before the INBOX could finish). A folder with 2+ errors in the last
2049
- * 15 minutes is skipped until the cooldown passes. User-reported via
2050
- * log analysis 2026-04-23: bobma prefetch timing out on Added2.organizations,
2051
- * Added2.technews, _Spam, "Prefirst.Jerry's Retreat", Added2.zines. */
2052
- private folderErrorCooldown = new Map<string, number[]>();
2053
- private readonly FOLDER_ERROR_WINDOW_MS = 15 * 60_000;
2054
- private readonly FOLDER_ERROR_THRESHOLD = 2;
2055
-
2056
- private shouldSkipFolder(accountId: string, folderPath: string): boolean {
2057
- const key = `${accountId}:${folderPath}`;
2058
- const now = Date.now();
2059
- const errors = (this.folderErrorCooldown.get(key) || [])
2060
- .filter(t => now - t < this.FOLDER_ERROR_WINDOW_MS);
2061
- this.folderErrorCooldown.set(key, errors);
2062
- return errors.length >= this.FOLDER_ERROR_THRESHOLD;
2063
- }
2064
- private recordFolderError(accountId: string, folderPath: string): void {
2065
- const key = `${accountId}:${folderPath}`;
2066
- const arr = this.folderErrorCooldown.get(key) || [];
2067
- arr.push(Date.now());
2068
- this.folderErrorCooldown.set(key, arr);
2069
- }
2070
- private clearFolderErrors(accountId: string, folderPath: string): void {
2071
- this.folderErrorCooldown.delete(`${accountId}:${folderPath}`);
2072
- }
2073
-
2074
- private async prefetchBodies(accountId: string): Promise<void> {
2075
- if (this.prefetchingAccounts.has(accountId)) return;
2076
- this.prefetchingAccounts.add(accountId);
2077
- try { await this._prefetchBodies(accountId); }
2078
- finally { this.prefetchingAccounts.delete(accountId); }
2079
- }
2080
-
2081
- private async _prefetchBodies(accountId: string): Promise<void> {
2082
- const counters = { totalFetched: 0, deleted: 0, errors: 0 };
2083
- const ERROR_BUDGET = 20;
2084
- const RATE_LIMIT_PAUSE_MS = 30000;
2085
- const BATCH_SIZE = 100;
2086
- const isGmail = this.isGmailAccount(accountId);
2087
- // Gmail still uses per-message fetch (HTTP /batch is a separate TODO in this file's
2088
- // governing unit). IMAP uses the batched `fetchBodiesBatch` path via iflow-direct —
2089
- // one SELECT + one UID FETCH per folder per tick instead of N round trips.
2090
- let announced = false;
2091
- while (true) {
2092
- const missing = this.db.getMessagesWithoutBody(accountId, BATCH_SIZE);
2093
- if (missing.length === 0) break;
2094
- if (!announced) {
2095
- console.log(` [prefetch] ${accountId}: ${missing.length}+ bodies to fetch`);
2096
- announced = true;
2097
- }
2098
- let madeProgress = false;
2099
-
2100
- if (isGmail) {
2101
- // Gmail batch path: group by label (what mailx calls "folder"),
2102
- // list once per label, bounded-concurrency fetch. Far fewer
2103
- // HTTP round trips than the old one-listMessageIds-per-body path.
2104
- // Note on the model: Gmail has labels, not folders. A message in
2105
- // multiple labels gets fetched twice under current grouping. A
2106
- // deeper label-native redesign is tracked as a separate TODO.
2107
- const byFolder = new Map<number, number[]>();
2108
- for (const m of missing) {
2109
- let arr = byFolder.get(m.folderId);
2110
- if (!arr) { arr = []; byFolder.set(m.folderId, arr); }
2111
- arr.push(m.uid);
2112
- }
2113
- const folders = this.db.getFolders(accountId);
2114
- const api = this.getGmailProvider(accountId);
2115
- try {
2116
- for (const [folderId, uidsInFolder] of byFolder) {
2117
- const folder = folders.find(f => f.id === folderId);
2118
- if (!folder) continue;
2119
- const received = new Set<number>();
2120
- const pending: Promise<void>[] = [];
2121
- let batchSucceeded = false;
2122
- try {
2123
- await (api as any).fetchBodiesBatch(folder.path, uidsInFolder, (uid: number, source: string) => {
2124
- received.add(uid);
2125
- pending.push((async () => {
2126
- try {
2127
- const raw = Buffer.from(source, "utf-8");
2128
- const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
2129
- this.db.updateBodyPath(accountId, uid, bodyPath);
2130
- this.emit("bodyCached", accountId, uid);
2131
- counters.totalFetched++;
2132
- madeProgress = true;
2133
- } catch (e: any) {
2134
- console.error(` [prefetch] ${accountId}/${uid}: store write failed: ${e.message}`);
2135
- }
2136
- })());
2137
- });
2138
- batchSucceeded = true;
2139
- } catch (e: any) {
2140
- const isRate = /429|rate|too many/i.test(String(e?.message || ""));
2141
- if (isRate) {
2142
- console.log(` [prefetch] ${accountId}: rate-limited — pausing ${RATE_LIMIT_PAUSE_MS / 1000}s`);
2143
- await new Promise(r => setTimeout(r, RATE_LIMIT_PAUSE_MS));
2144
- } else {
2145
- console.error(` [prefetch] ${accountId} folder ${folder.path}: Gmail batch fetch failed: ${e.message}`);
2146
- counters.errors++;
2147
- }
2148
- }
2149
- await Promise.all(pending);
2150
- // CRITICAL: only prune as "server-deleted" when the batch
2151
- // actually completed. If the batch threw (403, 429, network
2152
- // error, etc.) NOTHING was received, and treating every
2153
- // requested UID as deleted silently wipes 100 messages per
2154
- // batch. That's a data-loss bug. Earlier version did this
2155
- // and pruned 296 messages on a 403 auth error.
2156
- if (batchSucceeded) {
2157
- for (const uid of uidsInFolder) {
2158
- if (received.has(uid)) continue;
2159
- try {
2160
- this.unlinkBodyFile(accountId, uid, folderId).catch(() => {});
2161
- this.db.deleteMessage(accountId, uid);
2162
- counters.deleted++;
2163
- madeProgress = true;
2164
- } catch { /* ignore */ }
2165
- }
2166
- }
2167
- if (counters.errors >= ERROR_BUDGET) break;
2168
- }
2169
- } finally {
2170
- try { await api.close(); } catch { /* ignore */ }
2171
- }
2172
- if (counters.errors >= ERROR_BUDGET) {
2173
- console.error(` [prefetch] ${accountId}: stopping after ${counters.errors} errors (${counters.totalFetched} cached, ${counters.deleted} pruned)`);
2174
- return;
2175
- }
2176
- } else {
2177
- // IMAP batch path: one UID FETCH per folder, each on its own
2178
- // turn through the slow lane. Yielding between folders is
2179
- // crucial — a click-to-view body should jump ahead of the
2180
- // next folder's batch via the fast lane, not wait for all
2181
- // folders to finish.
2182
- const byFolder = new Map<number, number[]>();
2183
- for (const m of missing) {
2184
- let arr = byFolder.get(m.folderId);
2185
- if (!arr) { arr = []; byFolder.set(m.folderId, arr); }
2186
- arr.push(m.uid);
2187
- }
2188
- const folders = this.db.getFolders(accountId);
2189
- // INBOX-first ordering so the folder the user actually looks at
2190
- // gets its bodies even if a later folder eats the error budget.
2191
- const orderedFolders = Array.from(byFolder.entries()).sort(([aid], [bid]) => {
2192
- const af = folders.find(f => f.id === aid);
2193
- const bf = folders.find(f => f.id === bid);
2194
- const ai = af?.specialUse === "inbox" ? 0 : 1;
2195
- const bi = bf?.specialUse === "inbox" ? 0 : 1;
2196
- return ai - bi;
2197
- });
2198
- for (const [folderId, uids] of orderedFolders) {
2199
- const folder = folders.find(f => f.id === folderId);
2200
- if (!folder) continue;
2201
- if (this.shouldSkipFolder(accountId, folder.path)) {
2202
- console.log(` [prefetch] ${accountId}: skipping ${folder.path} (recent timeouts — cooling down)`);
2203
- continue;
2204
- }
2205
- const received = new Set<number>();
2206
- let batchSucceeded = false;
2207
- try {
2208
- // Slow lane: prefetch is the textbook "this might take
2209
- // a while" case — let interactive ops slip ahead.
2210
- await this.withConnection(accountId, async (client) => {
2211
- const pending: Promise<void>[] = [];
2212
- await client.fetchBodiesBatch(folder.path, uids, (uid: number, source: string) => {
2213
- received.add(uid);
2214
- pending.push((async () => {
2215
- try {
2216
- const raw = Buffer.from(source, "utf-8");
2217
- const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
2218
- this.db.updateBodyPath(accountId, uid, bodyPath);
2219
- this.emit("bodyCached", accountId, uid);
2220
- counters.totalFetched++;
2221
- madeProgress = true;
2222
- } catch (e: any) {
2223
- console.error(` [prefetch] ${accountId}/${uid}: store write failed: ${e.message}`);
2224
- }
2225
- })());
2226
- });
2227
- await Promise.all(pending);
2228
- }, { slow: true });
2229
- batchSucceeded = true;
2230
- this.clearFolderErrors(accountId, folder.path);
2231
- } catch (e: any) {
2232
- const msg = String(e?.message || "");
2233
- console.error(` [prefetch] ${accountId} folder ${folder.path}: batch fetch failed: ${msg}`);
2234
- counters.errors++;
2235
- this.recordFolderError(accountId, folder.path);
2236
- if (counters.errors >= ERROR_BUDGET) break;
2237
- }
2238
- // CRITICAL: only prune when the batch actually completed.
2239
- // A thrown batch means NOTHING was received; treating
2240
- // absence as server-deletion lost 296 messages once.
2241
- if (batchSucceeded) for (const uid of uids) {
2242
- if (received.has(uid)) continue;
2243
- try {
2244
- this.unlinkBodyFile(accountId, uid, folderId).catch(() => {});
2245
- this.db.deleteMessage(accountId, uid);
2246
- counters.deleted++;
2247
- madeProgress = true;
2248
- } catch { /* ignore */ }
2249
- }
2250
- }
2251
- if (counters.errors >= ERROR_BUDGET) {
2252
- console.error(` [prefetch] ${accountId}: stopping after ${counters.errors} errors (${counters.totalFetched} cached, ${counters.deleted} pruned)`);
2253
- return;
2254
- }
2255
- }
2256
-
2257
- // Safety: zero progress this tick → bail rather than loop forever.
2258
- if (!madeProgress) break;
2259
- // Emit so the UI refreshes the open-circle → filled-teal indicator
2260
- // without waiting for the next sync cycle.
2261
- this.emit("folderCountsChanged", accountId, {});
2262
- }
2263
- if (counters.totalFetched > 0 || counters.deleted > 0) {
2264
- console.log(` [prefetch] ${accountId}: ${counters.totalFetched} bodies cached, ${counters.deleted} stale rows pruned (done)`);
2265
- this.emit("folderCountsChanged", accountId, {});
2266
- }
2267
- }
2268
-
2269
- /** Get the body store for direct access */
2270
- getBodyStore(): FileMessageStore {
2271
- return this.bodyStore;
2272
- }
2273
-
2274
- /** Bulk trash messages — local-first, single IMAP connection for all */
2275
- async trashMessages(accountId: string, messages: { uid: number; folderId: number }[]): Promise<void> {
2276
- if (messages.length === 0) return;
2277
- const trash = this.findFolder(accountId, "trash");
2278
-
2279
- // Local first — remove all from DB immediately
2280
- for (const msg of messages) {
2281
- this.unlinkBodyFile(accountId, msg.uid, msg.folderId).catch(() => {});
2282
- this.db.deleteMessage(accountId, msg.uid);
2283
- }
2284
- console.log(` Deleted ${messages.length} messages locally`);
2285
-
2286
- // Queue IMAP actions
2287
- for (const msg of messages) {
2288
- if (trash && trash.id !== msg.folderId) {
2289
- this.db.queueSyncAction(accountId, "move", msg.uid, msg.folderId, { targetFolderId: trash.id });
2290
- } else {
2291
- this.db.queueSyncAction(accountId, "delete", msg.uid, msg.folderId);
2292
- }
2293
- }
2294
-
2295
- // Recalc folder counts so the tree badge updates immediately instead
2296
- // of showing stale numbers until the next full sync.
2297
- const sourceFolderIds = new Set(messages.map(m => m.folderId));
2298
- for (const fid of sourceFolderIds) this.db.recalcFolderCounts(fid);
2299
- if (trash) this.db.recalcFolderCounts(trash.id);
2300
- this.emit("folderCountsChanged", accountId, {});
2301
-
2302
- // Process all queued actions in one IMAP session
2303
- this.debounceSyncActions(accountId);
2304
- }
2305
-
2306
- /** Bulk move messages — queues the IMAP action only. The service layer
2307
- * (MailxService.moveMessages) owns the local DB mutation via
2308
- * updateMessageFolder; this method used to ALSO deleteMessage here,
2309
- * which wiped the row the service just updated — the message vanished
2310
- * on the next reconcile and "spam folder empty" was the symptom. */
2311
- async moveMessages(accountId: string, messages: { uid: number; folderId: number }[], targetFolderId: number): Promise<void> {
2312
- if (messages.length === 0) return;
2313
- for (const msg of messages) {
2314
- this.db.queueSyncAction(accountId, "move", msg.uid, msg.folderId, { targetFolderId });
2315
- }
2316
- console.log(` [move] ${accountId}: queued IMAP MOVE for ${messages.length} message(s) → folder ${targetFolderId}`);
2317
- this.debounceSyncActions(accountId);
2318
- }
2319
-
2320
- /** Debounced sync actions — batches rapid local changes into one IMAP operation */
2321
- private syncActionTimers = new Map<string, ReturnType<typeof setTimeout>>();
2322
-
2323
- private debounceSyncActions(accountId: string): void {
2324
- const existing = this.syncActionTimers.get(accountId);
2325
- if (existing) clearTimeout(existing);
2326
- this.syncActionTimers.set(accountId, setTimeout(() => {
2327
- this.syncActionTimers.delete(accountId);
2328
- this.processSyncActions(accountId).catch(() => {});
2329
- }, 1000));
2330
- }
2331
-
2332
- /** Move a message to Trash (delete) — local-first, queues IMAP sync */
2333
- async trashMessage(accountId: string, folderId: number, uid: number): Promise<void> {
2334
- const trash = this.findFolder(accountId, "trash");
2335
-
2336
- // Local first — remove from DB immediately
2337
- this.unlinkBodyFile(accountId, uid, folderId).catch(() => {});
2338
- this.db.deleteMessage(accountId, uid);
2339
-
2340
- // Queue IMAP action + log the resolution so "I deleted a message and
2341
- // now it's in neither trash nor deleted" is diagnosable from the log.
2342
- if (trash && trash.id !== folderId) {
2343
- const trashFolder = this.db.getFolders(accountId).find(f => f.id === trash.id);
2344
- this.db.queueSyncAction(accountId, "move", uid, folderId, { targetFolderId: trash.id });
2345
- console.log(` [trash] ${accountId} UID ${uid}: queued MOVE to "${trashFolder?.path || trash.path}" (id=${trash.id}, specialUse=trash)`);
2346
- } else {
2347
- this.db.queueSyncAction(accountId, "delete", uid, folderId);
2348
- console.log(` [trash] ${accountId} UID ${uid}: queued EXPUNGE in folder ${folderId} (already in trash or no trash configured)`);
2349
- }
2350
-
2351
- // Debounced sync — batches multiple deletes into one IMAP session
2352
- this.debounceSyncActions(accountId);
2353
- }
2354
-
2355
- /** Move a message between folders — queues IMAP sync only. Service
2356
- * layer owns the local DB update (see MailxService.moveMessage). */
2357
- async moveMessage(accountId: string, uid: number, fromFolderId: number, toFolderId: number): Promise<void> {
2358
- this.db.queueSyncAction(accountId, "move", uid, fromFolderId, { targetFolderId: toFolderId });
2359
- console.log(` [move] ${accountId}: queued IMAP MOVE UID ${uid} folder ${fromFolderId} → ${toFolderId}`);
2360
- this.debounceSyncActions(accountId);
2361
- }
2362
-
2363
- /** Move message across accounts using iflow's moveMessageToServer */
2364
- async moveMessageCrossAccount(
2365
- fromAccountId: string, uid: number, fromFolderId: number,
2366
- toAccountId: string, toFolderId: number
2367
- ): Promise<void> {
2368
- const fromFolders = this.db.getFolders(fromAccountId);
2369
- const fromFolder = fromFolders.find(f => f.id === fromFolderId);
2370
- if (!fromFolder) throw new Error(`Source folder ${fromFolderId} not found`);
2371
-
2372
- const toFolders = this.db.getFolders(toAccountId);
2373
- const toFolder = toFolders.find(f => f.id === toFolderId);
2374
- if (!toFolder) throw new Error(`Target folder ${toFolderId} not found`);
2375
-
2376
- // Two accounts, two ops connections. Cross-account move is rare
2377
- // and requires both sockets to be live concurrently (we APPEND to
2378
- // target while still authenticated to source), so this can't fold
2379
- // into a single withConnection call.
2380
- await this.withConnection(fromAccountId, async (sourceClient) => {
2381
- await this.withConnection(toAccountId, async (targetClient) => {
2382
- const msg = await sourceClient.fetchMessageByUid(fromFolder.path, uid, { source: true });
2383
- if (!msg) throw new Error(`Message UID ${uid} not found in ${fromFolder.path}`);
2384
- await sourceClient.moveMessageToServer(msg, fromFolder.path, targetClient, toFolder.path);
2385
- this.db.deleteMessage(fromAccountId, uid);
2386
- console.log(` Cross-account move: ${fromAccountId}/${fromFolder.path} UID ${uid} → ${toAccountId}/${toFolder.path}`);
2387
- });
2388
- });
2389
- }
2390
-
2391
- /** Undelete — move from Trash back to original folder */
2392
- async undeleteMessage(accountId: string, uid: number, originalFolderId: number): Promise<void> {
2393
- const trash = this.findFolder(accountId, "trash");
2394
- if (!trash) throw new Error("No Trash folder found");
2395
- await this.moveMessage(accountId, uid, trash.id, originalFolderId);
2396
- }
2397
-
2398
- /** Update flags — local-first, queues IMAP sync */
2399
- async updateFlagsLocal(accountId: string, uid: number, folderId: number, flags: string[]): Promise<void> {
2400
- this.db.updateMessageFlags(accountId, uid, flags);
2401
- this.db.queueSyncAction(accountId, "flags", uid, folderId, { flags });
2402
- // User-visible pink-dot pending state stays until the action drains.
2403
- // The 30-second periodic tick was too slow — opening one message to
2404
- // auto-mark-as-read left it pink for half a minute. Same 1-second
2405
- // debounce as moves/deletes batches rapid flag churn without the
2406
- // visual lag.
2407
- this.debounceSyncActions(accountId);
2408
- }
2409
-
2410
- /** Process pending sync actions for an account */
2411
- async processSyncActions(accountId: string): Promise<void> {
2412
- const actions = this.db.getPendingSyncActions(accountId);
2413
- if (actions.length === 0) return;
2414
- const startCount = actions.length;
2415
-
2416
- const folders = this.db.getFolders(accountId);
2417
-
2418
- // Gmail path: push flag/label changes through the REST provider so
2419
- // they actually reach the server. Earlier this method always went
2420
- // through withConnection → IMAP, which silently no-op'd for Gmail
2421
- // accounts (REST-only, no IMAP connection) and left local-only stars
2422
- // that vanished on the next full sync.
2423
- if (this.isGmailAccount(accountId)) {
2424
- const api = this.getGmailProvider(accountId) as any;
2425
- try {
2426
- for (const action of actions) {
2427
- const folder = folders.find(f => f.id === action.folderId);
2428
- if (!folder) { this.db.completeSyncAction(action.id); continue; }
2429
- try {
2430
- if (action.action === "flags" && api.setFlags) {
2431
- await api.setFlags(folder.path, action.uid, action.flags || []);
2432
- console.log(` [api] ${accountId}: flags synced UID ${action.uid}`);
2433
- } else if ((action.action === "delete" || action.action === "trash") && api.trashMessage) {
2434
- await api.trashMessage(folder.path, action.uid);
2435
- console.log(` [api] ${accountId}: trashed UID ${action.uid} from ${folder.path}`);
2436
- } else if (action.action === "move" && api.moveMessage) {
2437
- const target = folders.find(f => f.id === action.targetFolderId);
2438
- if (!target) {
2439
- // Unreachable target — drop the action rather than loop.
2440
- console.error(` [api] ${accountId}: move target folder ${action.targetFolderId} missing — dropping UID ${action.uid}`);
2441
- this.db.completeSyncAction(action.id);
2442
- continue;
2443
- }
2444
- await api.moveMessage(folder.path, action.uid, target.path);
2445
- console.log(` [api] ${accountId}: moved UID ${action.uid} ${folder.path} → ${target.path}`);
2446
- } else {
2447
- // Unsupported action on Gmail. After 5 retries, drop it
2448
- // so stale rows don't mark messages pending-reconcile
2449
- // forever. Previously "continue" here caused the pink
2450
- // rows that shouldn't have been pink.
2451
- if (action.attempts >= 5) {
2452
- console.warn(` [api] ${accountId}: dropping stale action "${action.action}" UID ${action.uid} after ${action.attempts} attempts (unsupported on Gmail API path)`);
2453
- this.db.completeSyncAction(action.id);
2454
- } else {
2455
- this.db.failSyncAction(action.id, `unsupported Gmail action: ${action.action}`);
2456
- }
2457
- continue;
2458
- }
2459
- this.db.completeSyncAction(action.id);
2460
- } catch (e: any) {
2461
- console.error(` [api] ${accountId}: flag sync failed UID ${action.uid}: ${e.message}`);
2462
- this.db.failSyncAction(action.id, e.message);
2463
- if (action.attempts >= 5) this.db.completeSyncAction(action.id);
2464
- }
2465
- }
2466
- } finally { try { await api.close(); } catch { /* */ } }
2467
- // Nudge the UI so rows that were pending-reconcile re-query their
2468
- // pending state (pink dot was sticky until this event fired).
2469
- const remaining = this.db.getPendingSyncActions(accountId).length;
2470
- if (remaining < startCount) this.emit("folderCountsChanged", accountId, {});
2471
- return;
2472
- }
2473
-
2474
- await this.withConnection(accountId, async (client) => {
2475
- for (const action of actions) {
2476
- const folder = folders.find(f => f.id === action.folderId);
2477
- if (!folder) {
2478
- this.db.completeSyncAction(action.id);
2479
- continue;
2480
- }
2481
-
2482
- try {
2483
- switch (action.action) {
2484
- case "delete":
2485
- await client.deleteMessageByUid(folder.path, action.uid);
2486
- console.log(` [sync] Deleted UID ${action.uid} from ${folder.path}`);
2487
- break;
2488
-
2489
- case "move": {
2490
- const target = folders.find(f => f.id === action.targetFolderId);
2491
- if (!target) {
2492
- // Target folder gone — treat as permanent failure so the
2493
- // action doesn't loop forever. User must re-delete manually.
2494
- console.error(` [sync] Move target folder ${action.targetFolderId} missing — dropping action UID ${action.uid}`);
2495
- throw new Error(`move target folder ${action.targetFolderId} not found`);
2496
- }
2497
- const msg = await client.fetchMessageByUid(folder.path, action.uid, { source: false });
2498
- if (!msg) {
2499
- // Message no longer in source folder. Two real cases:
2500
- // (a) another client already moved/deleted it — nothing to do,
2501
- // just mark the action done.
2502
- // (b) the server is lying (transient SELECT miss) — the retry
2503
- // will pick it up. We can't tell these apart from one fetch,
2504
- // so log loud and treat as (a) after the first failure; the
2505
- // attempts counter handles (b) via the failSyncAction path.
2506
- console.log(` [sync] Move UID ${action.uid} in ${folder.path}: message gone (attempt ${action.attempts + 1}); dropping action`);
2507
- break;
2508
- }
2509
- await client.moveMessage(msg, folder.path, target.path);
2510
- console.log(` [sync] Moved UID ${action.uid}: ${folder.path} → ${target.path}`);
2511
- break;
2512
- }
2513
-
2514
- case "flags":
2515
- if (action.flags.length > 0) {
2516
- await client.addFlags(folder.path, action.uid, action.flags.filter(f => !f.startsWith("-")));
2517
- const toRemove = action.flags.filter(f => f.startsWith("-")).map(f => f.slice(1));
2518
- if (toRemove.length > 0) {
2519
- await client.removeFlags(folder.path, action.uid, toRemove);
2520
- }
2521
- console.log(` [sync] Updated flags UID ${action.uid}`);
2522
- }
2523
- break;
2524
-
2525
- case "append": {
2526
- if (action.rawMessage) {
2527
- await client.appendMessage(folder.path, action.rawMessage, action.flags);
2528
- console.log(` [sync] Appended to ${folder.path}`);
2529
- }
2530
- break;
2531
- }
2532
- }
2533
- this.db.completeSyncAction(action.id);
2534
- } catch (e: any) {
2535
- console.error(` [sync] Failed action ${action.action} UID ${action.uid}: ${e.message}`);
2536
- this.db.failSyncAction(action.id, e.message);
2537
- this.emit("syncActionFailed", accountId, action.action, action.uid, e.message);
2538
- if (action.attempts >= 5) {
2539
- console.error(` [sync] Giving up on action ${action.id} after 5 attempts`);
2540
- this.db.completeSyncAction(action.id);
2541
- this.emit("syncActionFailed", accountId, action.action, action.uid, `Gave up after 5 attempts: ${e.message}`);
2542
- }
2543
- }
2544
- }
2545
- });
2546
- // IMAP path: same nudge as the Gmail branch above. Any action that
2547
- // drained (successful or gave-up-after-5) decrements the pending
2548
- // count, which flips the pink dot off on the next re-query.
2549
- const remaining = this.db.getPendingSyncActions(accountId).length;
2550
- if (remaining < startCount) this.emit("folderCountsChanged", accountId, {});
2551
- }
2552
-
2553
- /** Find a folder by specialUse, case-insensitive */
2554
- private findFolder(accountId: string, specialUse: string): { id: number; path: string } | null {
2555
- const folders = this.db.getFolders(accountId);
2556
- return folders.find(f =>
2557
- f.specialUse === specialUse ||
2558
- f.path.toLowerCase() === specialUse.toLowerCase()
2559
- ) || null;
2560
- }
2561
-
2562
- /** Optimistic local-first Sent insert: write a row into the local DB's
2563
- * Sent folder the moment the user hits Send, so the list reflects it
2564
- * immediately instead of waiting for SMTP + IMAP-APPEND + syncFolder
2565
- * (five server round-trips against a Dovecot that caps at 20 conns).
2566
- *
2567
- * Uses a synthetic negative UID so it can't collide with a real APPENDUID
2568
- * (which is always positive). When the real sync eventually picks the
2569
- * message up in Sent with the server's UID, `db.upsertMessage` spots
2570
- * the Message-ID match and rebinds the existing row's UID — no duplicate.
2571
- * Negative UID also makes the row render pink (getMessages flags uid<0
2572
- * as pending) so the user sees it's not-yet-reconciled.
2573
- *
2574
- * Best-effort — any failure path (no Sent folder yet, parse error, store
2575
- * write error) is logged and swallowed; the send itself is unaffected. */
2576
- async insertOptimisticSentRow(
2577
- accountId: string,
2578
- envelope: {
2579
- messageId: string;
2580
- inReplyTo: string;
2581
- references: string[];
2582
- subject: string;
2583
- from: EmailAddress;
2584
- to: EmailAddress[];
2585
- cc: EmailAddress[];
2586
- bcc: EmailAddress[];
2587
- date: number;
2588
- },
2589
- rawMessage: string,
2590
- ): Promise<void> {
2591
- try {
2592
- const sent = this.findFolder(accountId, "sent");
2593
- if (!sent) {
2594
- console.log(` [sent-local] no Sent folder for ${accountId}; skipping optimistic row`);
2595
- return;
2596
- }
2597
- // Synthetic UID — negative ms timestamp is monotonic + won't
2598
- // collide with server UIDs. When the real APPENDUID returns via
2599
- // sync, upsertMessage's Message-ID rebind swaps this for the
2600
- // real positive value.
2601
- const synthUid = -Date.now();
2602
- const bodyPath = await this.bodyStore.putMessage(
2603
- accountId, sent.id, synthUid, Buffer.from(rawMessage, "utf-8"),
2604
- );
2605
- const parsed = await extractPreview(rawMessage);
2606
- this.db.upsertMessage({
2607
- accountId,
2608
- folderId: sent.id,
2609
- uid: synthUid,
2610
- messageId: envelope.messageId,
2611
- inReplyTo: envelope.inReplyTo,
2612
- references: envelope.references,
2613
- date: envelope.date,
2614
- subject: envelope.subject,
2615
- from: envelope.from,
2616
- to: envelope.to,
2617
- cc: envelope.cc,
2618
- flags: ["\\Seen"],
2619
- size: rawMessage.length,
2620
- hasAttachments: parsed.hasAttachments,
2621
- preview: parsed.preview,
2622
- bodyPath,
2623
- });
2624
- // Folder-tree badge refresh + message-list reload if the user
2625
- // is currently on Sent — same event the sync path emits.
2626
- this.db.recalcFolderCounts(sent.id);
2627
- this.emit("folderCountsChanged", { accountId, folderId: sent.id });
2628
- console.log(` [sent-local] wrote optimistic row in Sent (uid=${synthUid}) for ${accountId}: ${envelope.subject}`);
2629
- } catch (e: any) {
2630
- // Non-fatal — send continues, Sent folder just won't show the
2631
- // row until the real APPEND-then-sync cycle completes.
2632
- console.error(` [sent-local] optimistic insert failed: ${e?.message || e}`);
2633
- }
2634
- }
2635
-
2636
- /** Copy sent message to the Sent folder via IMAP APPEND */
2637
- async copyToSent(accountId: string, rawMessage: string | Buffer): Promise<void> {
2638
- const sent = this.findFolder(accountId, "sent");
2639
- if (!sent) {
2640
- console.error(` [sent] No Sent folder found for ${accountId}`);
2641
- return;
2642
- }
2643
-
2644
- await this.withConnection(accountId, async (client) => {
2645
- await client.appendMessage(sent.path, rawMessage, ["\\Seen"]);
2646
- console.log(` [sent] Copied to ${sent.path}`);
2647
- });
2648
- }
2649
-
2650
- /** Save a draft to the Drafts folder via IMAP APPEND.
2651
- * Returns the UID of the saved draft (for replacing on next save). */
2652
- async saveDraft(accountId: string, rawMessage: string | Buffer, previousDraftUid?: number, draftId?: string): Promise<number | null> {
2653
- const drafts = this.findFolder(accountId, "drafts");
2654
- if (!drafts) {
2655
- console.error(` [drafts] No Drafts folder found for ${accountId}`);
2656
- return null;
2657
- }
2658
-
2659
- return this.withConnection(accountId, async (client) => {
2660
- // Delete previous draft — try UID first (fast path), and ALWAYS also try
2661
- // searchByHeader(X-Mailx-Draft-ID) as a safety net. Running both catches
2662
- // orphans from a crash-mid-save or a UID delete that failed silently.
2663
- if (previousDraftUid) {
2664
- try {
2665
- await client.deleteMessageByUid(drafts.path, previousDraftUid);
2666
- } catch (e: any) {
2667
- console.error(` [drafts] Delete prev UID ${previousDraftUid} failed: ${e.message}`);
2668
- }
2669
- }
2670
- if (draftId) {
2671
- try {
2672
- const uids = await client.searchByHeader(drafts.path, "X-Mailx-Draft-ID", draftId);
2673
- for (const uid of uids) {
2674
- try { await client.deleteMessageByUid(drafts.path, uid); } catch { /* next */ }
2675
- }
2676
- if (uids.length > 0) console.log(` [drafts] Deleted ${uids.length} stale draft(s) by ID ${draftId}`);
2677
- } catch (e: any) {
2678
- console.error(` [drafts] searchByHeader for ${draftId} failed: ${e.message}`);
2679
- }
2680
- }
2681
-
2682
- // Append new draft. If the server returns [TRYCREATE] (RFC 3501 §7.1),
2683
- // the folder doesn't exist on the server even though mailx's DB has
2684
- // it. Create it and retry once.
2685
- let result: any;
2686
- try {
2687
- result = await client.appendMessage(drafts.path, rawMessage, ["\\Draft", "\\Seen"]);
2688
- } catch (e: any) {
2689
- const msg = String(e?.message || e);
2690
- if (/TRYCREATE/i.test(msg)) {
2691
- console.log(` [drafts] APPEND got TRYCREATE for "${drafts.path}" — creating folder and retrying`);
2692
- try { await client.createmailbox(drafts.path); }
2693
- catch (ce: any) {
2694
- if (!/already exists/i.test(String(ce?.message || ""))) {
2695
- console.error(` [drafts] Folder create failed for "${drafts.path}": ${ce.message}`);
2696
- }
2697
- }
2698
- result = await client.appendMessage(drafts.path, rawMessage, ["\\Draft", "\\Seen"]);
2699
- } else {
2700
- throw e;
2701
- }
2702
- }
2703
- const uid: number | null = typeof result === "number" ? result : (result as any)?.uid || null;
2704
- return uid;
2705
- });
2706
- }
2707
-
2708
- /** Delete a draft (or all drafts with a stable X-Mailx-Draft-ID) after successful send.
2709
- * Tries the specific UID first, then falls back to searchByHeader so orphaned copies
2710
- * from earlier failed autosaves are cleaned up at the same time. */
2711
- async deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void> {
2712
- const drafts = this.findFolder(accountId, "drafts");
2713
- if (!drafts) return;
2714
- if (!draftUid && !draftId) return;
2715
-
2716
- await this.withConnection(accountId, async (client) => {
2717
- if (draftUid) {
2718
- try {
2719
- await client.deleteMessageByUid(drafts.path, draftUid);
2720
- console.log(` [drafts] Deleted draft UID ${draftUid}`);
2721
- } catch (e: any) {
2722
- console.error(` [drafts] Delete by UID ${draftUid} failed: ${e.message}`);
2723
- }
2724
- }
2725
- if (draftId) {
2726
- try {
2727
- const uids = await client.searchByHeader(drafts.path, "X-Mailx-Draft-ID", draftId);
2728
- for (const uid of uids) {
2729
- try { await client.deleteMessageByUid(drafts.path, uid); } catch { /* next */ }
2730
- }
2731
- if (uids.length > 0) console.log(` [drafts] Deleted ${uids.length} draft(s) by ID ${draftId}`);
2732
- } catch (e: any) {
2733
- console.error(` [drafts] searchByHeader for ${draftId} failed: ${e.message}`);
2734
- }
2735
- }
2736
- });
2737
- }
2738
-
2739
- /** Queue outgoing message locally — never fails, worker handles IMAP+SMTP.
2740
- * Single path: write `~/.mailx/outbox/<acct>/*.ltr` synchronously, then
2741
- * kick processLocalQueue. The file IS the queue — durable across crashes,
2742
- * visible in the filesystem, consumed by the existing outbox worker that
2743
- * handles both IMAP-APPEND (non-Gmail) and direct SMTP (Gmail). The old
2744
- * sync_actions "send" branch was removed because it duplicated the same
2745
- * work and risked double-send when both paths fired on the same message. */
2746
- queueOutgoingLocal(accountId: string, rawMessage: string): void {
2747
- // Loud logging so a "vanished message" report is diagnosable from the log alone.
2748
- // ALWAYS leave a backup copy in sending/<acct>/attempted/ first — unconditionally,
2749
- // before the outbox write. The outbox .ltr may be claimed/consumed by the worker
2750
- // within milliseconds; this copy survives regardless of SMTP success, IMAP
2751
- // append, worker crash, or any other downstream failure. User asked for this
2752
- // as a fallback because "there isn't even the backup copy in sent".
2753
- this.saveSendingCopy(accountId, rawMessage, "attempted");
2754
- const outboxDir = path.join(getConfigDir(), "outbox", accountId);
2755
- try {
2756
- fs.mkdirSync(outboxDir, { recursive: true });
2757
- } catch (e: any) {
2758
- console.error(` [outbox] FAIL mkdirSync ${outboxDir}: ${e?.message || e}`);
2759
- throw new Error(`Cannot create outbox dir ${outboxDir}: ${e?.message || e}`);
2760
- }
2761
- const now = new Date();
2762
- const pad2 = (n: number) => String(n).padStart(2, "0");
2763
- const filename = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}-${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}.ltr`;
2764
- const filePath = path.join(outboxDir, filename);
2765
- try {
2766
- fs.writeFileSync(filePath, rawMessage);
2767
- } catch (e: any) {
2768
- console.error(` [outbox] FAIL writeFileSync ${filePath}: ${e?.message || e}`);
2769
- throw new Error(`Cannot write outbox file ${filePath}: ${e?.message || e}`);
2770
- }
2771
- // Immediate readback verification — if this DOESN'T print, the user's
2772
- // "neither in outbox nor file system" report has a real explanation.
2773
- const written = fs.existsSync(filePath);
2774
- const size = written ? fs.statSync(filePath).size : 0;
2775
- console.log(` [outbox] WROTE ${filePath} (${size} bytes, exists=${written})`);
2776
- this.emitOutboxStatus();
2777
- // CRITICAL: defer to next tick. processLocalQueue runs ~30+ lines
2778
- // of synchronous fs work BEFORE its first await — calling it inline
2779
- // blocks the IPC ack on all that work.
2780
- setImmediate(() => {
2781
- this.processLocalQueue(accountId)
2782
- .catch((e: any) => console.error(` [outbox] processLocalQueue error: ${e?.message || e}`))
2783
- .finally(() => this.emitOutboxStatus());
2784
- });
2785
- }
2786
-
2787
- /** Scan the local outbox + sending/queued dirs and return counts + age.
2788
- * Cheap — a handful of readdir + head-read per file. Called by both the
2789
- * polling UI (status bar) and emitted as an event after queue mutations. */
2790
- getOutboxStatus(): OutboxStatus {
2791
- const configDir = getConfigDir();
2792
- const perAccount: Record<string, { total: number; retrying: number; claimed: number }> = {};
2793
- let total = 0;
2794
- let retrying = 0;
2795
- let claimed = 0;
2796
- let oldestMs = 0;
2797
- let maxAttempts = 0;
2798
- const now = Date.now();
2799
- const scan = (accountId: string, dir: string) => {
2800
- if (!fs.existsSync(dir)) return;
2801
- for (const f of fs.readdirSync(dir)) {
2802
- const isClaim = /\.sending-[^-]+-\d+$/.test(f);
2803
- const isActive = isClaim || f.endsWith(".ltr") || f.endsWith(".eml");
2804
- if (!isActive) continue;
2805
- total++;
2806
- const acctSlot = perAccount[accountId] ||= { total: 0, retrying: 0, claimed: 0 };
2807
- acctSlot.total++;
2808
- if (isClaim) { claimed++; acctSlot.claimed++; }
2809
- const fp = path.join(dir, f);
2810
- try {
2811
- const st = fs.statSync(fp);
2812
- const age = now - st.mtimeMs;
2813
- if (age > oldestMs) oldestMs = age;
2814
- // Only read header region to count retry attempts — tiny I/O.
2815
- const fd = fs.openSync(fp, "r");
2816
- try {
2817
- const buf = Buffer.alloc(4096);
2818
- const n = fs.readSync(fd, buf, 0, 4096, 0);
2819
- const head = buf.slice(0, n).toString("utf-8");
2820
- const info = parseRetryInfo(head);
2821
- if (info.attemptCount > 0) { retrying++; acctSlot.retrying++; }
2822
- if (info.attemptCount > maxAttempts) maxAttempts = info.attemptCount;
2823
- } finally { fs.closeSync(fd); }
2824
- } catch { /* ignore per-file errors */ }
2825
- }
2826
- };
2827
- const outboxRoot = path.join(configDir, "outbox");
2828
- const sendingRoot = path.join(configDir, "sending");
2829
- try {
2830
- if (fs.existsSync(outboxRoot)) {
2831
- for (const acct of fs.readdirSync(outboxRoot)) scan(acct, path.join(outboxRoot, acct));
2832
- }
2833
- if (fs.existsSync(sendingRoot)) {
2834
- for (const acct of fs.readdirSync(sendingRoot)) {
2835
- scan(acct, path.join(sendingRoot, acct, "queued"));
2836
- }
2837
- }
2838
- } catch { /* */ }
2839
- return {
2840
- total, retrying, claimed,
2841
- oldestAgeSec: Math.floor(oldestMs / 1000),
2842
- maxAttempts,
2843
- perAccount,
2844
- };
2845
- }
2846
-
2847
- /** Emit outboxStatus now. Call after any queue mutation. */
2848
- private emitOutboxStatus(): void {
2849
- try { this.emit("outboxStatus", this.getOutboxStatus()); } catch { /* */ }
2850
- }
2851
-
2852
- /** Guard against concurrent processSendActions for the same account */
2853
- private sendingAccounts = new Set<string>();
2854
-
2855
- /** Process local send actions — APPEND to Outbox, which the outbox worker then sends */
2856
- private async processSendActions(accountId: string): Promise<void> {
2857
- if (this.sendingAccounts.has(accountId)) return; // already processing
2858
- this.sendingAccounts.add(accountId);
2859
- try { await this._processSendActions(accountId); }
2860
- finally { this.sendingAccounts.delete(accountId); }
2861
- }
2862
-
2863
- private async _processSendActions(accountId: string): Promise<void> {
2864
- const actions = this.db.getPendingSyncActions(accountId)
2865
- .filter(a => a.action === "send");
2866
- if (actions.length === 0) return;
2867
-
2868
- for (const action of actions) {
2869
- if (!action.rawMessage) {
2870
- this.db.completeSyncAction(action.id);
2871
- continue;
2872
- }
2873
- // Abandon after 10 failed attempts — don't retry forever
2874
- if (action.attempts >= 10) {
2875
- console.error(` [outbox] Abandoning send action ${action.id} after ${action.attempts} attempts: ${action.rawMessage?.substring(0, 100)}`);
2876
- this.db.completeSyncAction(action.id);
2877
- this.emit("accountError", accountId, `Send permanently failed after ${action.attempts} attempts`, "Message removed from queue", false);
2878
- continue;
2879
- }
2880
- try {
2881
- await this.queueOutgoing(accountId, action.rawMessage);
2882
- this.db.completeSyncAction(action.id);
2883
- } catch (e: any) {
2884
- console.error(` [outbox] Local→IMAP failed (attempt ${action.attempts + 1}): ${e.message}`);
2885
- this.db.failSyncAction(action.id, e.message);
2886
- }
2887
- }
2888
- }
2889
-
2890
- // ── Outbox ──
2891
-
2892
- private outboxInterval: ReturnType<typeof setInterval> | null = null;
2893
- private readonly hostname = os.hostname();
2894
-
2895
- /** Ensure Outbox folder exists, create if needed */
2896
- private async ensureOutbox(accountId: string): Promise<string> {
2897
- let outbox = this.findFolder(accountId, "outbox");
2898
- if (outbox) return outbox.path;
2899
-
2900
- // Look for existing folder named Outbox (case-insensitive)
2901
- const folders = this.db.getFolders(accountId);
2902
- const existing = folders.find(f => f.path.toLowerCase() === "outbox");
2903
- if (existing) return existing.path;
2904
-
2905
- try {
2906
- await this.withConnection(accountId, async (client) => {
2907
- await client.createmailbox("Outbox");
2908
- await this.syncFolders(accountId, client);
2909
- });
2910
- } catch (e: any) {
2911
- // Might already exist — benign
2912
- if (!e.message?.includes("already exists")) throw e;
2913
- }
2914
-
2915
- outbox = this.findFolder(accountId, "outbox");
2916
- return outbox?.path || "Outbox";
2917
- }
2918
-
2919
- /** Save a copy of outgoing mail — label is a subdirectory (editing/queued/sent) */
2920
- private saveSendingCopy(accountId: string, rawMessage: string | Buffer, label: string): void {
2921
- try {
2922
- const dir = path.join(getConfigDir(), "sending", accountId, label);
2923
- fs.mkdirSync(dir, { recursive: true });
2924
- const now = new Date();
2925
- const pad2 = (n: number) => String(n).padStart(2, "0");
2926
- const ts = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`;
2927
- fs.writeFileSync(path.join(dir, `${ts}.eml`), rawMessage);
2928
- console.log(` [sending] ${label}/${ts}.eml`);
2929
- } catch (e: any) {
2930
- console.error(` [sending] Failed to save copy: ${e.message}`);
2931
- }
2932
- }
2933
-
2934
- /** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
2935
- async queueOutgoing(accountId: string, rawMessage: string | Buffer): Promise<void> {
2936
- // IMPORTANT: do NOT save a "debug copy" to sending/<acct>/queued/ here.
2937
- // processLocalQueue also scans sending/<acct>/queued/, so writing there
2938
- // on every send caused the same message to be re-APPENDed to the IMAP
2939
- // Outbox on the next outbox tick — resulting in a duplicate send.
2940
- // The only two legitimate queue locations are:
2941
- // - IMAP Outbox (primary, populated by APPEND below)
2942
- // - ~/.mailx/outbox/<acct>/*.ltr (fallback when IMAP is unreachable)
2943
- try {
2944
- const outboxPath = await this.ensureOutbox(accountId);
2945
- await this.withConnection(accountId, async (client) => {
2946
- await client.appendMessage(outboxPath, rawMessage, ["\\Seen"]);
2947
- console.log(` [outbox] Queued message in ${outboxPath}`);
2948
- });
2949
- const outboxFolder = this.findFolder(accountId, "outbox");
2950
- if (outboxFolder) {
2951
- this.syncFolder(accountId, outboxFolder.id).catch(() => {});
2952
- }
2953
- return;
2954
- } catch (e: any) {
2955
- console.error(` [outbox] IMAP queue failed: ${e.message} — saving locally`);
2956
- }
2957
-
2958
- // Fallback: save to local file queue (processLocalQueue picks these up)
2959
- const localQueue = path.join(getConfigDir(), "outbox", accountId);
2960
- fs.mkdirSync(localQueue, { recursive: true });
2961
- const now = new Date();
2962
- const pad2 = (n: number) => String(n).padStart(2, "0");
2963
- const filename = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}-${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}.ltr`;
2964
- fs.writeFileSync(path.join(localQueue, filename), rawMessage);
2965
- console.log(` [outbox] Saved locally: ${filename}`);
2966
- }
2967
-
2968
- /** Process local file queue — scan both outbox/ (IMAP-unreachable fallback)
2969
- * and sending/<acct>/queued/ (manual drop-in, crash recovery). The earlier
2970
- * double-send bug was caused by queueOutgoing() WRITING a debug copy to
2971
- * sending/queued/ on every send — that write is gone now, so scanning the
2972
- * directory is safe again. Any legitimate files that land there (crash
2973
- * recovery, manual drop) will get sent. */
2974
- private async processLocalQueue(accountId: string): Promise<void> {
2975
- const outboxDir = path.join(getConfigDir(), "outbox", accountId);
2976
- const queuedDir = path.join(getConfigDir(), "sending", accountId, "queued");
2977
-
2978
- // Recovery sweep: any *.sending-<host>-<pid> on THIS host whose PID is
2979
- // dead (process crashed mid-send) gets unclaimed so the next tick can
2980
- // retry. Foreign hosts are left alone — we have no way to know if their
2981
- // process is alive. Cross-host stale recovery is the IMAP-folder path's
2982
- // job (sweeper looks at server-side claim flags, not local files).
2983
- for (const dir of [outboxDir, queuedDir]) {
2984
- if (!fs.existsSync(dir)) continue;
2985
- for (const f of fs.readdirSync(dir)) {
2986
- const m = f.match(/^(.+)\.sending-([^-]+)-(\d+)$/);
2987
- if (!m) continue;
2988
- const [, original, host, pidStr] = m;
2989
- if (host !== this.hostname) continue;
2990
- const pid = parseInt(pidStr);
2991
- let alive = false;
2992
- try { process.kill(pid, 0); alive = true; } catch { /* dead */ }
2993
- if (alive) continue; // live claim — owner (sibling or self) still has it
2994
- try {
2995
- fs.renameSync(path.join(dir, f), path.join(dir, original));
2996
- console.log(` [outbox] Recovered stale claim ${f} → ${original}`);
2997
- } catch { /* ignore */ }
2998
- }
2999
- }
3000
-
3001
- const filesToSend: { dir: string; file: string }[] = [];
3002
- for (const dir of [outboxDir, queuedDir]) {
3003
- if (!fs.existsSync(dir)) continue;
3004
- for (const file of fs.readdirSync(dir).filter(f => f.endsWith(".ltr") || f.endsWith(".eml"))) {
3005
- filesToSend.push({ dir, file });
3006
- }
3007
- }
3008
- if (filesToSend.length === 0) return;
3009
-
3010
- // Gmail/API accounts: send directly via SMTP from local queue (no IMAP outbox)
3011
- const sentDir = path.join(getConfigDir(), "sending", accountId, "sent");
3012
- fs.mkdirSync(sentDir, { recursive: true });
3013
-
3014
- if (this.isGmailAccount(accountId)) {
3015
- const nowMs = Date.now();
3016
- for (const { dir, file } of filesToSend) {
3017
- const filePath = path.join(dir, file);
3018
-
3019
- // Atomic claim: rename to <file>.sending-<host>-<pid> so a sibling
3020
- // process scanning the same dir can't grab the same .ltr. Filesystem
3021
- // rename is atomic; loser sees ENOENT and skips. Without this, two
3022
- // mailx instances on one machine (or two ticks within one process)
3023
- // could both pass the Message-ID dedup check and both call SMTP.
3024
- const claimSuffix = `.sending-${this.hostname}-${process.pid}`;
3025
- const claimedPath = filePath + claimSuffix;
3026
- try {
3027
- fs.renameSync(filePath, claimedPath);
3028
- } catch (e: any) {
3029
- if (e.code === "ENOENT") continue; // another process won
3030
- throw e;
3031
- }
3032
-
3033
- let raw = fs.readFileSync(claimedPath, "utf-8");
3034
-
3035
- // Per-message rate limit: if a prior attempt set X-Mailx-Retry-After
3036
- // in the future, skip this file for now. Minimizes the race where the
3037
- // SMTP server actually accepted DATA but we lost the ack and would
3038
- // otherwise retry immediately on the next 10s tick.
3039
- const retryInfo = parseRetryInfo(raw);
3040
- if (retryInfo.nextAttemptAt > nowMs) {
3041
- // Release claim — let next tick reconsider
3042
- try { fs.renameSync(claimedPath, filePath); } catch { /* ignore */ }
3043
- continue;
3044
- }
3045
-
3046
- // Record this attempt: strip internal X-Mailx-Retry-After, append a new
3047
- // X-Mailx-Retry: N <ISO-timestamp> line to the headers. The updated file
3048
- // is written back *before* the send so a crash mid-send doesn't lose state.
3049
- const attempt = retryInfo.attemptCount + 1;
3050
- raw = stripHeaderField(raw, "X-Mailx-Retry-After");
3051
- raw = insertHeaderBeforeBody(raw, `X-Mailx-Retry: ${attempt} ${new Date().toISOString()}`);
3052
- fs.writeFileSync(claimedPath, raw, "utf-8");
3053
-
3054
- try {
3055
- await this.sendRawViaSMTP(accountId, raw);
3056
- fs.renameSync(claimedPath, path.join(sentDir, file));
3057
- console.log(` [outbox] Sent ${file} via SMTP → sent/ (attempt ${attempt})`);
3058
- } catch (e: any) {
3059
- // Persist a next-attempt timestamp and release the claim so the
3060
- // file is visible to the scan loop again.
3061
- const nextAt = new Date(nowMs + OUTBOX_RETRY_DELAY_MS).toISOString();
3062
- const withDelay = insertHeaderBeforeBody(raw, `X-Mailx-Retry-After: ${nextAt}`);
3063
- fs.writeFileSync(claimedPath, withDelay, "utf-8");
3064
- try { fs.renameSync(claimedPath, filePath); } catch { /* file stays claimed; recovery sweeper will handle */ }
3065
- console.error(` [outbox] Send failed for ${file} (attempt ${attempt}, retry after ${nextAt}): ${e.message}`);
3066
- }
3067
- }
3068
- return;
3069
- }
3070
-
3071
- // IMAP accounts: append to IMAP Outbox for multi-machine interlock.
3072
- //
3073
- // Atomic claim (same pattern as the Gmail path above): rename the file
3074
- // to <file>.sending-<host>-<pid> BEFORE reading it, so two concurrent
3075
- // mailx instances scanning the same dir can't both APPEND the same
3076
- // message to IMAP Outbox and end up with a duplicate. Filesystem rename
3077
- // is atomic; the loser sees ENOENT and skips. On APPEND success, move
3078
- // the claimed file to sending/sent/; on APPEND failure, release the
3079
- // claim so the recovery sweeper picks it up next tick.
3080
- try {
3081
- const outboxPath = await this.ensureOutbox(accountId);
3082
- const client = await this.createClientWithLimit(accountId);
3083
- try {
3084
- for (const { dir, file } of filesToSend) {
3085
- const filePath = path.join(dir, file);
3086
- const claimSuffix = `.sending-${this.hostname}-${process.pid}`;
3087
- const claimedPath = filePath + claimSuffix;
3088
- try {
3089
- fs.renameSync(filePath, claimedPath);
3090
- } catch (e: any) {
3091
- if (e.code === "ENOENT") continue; // sibling claimed first
3092
- throw e;
3093
- }
3094
- try {
3095
- const raw = fs.readFileSync(claimedPath, "utf-8");
3096
- await client.appendMessage(outboxPath, raw, ["\\Seen"]);
3097
- fs.renameSync(claimedPath, path.join(sentDir, file));
3098
- console.log(` [outbox] Moved ${file} to IMAP Outbox → sent/`);
3099
- } catch (e: any) {
3100
- // APPEND failed (connection dropped mid-send, server
3101
- // busy, etc.) — release the claim so next tick can
3102
- // retry. Don't swallow: rethrow after release so the
3103
- // outer catch ("IMAP still unreachable") bails out of
3104
- // the remaining files too — whatever broke will break
3105
- // the next file the same way.
3106
- try { fs.renameSync(claimedPath, filePath); } catch { /* recovery sweeper will handle */ }
3107
- throw e;
3108
- }
3109
- }
3110
- } finally {
3111
- try { await client.logout(); } catch { /* ignore */ }
3112
- }
3113
- } catch {
3114
- // IMAP still unreachable — leave files for next attempt
3115
- }
3116
- }
3117
-
3118
- /** Send a raw RFC 2822 message via SMTP for a given account.
3119
- * Uses @bobfrankston/smtp-direct with the same TransportFactory as IMAP —
3120
- * same TCP byte-stream interface, no nodemailer dependency. */
3121
- private async sendRawViaSMTP(accountId: string, raw: string): Promise<void> {
3122
- const settings = loadSettings();
3123
- const account = settings.accounts.find(a => a.id === accountId);
3124
- if (!account?.smtp) throw new Error(`No SMTP config for ${accountId}`);
3125
-
3126
- const smtpPort = account.smtp.port || SMTP_PORT_STARTTLS;
3127
- const smtpHost = account.smtp.host || account.imap?.host;
3128
- if (!smtpHost) throw new Error(`No SMTP host for ${accountId}`);
3129
-
3130
- // SMTP auth: explicit SMTP creds, fall back to IMAP creds for password auth.
3131
- const smtpAuthType = account.smtp.auth || (account.imap?.password ? "password" : undefined);
3132
- const smtpUser = account.smtp.user || account.imap?.user || account.email;
3133
- let auth: import("@bobfrankston/smtp-direct").SmtpAuth | undefined;
3134
- if (smtpAuthType === "password") {
3135
- const pass = account.smtp.password || account.imap?.password;
3136
- if (!pass) throw new Error("SMTP password not configured");
3137
- auth = { method: "PLAIN", user: smtpUser, pass };
3138
- } else if (smtpAuthType === "oauth2") {
3139
- const token = await this.getOAuthToken(accountId);
3140
- if (!token) throw new Error("OAuth token not available");
3141
- auth = { method: "XOAUTH2", user: smtpUser, token };
3142
- }
3143
-
3144
- const parseAddrs = (s: string) => s.match(/[\w.+-]+@[\w.-]+/g) || [];
3145
- const toMatch = raw.match(/^To:\s*(.+)$/mi);
3146
- const ccMatch = raw.match(/^Cc:\s*(.+)$/mi);
3147
- const bccMatch = raw.match(/^Bcc:\s*(.+)$/mi);
3148
- const fromMatch = raw.match(/^From:\s*(.+)$/mi);
3149
- const subjectMatch = raw.match(/^Subject:\s*(.+)$/mi);
3150
- const messageIdMatch = raw.match(/^Message-ID:\s*(<[^>]+>)/mi);
3151
- const recipients = [
3152
- ...(toMatch ? parseAddrs(toMatch[1]) : []),
3153
- ...(ccMatch ? parseAddrs(ccMatch[1]) : []),
3154
- ...(bccMatch ? parseAddrs(bccMatch[1]) : []),
3155
- ];
3156
- const sender = fromMatch ? (parseAddrs(fromMatch[1])[0] || account.email) : account.email;
3157
- if (recipients.length === 0) throw new Error("No recipients");
3158
-
3159
- // Dedup: skip if this Message-ID has already been sent. Prevents the
3160
- // outbox from re-sending the same file across crash/restart cycles.
3161
- const messageId = messageIdMatch ? messageIdMatch[1] : "";
3162
- if (messageId && this.db.hasSentMessage(messageId)) {
3163
- console.log(` [smtp] ${accountId}: SKIP ${messageId} — already in sent_log`);
3164
- return;
3165
- }
3166
-
3167
- const rawToSend = raw.replace(/^Bcc:.*\r?\n/mi, "");
3168
- this.saveSendingCopy(accountId, rawToSend, "sent");
3169
-
3170
- const smtp = new SmtpClient({
3171
- host: smtpHost,
3172
- port: smtpPort,
3173
- secure: smtpPort === SMTP_PORT_IMPLICIT_TLS,
3174
- auth,
3175
- localname: os.hostname(),
3176
- }, this.transportFactory);
3177
- try {
3178
- await smtp.connect();
3179
- const result = await smtp.sendMail({ from: sender, to: recipients }, rawToSend);
3180
- if (result.rejected.length > 0) {
3181
- console.log(` [smtp] ${accountId}: ${result.rejected.length} recipient(s) rejected: ${result.rejected.map(r => `${r.address} (${r.code})`).join(", ")}`);
3182
- }
3183
- } finally {
3184
- try { await smtp.quit(); } catch { /* ignore */ }
3185
- }
3186
-
3187
- if (messageId) {
3188
- this.db.recordSent(messageId, accountId, subjectMatch?.[1]?.trim() || "", recipients);
3189
- }
3190
- console.log(` [smtp] ${accountId}: sent to ${recipients.join(", ")}`);
3191
- }
3192
-
3193
- /** Process Outbox — send pending messages with flag-based interlock.
3194
- * Each per-UID step is its own withConnection({slow}) call so the queue
3195
- * yields between messages: a click-to-view body in the middle of a
3196
- * 10-message outbox drain doesn't wait for all 10 to finish. */
3197
- async processOutbox(accountId: string): Promise<void> {
3198
- const outboxFolder = this.findFolder(accountId, "outbox");
3199
- if (!outboxFolder) return;
3200
-
3201
- // Gmail: skip IMAP outbox check — sending handled by processLocalQueue which sends directly via SMTP
3202
- if (this.isGmailAccount(accountId)) return;
3203
-
3204
- const settings = loadSettings();
3205
- const account = settings.accounts.find(a => a.id === accountId);
3206
- if (!account) return;
3207
-
3208
- // List UIDs first — quick command, fast lane.
3209
- const uids = await this.withConnection(accountId, (client) =>
3210
- client.getUids(outboxFolder.path)
3211
- ) as number[];
3212
- if (uids.length === 0) return;
3213
-
3214
- const STALE_CLAIM_MS = 3600_000; // 1 hour — longer than any reasonable SMTP send
3215
- const nowSec = Math.floor(Date.now() / 1000);
3216
- const sendingFlag = `$Sending-${this.hostname}-${nowSec}`;
3217
- const sentFolder = this.findFolder(accountId, "sent");
3218
-
3219
- for (const uid of uids) {
3220
- // Each iteration is one slow-lane turn — fast-lane work can run
3221
- // between iterations, so a body click during a long outbox drain
3222
- // gets serviced promptly.
3223
- const result = await this.withConnection(accountId, async (client) => {
3224
- const flags = await client.getFlags(outboxFolder.path, uid);
3225
-
3226
- // Sweep stale claims. $Sending-<host>-<sec> with old timestamp,
3227
- // or legacy $Sending-<host> without timestamp (treated as
3228
- // stale; if the real owner is alive it'll re-claim next tick).
3229
- const claimFlags = flags.filter((f: string) => f.startsWith("$Sending"));
3230
- for (const cf of claimFlags) {
3231
- const m = cf.match(/^\$Sending-(.+?)(?:-(\d+))?$/);
3232
- if (!m) continue;
3233
- const tsSec = m[2] ? parseInt(m[2]) : 0;
3234
- const ageSec = nowSec - tsSec;
3235
- if (ageSec * 1000 > STALE_CLAIM_MS) {
3236
- try {
3237
- await client.removeFlags(outboxFolder.path, uid, [cf]);
3238
- console.log(` [outbox] Swept stale claim ${cf} on UID ${uid} (age ${Math.round(ageSec / 60)}m)`);
3239
- } catch { /* ignore */ }
3240
- }
3241
- }
3242
-
3243
- const flagsNow = (claimFlags.length > 0)
3244
- ? await client.getFlags(outboxFolder.path, uid)
3245
- : flags;
3246
- if (flagsNow.some((f: string) => f.startsWith("$Sending"))) return { skip: true };
3247
- if (flagsNow.includes("$PermanentFailure")) return { skip: true };
3248
- if (flagsNow.includes("$Failed")) {
3249
- await client.removeFlags(outboxFolder.path, uid, ["$Failed"]);
3250
- }
3251
-
3252
- await client.addFlags(outboxFolder.path, uid, [sendingFlag]);
3253
-
3254
- // TOCTOU re-check: if two devices addFlags concurrently both
3255
- // see ≥2 sending flags. Fail-safe: both back off, next tick
3256
- // one wins.
3257
- const flagsAfter = await client.getFlags(outboxFolder.path, uid);
3258
- const sendingFlags = flagsAfter.filter((f: string) => f.startsWith("$Sending"));
3259
- if (sendingFlags.length > 1 || (sendingFlags.length === 1 && sendingFlags[0] !== sendingFlag)) {
3260
- await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
3261
- return { skip: true };
3262
- }
3263
-
3264
- const msg = await client.fetchMessageByUid(outboxFolder.path, uid, { source: true });
3265
- if (!msg?.source) {
3266
- await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
3267
- return { skip: true };
3268
- }
3269
- return { source: msg.source };
3270
- }, { slow: true });
3271
-
3272
- if ((result as any).skip) continue;
3273
- const source = (result as any).source as string;
3274
-
3275
- // SMTP send is its own connection — not an IMAP op, doesn't go
3276
- // through withConnection.
3277
- try {
3278
- await this.sendRawViaSMTP(accountId, source);
3279
- console.log(` [outbox] Sent UID ${uid}`);
3280
-
3281
- // Delete from Outbox + copy to Sent. Done in two separate
3282
- // withConnection calls so other work can interleave.
3283
- await this.withConnection(accountId, async (client) => {
3284
- // Delete FIRST to prevent double-send if Sent-copy fails.
3285
- await client.deleteMessageByUid(outboxFolder.path, uid);
3286
- }, { slow: true });
3287
- if (sentFolder) {
3288
- try {
3289
- await this.withConnection(accountId, async (client) => {
3290
- await client.appendMessage(sentFolder.path, source, ["\\Seen"]);
3291
- }, { slow: true });
3292
- this.syncFolder(accountId, sentFolder.id).catch(() => {});
3293
- } catch (sentErr: any) {
3294
- console.error(` [outbox] Failed to copy to Sent: ${sentErr.message} — message was sent successfully`);
3295
- }
3296
- this.syncFolder(accountId, outboxFolder.id).catch(() => {});
3297
- }
3298
- } catch (e: any) {
3299
- const errMsg = e.message || String(e);
3300
- console.error(` [outbox] Send failed UID ${uid}: ${errMsg}`);
3301
- try {
3302
- await this.withConnection(accountId, async (client) => {
3303
- await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
3304
- await client.addFlags(outboxFolder.path, uid, ["$Failed"]);
3305
- }, { slow: true });
3306
- } catch { /* best-effort */ }
3307
- this.emit("accountError", accountId, `Send failed: ${errMsg}`, "Message kept in Outbox", false);
3308
- if (/auth|login|credential|invalid/i.test(errMsg)) {
3309
- this.outboxBackoff.set(accountId, Date.now() + 3600000); // 1 hour
3310
- console.error(` [outbox] Auth failure for ${accountId} — outbox paused for 1 hour`);
3311
- }
3312
- }
3313
- }
3314
- }
3315
-
3316
- /** Start background Outbox worker — runs immediately then every 10 seconds */
3317
- private outboxBackoff = new Map<string, number>(); // accountId → next retry timestamp
3318
- private outboxBackoffDelay = new Map<string, number>(); // accountId → current delay ms
3319
-
3320
- startOutboxWorker(): void {
3321
- if (this.outboxInterval) return;
3322
-
3323
- const processAll = async () => {
3324
- const now = Date.now();
3325
- for (const [accountId] of this.configs) {
3326
- // Skip accounts in backoff
3327
- const retryAfter = this.outboxBackoff.get(accountId) || 0;
3328
- if (now < retryAfter) continue;
3329
-
3330
- try {
3331
- await this.processLocalQueue(accountId);
3332
- await this.processOutbox(accountId);
3333
- // Success — clear backoff
3334
- this.outboxBackoff.delete(accountId);
3335
- this.outboxBackoffDelay.delete(accountId);
3336
- } catch (e: any) {
3337
- // Stale-socket errors (Dovecot silently drops idle connections,
3338
- // or the sync path timed out and destroyed the socket): force a
3339
- // fresh ops client so the next tick doesn't keep hitting the same
3340
- // dead socket. Without reconnectOps, the dead client stays in the
3341
- // opsClients map and every subsequent processOutbox call fails
3342
- // immediately with "Not connected" — forever.
3343
- const msg = String(e?.message || e);
3344
- if (/Not connected|ECONNRESET|socket hang up|EPIPE|write after end/i.test(msg)) {
3345
- this.reconnectOps(accountId).catch(() => {});
3346
- console.error(` [outbox] Stale connection for ${accountId}: ${msg} — reconnecting`);
3347
- } else {
3348
- // Exponential backoff: 60s → 120s → 300s (max 5min)
3349
- const prevDelay = this.outboxBackoffDelay.get(accountId) || 0;
3350
- const delay = prevDelay ? Math.min(prevDelay * 2, 300000) : 60000;
3351
- this.outboxBackoffDelay.set(accountId, delay);
3352
- this.outboxBackoff.set(accountId, now + delay);
3353
- console.error(` [outbox] Error for ${accountId}: ${imapError(e)} (retry in ${Math.round(delay / 1000)}s)`);
3354
- }
3355
- }
3356
- }
3357
- // After each full tick, refresh the UI indicator.
3358
- this.emitOutboxStatus();
3359
- };
3360
- // First tick at 500ms so any stale .sending-HOST-DEAD_PID claim file
3361
- // left behind by a prior crash gets recovered (renamed back to .ltr)
3362
- // within half a second of startup — otherwise the status-queue pill
3363
- // shows a red "1 queued" to the user until the first 10s tick passes.
3364
- setTimeout(() => processAll(), 500);
3365
- this.outboxInterval = setInterval(processAll, 10000);
3366
- }
3367
-
3368
- /** Stop Outbox worker */
3369
- stopOutboxWorker(): void {
3370
- if (this.outboxInterval) {
3371
- clearInterval(this.outboxInterval);
3372
- this.outboxInterval = null;
3373
- }
3374
- }
3375
-
3376
- // ── Config file watcher ──
3377
-
3378
- private configWatchers: fs.FSWatcher[] = [];
3379
- private cloudPollTimers: ReturnType<typeof setInterval>[] = [];
3380
-
3381
- /** Watch the local config files for external changes. On change, emit
3382
- * configChanged so the UI can show a "restart to apply" banner. Uses
3383
- * a debounce to coalesce rapid writes from save tools. */
3384
- watchConfigFiles(): void {
3385
- const files = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
3386
- const configDir = getConfigDir();
3387
- const debounce = new Map<string, ReturnType<typeof setTimeout>>();
3388
- // Cache the last-seen normalized content per file. fs.watch fires on
3389
- // metadata-only events (atime, attrib change) AND on no-op rewrites
3390
- // that land identical bytes — both would fire a spurious banner.
3391
- // Compare after the debounce window and only emit on a real change.
3392
- const normalize = (s: string): string =>
3393
- s.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/[ \t\r\n]+$/, "");
3394
- const lastContent = new Map<string, string>();
3395
- for (const filename of files) {
3396
- const full = path.join(configDir, filename);
3397
- if (!fs.existsSync(full)) continue;
3398
- try { lastContent.set(filename, normalize(fs.readFileSync(full, "utf-8"))); } catch { /* */ }
3399
- try {
3400
- const watcher = fs.watch(full, () => {
3401
- const prev = debounce.get(filename);
3402
- if (prev) clearTimeout(prev);
3403
- debounce.set(filename, setTimeout(() => {
3404
- debounce.delete(filename);
3405
- let current = "";
3406
- try { current = normalize(fs.readFileSync(full, "utf-8")); } catch { /* missing */ }
3407
- const previous = lastContent.get(filename) || "";
3408
- if (current === previous) {
3409
- console.log(` [watch] ${filename} fs.watch fired but content unchanged — no banner`);
3410
- return;
3411
- }
3412
- // Log a short diff hint so repeat-firings are diagnosable.
3413
- const prevSize = previous.length;
3414
- const curSize = current.length;
3415
- const firstDiff = (() => {
3416
- const n = Math.min(prevSize, curSize);
3417
- for (let i = 0; i < n; i++) if (previous[i] !== current[i]) return i;
3418
- return n;
3419
- })();
3420
- const prevSnip = previous.slice(Math.max(0, firstDiff - 20), firstDiff + 40).replace(/\n/g, "\\n");
3421
- const curSnip = current.slice(Math.max(0, firstDiff - 20), firstDiff + 40).replace(/\n/g, "\\n");
3422
- console.log(` [watch] ${filename} changed: size ${prevSize}→${curSize}, first diff at byte ${firstDiff}`);
3423
- console.log(` [watch] was: ${JSON.stringify(prevSnip)}`);
3424
- console.log(` [watch] now: ${JSON.stringify(curSnip)}`);
3425
- lastContent.set(filename, current);
3426
- this.emit("configChanged", filename);
3427
- }, 500));
3428
- });
3429
- this.configWatchers.push(watcher);
3430
- } catch (e: any) {
3431
- console.error(` [watch] Failed to watch ${filename}: ${e.message}`);
3432
- }
3433
- }
3434
-
3435
- // GDrive has no push/watch for arbitrary Drive files, so edits on
3436
- // another device (or via Drive web) never fire fs.watch locally.
3437
- // Poll the cloud copies of the replicated-to-cloud config files
3438
- // (accounts.jsonc, allowlist.jsonc, clients.jsonc) every 3 minutes,
3439
- // compare to local, and write-through on difference. The local
3440
- // fs.watch above then picks up the write and emits configChanged.
3441
- // config.jsonc is per-machine / local-only — never polled.
3442
- const cloudFiles = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "contacts.jsonc"];
3443
- const CLOUD_POLL_MS = 3 * 60 * 1000;
3444
- // normalize() reused from the fs.watch block above — same intent:
3445
- // cloud round-trips that re-wrap newlines / add a trailing newline are
3446
- // semantically identical; don't overwrite local on those.
3447
- const pollCloud = async (): Promise<void> => {
3448
- let cloudRead: any;
3449
- let parseJsonc: any;
3450
- try {
3451
- ({ cloudRead } = await import("@bobfrankston/mailx-settings"));
3452
- ({ parseJsonc } = await import("jsonc-parser").then(m => ({ parseJsonc: m.parse })));
3453
- } catch { return; /* cloud module unavailable */ }
3454
- for (const filename of cloudFiles) {
3455
- try {
3456
- const cloudContent = await cloudRead(filename);
3457
- if (!cloudContent) continue;
3458
- const localPath = path.join(configDir, filename);
3459
- let localContent: string | null = null;
3460
- try { localContent = fs.readFileSync(localPath, "utf-8"); } catch { /* missing */ }
3461
- if (localContent !== null) {
3462
- if (normalize(localContent) === normalize(cloudContent)) continue;
3463
- // Semantic check: parse both as JSONC and compare structures.
3464
- // Catches reorderings that normalize() doesn't (e.g. JSON with
3465
- // same keys in different order after a cloud-side re-serialize).
3466
- try {
3467
- const a = parseJsonc(localContent);
3468
- const b = parseJsonc(cloudContent);
3469
- if (a !== undefined && b !== undefined &&
3470
- JSON.stringify(a) === JSON.stringify(b)) continue;
3471
- } catch { /* fall through to write */ }
3472
- }
3473
- fs.writeFileSync(localPath, cloudContent);
3474
- console.log(` [cloud-poll] ${filename} updated from cloud copy`);
3475
- } catch (e: any) {
3476
- console.log(` [cloud-poll] ${filename} check skipped: ${e?.message || e}`);
3477
- }
3478
- }
3479
- };
3480
- // First poll ~10s after startup, then every 3 min.
3481
- setTimeout(() => {
3482
- pollCloud();
3483
- const interval = setInterval(pollCloud, CLOUD_POLL_MS);
3484
- this.cloudPollTimers.push(interval);
3485
- }, 10_000);
3486
- }
3487
-
3488
- /** Stop all config file watchers */
3489
- stopWatchingConfig(): void {
3490
- for (const w of this.configWatchers) {
3491
- try { w.close(); } catch { /* ignore */ }
3492
- }
3493
- this.configWatchers = [];
3494
- for (const t of this.cloudPollTimers) {
3495
- try { clearInterval(t); } catch { /* ignore */ }
3496
- }
3497
- this.cloudPollTimers = [];
3498
- }
3499
-
3500
- // ── Google Contacts Sync (incremental via People API syncToken) ──
3501
-
3502
- /** Per-account in-flight guard so concurrent calls (startup + periodic
3503
- * timer + manual button) share one round-trip instead of stacking. */
3504
- private contactsSyncing = new Map<string, Promise<number>>();
3505
-
3506
- /** Get an OAuth token for Google APIs (contacts, calendar, etc.)
3507
- * Uses the SAME token as IMAP — scopes are combined in one grant */
3508
- private async getContactsToken(accountId: string): Promise<string | null> {
3509
- // Reuse the IMAP token — it now includes contacts.readonly scope
3510
- return this.getOAuthToken(accountId);
3511
- }
3512
-
3513
- /** Sync contacts from Google People API. Incremental: persists
3514
- * `nextSyncToken` per account in the kv table (`scope='contacts'`,
3515
- * `key=accountId`) so subsequent calls only fetch changed/deleted rows.
3516
- * First-ever call passes `requestSyncToken=true` so Google returns a
3517
- * token to use next time; without that, the first response has no
3518
- * `nextSyncToken` and incremental never kicks in. Returns the number of
3519
- * contacts added or removed in this run. */
3520
- async syncGoogleContacts(accountId: string): Promise<number> {
3521
- // Coalesce concurrent calls for the same account.
3522
- const inFlight = this.contactsSyncing.get(accountId);
3523
- if (inFlight) return inFlight;
3524
- const promise = this.syncGoogleContactsImpl(accountId)
3525
- .finally(() => this.contactsSyncing.delete(accountId));
3526
- this.contactsSyncing.set(accountId, promise);
3527
- return promise;
3528
- }
3529
-
3530
- private async syncGoogleContactsImpl(accountId: string): Promise<number> {
3531
- const token = await this.getContactsToken(accountId);
3532
- if (!token) return 0;
3533
-
3534
- let changed = 0;
3535
- let nextPageToken: string | undefined;
3536
- const now = Date.now();
3537
-
3538
- // Per-account persisted sync token (survives restarts). Empty means
3539
- // we've never completed an initial sync for this account.
3540
- let syncToken = this.db.getKv("contacts", accountId) || "";
3541
-
3542
- try {
3543
- do {
3544
- const params = new URLSearchParams({
3545
- personFields: "names,emailAddresses,organizations,photos",
3546
- pageSize: "100",
3547
- });
3548
- if (nextPageToken) params.set("pageToken", nextPageToken);
3549
- if (syncToken) {
3550
- params.set("syncToken", syncToken);
3551
- } else {
3552
- // First-ever sync for this account — ask Google to give us
3553
- // a token in the response so the NEXT call can be cheap.
3554
- params.set("requestSyncToken", "true");
3555
- }
3556
-
3557
- const url = `https://people.googleapis.com/v1/people/me/connections?${params}`;
3558
- const res = await fetch(url, {
3559
- headers: { Authorization: `Bearer ${token}` },
3560
- });
3561
-
3562
- if (res.status === 410) {
3563
- // Sync token expired (Google retains tokens for ~7 days).
3564
- // Drop the stored token and recurse for a full sync — the
3565
- // in-flight guard is on syncGoogleContacts (the public
3566
- // wrapper), not Impl, so recursion is safe.
3567
- this.db.setKv("contacts", accountId, null);
3568
- return this.syncGoogleContactsImpl(accountId);
3569
- }
3570
-
3571
- if (!res.ok) {
3572
- const err = await res.text();
3573
- console.error(` [contacts] API error for ${accountId}: ${res.status} ${err}`);
3574
- return changed;
3575
- }
3576
-
3577
- const data = await res.json() as any;
3578
-
3579
- if (data.connections) {
3580
- for (const person of data.connections) {
3581
- const googleId = person.resourceName || "";
3582
- // Incremental responses tag deleted contacts via
3583
- // metadata.deleted=true (and emit no other fields).
3584
- // Drop those rows so autocomplete stops surfacing them.
3585
- if (person.metadata?.deleted) {
3586
- const removed = this.db.deleteContactByGoogleId(googleId);
3587
- if (removed > 0) changed += removed;
3588
- continue;
3589
- }
3590
- const emails = person.emailAddresses || [];
3591
- const names = person.names || [];
3592
- const orgs = person.organizations || [];
3593
- const name = names[0]?.displayName || "";
3594
- const org = orgs[0]?.name || "";
3595
-
3596
- for (const emailEntry of emails) {
3597
- const email = emailEntry.value?.toLowerCase();
3598
- if (!email) continue;
3599
-
3600
- const existing = this.db.searchContacts(email, 1);
3601
- const wasNew = !(existing.length > 0 && existing[0].email === email);
3602
- this.db.recordSentAddress(name, email);
3603
- if (wasNew) changed++;
3604
-
3605
- try {
3606
- (this.db as any).db.prepare(
3607
- "UPDATE contacts SET source = 'google', google_id = ?, organization = ?, updated_at = ? WHERE email = ?"
3608
- ).run(googleId, org, now, email);
3609
- } catch { /* ignore */ }
3610
- }
3611
- }
3612
- }
3613
-
3614
- nextPageToken = data.nextPageToken;
3615
- // Google only returns nextSyncToken on the LAST page of a
3616
- // sync run (sentinel that the snapshot is consistent). When
3617
- // it appears, persist it for next time.
3618
- if (data.nextSyncToken) {
3619
- this.db.setKv("contacts", accountId, data.nextSyncToken);
3620
- syncToken = data.nextSyncToken;
3621
- }
3622
- } while (nextPageToken);
3623
-
3624
- console.log(` [contacts] ${accountId}: ${changed} change(s) (${syncToken ? "incremental" : "full"})`);
3625
- } catch (e: any) {
3626
- console.error(` [contacts] Sync error for ${accountId}: ${e.message}`);
3627
- }
3628
-
3629
- return changed;
3630
- }
3631
-
3632
- /** Sync contacts for all OAuth accounts */
3633
- async syncAllContacts(): Promise<void> {
3634
- const settings = loadSettings();
3635
- for (const account of settings.accounts) {
3636
- if (account.imap.auth === "oauth2" && (account.enabled || (account as any).syncContacts)) {
3637
- await this.syncGoogleContacts(account.id);
3638
- }
3639
- }
3640
- }
3641
-
3642
- /** Shut down all watchers and timers */
3643
- async shutdown(): Promise<void> {
3644
- this.stopPeriodicSync();
3645
- this.stopOutboxWorker();
3646
- await this.stopWatching();
3647
- // Disconnect all persistent operational connections
3648
- for (const [accountId] of this.opsClients) {
3649
- await this.disconnectOps(accountId);
3650
- }
3651
- }
3652
- }