@bobfrankston/rmfmail 1.0.467

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/README.md +429 -0
  2. package/bin/build-icon-ico.js +283 -0
  3. package/bin/lean-accounts.js +172 -0
  4. package/bin/mailx.js +1580 -0
  5. package/bin/mailx.js.map +1 -0
  6. package/bin/mailx.ts +1549 -0
  7. package/bin/postinstall.js +39 -0
  8. package/bin/postinstall.js.map +1 -0
  9. package/bin/postinstall.ts +41 -0
  10. package/bin/tsconfig.json +10 -0
  11. package/client/.gitattributes +10 -0
  12. package/client/.msger-window.json +1 -0
  13. package/client/android.html +226 -0
  14. package/client/app.js +3400 -0
  15. package/client/app.js.map +1 -0
  16. package/client/app.ts +3190 -0
  17. package/client/components/address-book.js +199 -0
  18. package/client/components/address-book.js.map +1 -0
  19. package/client/components/address-book.ts +204 -0
  20. package/client/components/alarms.js +299 -0
  21. package/client/components/alarms.js.map +1 -0
  22. package/client/components/alarms.ts +276 -0
  23. package/client/components/calendar-sidebar.js +508 -0
  24. package/client/components/calendar-sidebar.js.map +1 -0
  25. package/client/components/calendar-sidebar.ts +474 -0
  26. package/client/components/calendar.js +217 -0
  27. package/client/components/calendar.js.map +1 -0
  28. package/client/components/calendar.ts +211 -0
  29. package/client/components/context-menu.js +80 -0
  30. package/client/components/context-menu.js.map +1 -0
  31. package/client/components/context-menu.ts +95 -0
  32. package/client/components/folder-picker.js +119 -0
  33. package/client/components/folder-picker.js.map +1 -0
  34. package/client/components/folder-picker.ts +127 -0
  35. package/client/components/folder-tree.js +1093 -0
  36. package/client/components/folder-tree.js.map +1 -0
  37. package/client/components/folder-tree.ts +1069 -0
  38. package/client/components/message-list.js +1140 -0
  39. package/client/components/message-list.js.map +1 -0
  40. package/client/components/message-list.ts +1129 -0
  41. package/client/components/message-viewer.js +1291 -0
  42. package/client/components/message-viewer.js.map +1 -0
  43. package/client/components/message-viewer.ts +1257 -0
  44. package/client/components/outbox-view.js +104 -0
  45. package/client/components/outbox-view.js.map +1 -0
  46. package/client/components/outbox-view.ts +102 -0
  47. package/client/components/tasks.js +261 -0
  48. package/client/components/tasks.js.map +1 -0
  49. package/client/components/tasks.ts +234 -0
  50. package/client/compose/compose.css +560 -0
  51. package/client/compose/compose.html +52 -0
  52. package/client/compose/compose.js +1289 -0
  53. package/client/compose/compose.js.map +1 -0
  54. package/client/compose/compose.ts +1231 -0
  55. package/client/compose/editor.js +616 -0
  56. package/client/compose/editor.js.map +1 -0
  57. package/client/compose/editor.ts +599 -0
  58. package/client/compose/ghost-text.js +122 -0
  59. package/client/compose/ghost-text.js.map +1 -0
  60. package/client/compose/ghost-text.ts +140 -0
  61. package/client/favicon.svg +11 -0
  62. package/client/icon.ico +0 -0
  63. package/client/icon.png +0 -0
  64. package/client/index.html +230 -0
  65. package/client/lib/android-bootstrap.js +9 -0
  66. package/client/lib/android-bootstrap.js.map +1 -0
  67. package/client/lib/android-bootstrap.ts +9 -0
  68. package/client/lib/api-client.js +378 -0
  69. package/client/lib/api-client.js.map +1 -0
  70. package/client/lib/api-client.ts +439 -0
  71. package/client/lib/local-service.js +603 -0
  72. package/client/lib/local-service.js.map +1 -0
  73. package/client/lib/local-service.ts +646 -0
  74. package/client/lib/local-store.js +214 -0
  75. package/client/lib/local-store.js.map +1 -0
  76. package/client/lib/local-store.ts +283 -0
  77. package/client/lib/mailxapi.js +304 -0
  78. package/client/lib/message-state.js +132 -0
  79. package/client/lib/message-state.js.map +1 -0
  80. package/client/lib/message-state.ts +160 -0
  81. package/client/package.json +16 -0
  82. package/client/styles/components.css +2049 -0
  83. package/client/styles/layout.css +332 -0
  84. package/client/styles/variables.css +85 -0
  85. package/client/tsconfig.json +19 -0
  86. package/package.json +124 -0
  87. package/packages/mailx-api/.gitattributes +10 -0
  88. package/packages/mailx-api/index.d.ts +10 -0
  89. package/packages/mailx-api/index.d.ts.map +1 -0
  90. package/packages/mailx-api/index.js +322 -0
  91. package/packages/mailx-api/index.js.map +1 -0
  92. package/packages/mailx-api/index.ts +283 -0
  93. package/packages/mailx-api/package.json +27 -0
  94. package/packages/mailx-api/tsconfig.json +9 -0
  95. package/packages/mailx-api/tsconfig.tsbuildinfo +1 -0
  96. package/packages/mailx-compose/.gitattributes +10 -0
  97. package/packages/mailx-compose/index.d.ts +21 -0
  98. package/packages/mailx-compose/index.d.ts.map +1 -0
  99. package/packages/mailx-compose/index.js +61 -0
  100. package/packages/mailx-compose/index.js.map +1 -0
  101. package/packages/mailx-compose/index.ts +85 -0
  102. package/packages/mailx-compose/package.json +18 -0
  103. package/packages/mailx-compose/tsconfig.json +9 -0
  104. package/packages/mailx-core/index.d.ts +139 -0
  105. package/packages/mailx-core/index.d.ts.map +1 -0
  106. package/packages/mailx-core/index.js +403 -0
  107. package/packages/mailx-core/index.js.map +1 -0
  108. package/packages/mailx-core/index.ts +424 -0
  109. package/packages/mailx-core/ipc.d.ts +13 -0
  110. package/packages/mailx-core/ipc.d.ts.map +1 -0
  111. package/packages/mailx-core/ipc.js +56 -0
  112. package/packages/mailx-core/ipc.js.map +1 -0
  113. package/packages/mailx-core/ipc.ts +62 -0
  114. package/packages/mailx-core/package.json +18 -0
  115. package/packages/mailx-core/tsconfig.json +9 -0
  116. package/packages/mailx-host/.gitattributes +10 -0
  117. package/packages/mailx-host/index.d.ts +21 -0
  118. package/packages/mailx-host/index.d.ts.map +1 -0
  119. package/packages/mailx-host/index.js +29 -0
  120. package/packages/mailx-host/index.js.map +1 -0
  121. package/packages/mailx-host/index.ts +38 -0
  122. package/packages/mailx-host/package.json +23 -0
  123. package/packages/mailx-host/tsconfig.json +9 -0
  124. package/packages/mailx-host/types-shim.d.ts +14 -0
  125. package/packages/mailx-imap/.gitattributes +10 -0
  126. package/packages/mailx-imap/index.d.ts +442 -0
  127. package/packages/mailx-imap/index.d.ts.map +1 -0
  128. package/packages/mailx-imap/index.js +3684 -0
  129. package/packages/mailx-imap/index.js.map +1 -0
  130. package/packages/mailx-imap/index.ts +3652 -0
  131. package/packages/mailx-imap/package-lock.json +131 -0
  132. package/packages/mailx-imap/package.json +28 -0
  133. package/packages/mailx-imap/providers/gmail-api.d.ts +8 -0
  134. package/packages/mailx-imap/providers/gmail-api.d.ts.map +1 -0
  135. package/packages/mailx-imap/providers/gmail-api.js +8 -0
  136. package/packages/mailx-imap/providers/gmail-api.js.map +1 -0
  137. package/packages/mailx-imap/providers/gmail-api.ts +8 -0
  138. package/packages/mailx-imap/providers/outlook-api.ts +7 -0
  139. package/packages/mailx-imap/providers/types.d.ts +9 -0
  140. package/packages/mailx-imap/providers/types.d.ts.map +1 -0
  141. package/packages/mailx-imap/providers/types.js +9 -0
  142. package/packages/mailx-imap/providers/types.js.map +1 -0
  143. package/packages/mailx-imap/providers/types.ts +9 -0
  144. package/packages/mailx-imap/tsconfig.json +9 -0
  145. package/packages/mailx-imap/tsconfig.tsbuildinfo +1 -0
  146. package/packages/mailx-send/.gitattributes +10 -0
  147. package/packages/mailx-send/README.md +217 -0
  148. package/packages/mailx-send/cli-queue.d.ts +11 -0
  149. package/packages/mailx-send/cli-queue.d.ts.map +1 -0
  150. package/packages/mailx-send/cli-queue.js +59 -0
  151. package/packages/mailx-send/cli-queue.js.map +1 -0
  152. package/packages/mailx-send/cli-queue.ts +62 -0
  153. package/packages/mailx-send/cli-send.d.ts +17 -0
  154. package/packages/mailx-send/cli-send.d.ts.map +1 -0
  155. package/packages/mailx-send/cli-send.js +75 -0
  156. package/packages/mailx-send/cli-send.js.map +1 -0
  157. package/packages/mailx-send/cli-send.ts +83 -0
  158. package/packages/mailx-send/cli.d.ts +22 -0
  159. package/packages/mailx-send/cli.d.ts.map +1 -0
  160. package/packages/mailx-send/cli.js +115 -0
  161. package/packages/mailx-send/cli.js.map +1 -0
  162. package/packages/mailx-send/cli.ts +126 -0
  163. package/packages/mailx-send/index.d.ts +77 -0
  164. package/packages/mailx-send/index.d.ts.map +1 -0
  165. package/packages/mailx-send/index.js +264 -0
  166. package/packages/mailx-send/index.js.map +1 -0
  167. package/packages/mailx-send/index.ts +333 -0
  168. package/packages/mailx-send/mailsend/README.md +133 -0
  169. package/packages/mailx-send/mailsend/cli.d.ts +12 -0
  170. package/packages/mailx-send/mailsend/cli.d.ts.map +1 -0
  171. package/packages/mailx-send/mailsend/cli.js +70 -0
  172. package/packages/mailx-send/mailsend/cli.js.map +1 -0
  173. package/packages/mailx-send/mailsend/cli.ts +81 -0
  174. package/packages/mailx-send/mailsend/index.d.ts +77 -0
  175. package/packages/mailx-send/mailsend/index.d.ts.map +1 -0
  176. package/packages/mailx-send/mailsend/index.js +264 -0
  177. package/packages/mailx-send/mailsend/index.js.map +1 -0
  178. package/packages/mailx-send/mailsend/index.ts +333 -0
  179. package/packages/mailx-send/mailsend/package-lock.json +65 -0
  180. package/packages/mailx-send/mailsend/package.json +26 -0
  181. package/packages/mailx-send/mailsend/tsconfig.json +21 -0
  182. package/packages/mailx-send/package-lock.json +65 -0
  183. package/packages/mailx-send/package.json +37 -0
  184. package/packages/mailx-send/tsconfig.json +21 -0
  185. package/packages/mailx-server/.gitattributes +10 -0
  186. package/packages/mailx-server/index.d.ts +8 -0
  187. package/packages/mailx-server/index.d.ts.map +1 -0
  188. package/packages/mailx-server/index.js +397 -0
  189. package/packages/mailx-server/index.js.map +1 -0
  190. package/packages/mailx-server/index.ts +429 -0
  191. package/packages/mailx-server/package.json +28 -0
  192. package/packages/mailx-server/tsconfig.json +9 -0
  193. package/packages/mailx-server/tsconfig.tsbuildinfo +1 -0
  194. package/packages/mailx-service/google-sync.d.ts +96 -0
  195. package/packages/mailx-service/google-sync.d.ts.map +1 -0
  196. package/packages/mailx-service/google-sync.js +153 -0
  197. package/packages/mailx-service/google-sync.js.map +1 -0
  198. package/packages/mailx-service/google-sync.ts +238 -0
  199. package/packages/mailx-service/index.d.ts +366 -0
  200. package/packages/mailx-service/index.d.ts.map +1 -0
  201. package/packages/mailx-service/index.js +2589 -0
  202. package/packages/mailx-service/index.js.map +1 -0
  203. package/packages/mailx-service/index.ts +2512 -0
  204. package/packages/mailx-service/jsonrpc.d.ts +25 -0
  205. package/packages/mailx-service/jsonrpc.d.ts.map +1 -0
  206. package/packages/mailx-service/jsonrpc.js +238 -0
  207. package/packages/mailx-service/jsonrpc.js.map +1 -0
  208. package/packages/mailx-service/jsonrpc.ts +268 -0
  209. package/packages/mailx-service/package.json +22 -0
  210. package/packages/mailx-service/tsconfig.json +9 -0
  211. package/packages/mailx-settings/.gitattributes +10 -0
  212. package/packages/mailx-settings/cloud.d.ts +47 -0
  213. package/packages/mailx-settings/cloud.d.ts.map +1 -0
  214. package/packages/mailx-settings/cloud.js +408 -0
  215. package/packages/mailx-settings/cloud.js.map +1 -0
  216. package/packages/mailx-settings/cloud.ts +393 -0
  217. package/packages/mailx-settings/index.d.ts +140 -0
  218. package/packages/mailx-settings/index.d.ts.map +1 -0
  219. package/packages/mailx-settings/index.js +872 -0
  220. package/packages/mailx-settings/index.js.map +1 -0
  221. package/packages/mailx-settings/index.ts +908 -0
  222. package/packages/mailx-settings/package.json +20 -0
  223. package/packages/mailx-settings/tsconfig.json +9 -0
  224. package/packages/mailx-settings/tsconfig.tsbuildinfo +1 -0
  225. package/packages/mailx-store/.gitattributes +10 -0
  226. package/packages/mailx-store/db.d.ts +346 -0
  227. package/packages/mailx-store/db.d.ts.map +1 -0
  228. package/packages/mailx-store/db.js +1796 -0
  229. package/packages/mailx-store/db.js.map +1 -0
  230. package/packages/mailx-store/db.ts +2007 -0
  231. package/packages/mailx-store/file-store.d.ts +36 -0
  232. package/packages/mailx-store/file-store.d.ts.map +1 -0
  233. package/packages/mailx-store/file-store.js +80 -0
  234. package/packages/mailx-store/file-store.js.map +1 -0
  235. package/packages/mailx-store/file-store.ts +82 -0
  236. package/packages/mailx-store/index.d.ts +7 -0
  237. package/packages/mailx-store/index.d.ts.map +1 -0
  238. package/packages/mailx-store/index.js +7 -0
  239. package/packages/mailx-store/index.js.map +1 -0
  240. package/packages/mailx-store/index.ts +7 -0
  241. package/packages/mailx-store/package.json +19 -0
  242. package/packages/mailx-store/tsconfig.json +9 -0
  243. package/packages/mailx-store/tsconfig.tsbuildinfo +1 -0
  244. package/packages/mailx-store-web/android-bootstrap.d.ts +16 -0
  245. package/packages/mailx-store-web/android-bootstrap.d.ts.map +1 -0
  246. package/packages/mailx-store-web/android-bootstrap.js +1247 -0
  247. package/packages/mailx-store-web/android-bootstrap.js.map +1 -0
  248. package/packages/mailx-store-web/android-bootstrap.ts +1262 -0
  249. package/packages/mailx-store-web/db.d.ts +132 -0
  250. package/packages/mailx-store-web/db.d.ts.map +1 -0
  251. package/packages/mailx-store-web/db.js +642 -0
  252. package/packages/mailx-store-web/db.js.map +1 -0
  253. package/packages/mailx-store-web/db.ts +756 -0
  254. package/packages/mailx-store-web/gmail-api-web.d.ts +11 -0
  255. package/packages/mailx-store-web/gmail-api-web.d.ts.map +1 -0
  256. package/packages/mailx-store-web/gmail-api-web.js +11 -0
  257. package/packages/mailx-store-web/gmail-api-web.js.map +1 -0
  258. package/packages/mailx-store-web/gmail-api-web.ts +11 -0
  259. package/packages/mailx-store-web/imap-web-provider.d.ts +33 -0
  260. package/packages/mailx-store-web/imap-web-provider.d.ts.map +1 -0
  261. package/packages/mailx-store-web/imap-web-provider.js +140 -0
  262. package/packages/mailx-store-web/imap-web-provider.js.map +1 -0
  263. package/packages/mailx-store-web/imap-web-provider.ts +156 -0
  264. package/packages/mailx-store-web/index.d.ts +10 -0
  265. package/packages/mailx-store-web/index.d.ts.map +1 -0
  266. package/packages/mailx-store-web/index.js +10 -0
  267. package/packages/mailx-store-web/index.js.map +1 -0
  268. package/packages/mailx-store-web/index.ts +10 -0
  269. package/packages/mailx-store-web/main-thread-host.d.ts +15 -0
  270. package/packages/mailx-store-web/main-thread-host.d.ts.map +1 -0
  271. package/packages/mailx-store-web/main-thread-host.js +292 -0
  272. package/packages/mailx-store-web/main-thread-host.js.map +1 -0
  273. package/packages/mailx-store-web/main-thread-host.ts +322 -0
  274. package/packages/mailx-store-web/package.json +23 -0
  275. package/packages/mailx-store-web/provider-types.d.ts +7 -0
  276. package/packages/mailx-store-web/provider-types.d.ts.map +1 -0
  277. package/packages/mailx-store-web/provider-types.js +7 -0
  278. package/packages/mailx-store-web/provider-types.js.map +1 -0
  279. package/packages/mailx-store-web/provider-types.ts +7 -0
  280. package/packages/mailx-store-web/sql-wasm-esm.js +10 -0
  281. package/packages/mailx-store-web/sql.js.d.ts +29 -0
  282. package/packages/mailx-store-web/sync-manager.d.ts +68 -0
  283. package/packages/mailx-store-web/sync-manager.d.ts.map +1 -0
  284. package/packages/mailx-store-web/sync-manager.js +506 -0
  285. package/packages/mailx-store-web/sync-manager.js.map +1 -0
  286. package/packages/mailx-store-web/sync-manager.ts +508 -0
  287. package/packages/mailx-store-web/tsconfig.json +10 -0
  288. package/packages/mailx-store-web/web-jsonrpc.d.ts +20 -0
  289. package/packages/mailx-store-web/web-jsonrpc.d.ts.map +1 -0
  290. package/packages/mailx-store-web/web-jsonrpc.js +102 -0
  291. package/packages/mailx-store-web/web-jsonrpc.js.map +1 -0
  292. package/packages/mailx-store-web/web-jsonrpc.ts +116 -0
  293. package/packages/mailx-store-web/web-message-store.d.ts +16 -0
  294. package/packages/mailx-store-web/web-message-store.d.ts.map +1 -0
  295. package/packages/mailx-store-web/web-message-store.js +89 -0
  296. package/packages/mailx-store-web/web-message-store.js.map +1 -0
  297. package/packages/mailx-store-web/web-message-store.ts +97 -0
  298. package/packages/mailx-store-web/web-service.d.ts +111 -0
  299. package/packages/mailx-store-web/web-service.d.ts.map +1 -0
  300. package/packages/mailx-store-web/web-service.js +542 -0
  301. package/packages/mailx-store-web/web-service.js.map +1 -0
  302. package/packages/mailx-store-web/web-service.ts +616 -0
  303. package/packages/mailx-store-web/web-settings.d.ts +83 -0
  304. package/packages/mailx-store-web/web-settings.d.ts.map +1 -0
  305. package/packages/mailx-store-web/web-settings.js +491 -0
  306. package/packages/mailx-store-web/web-settings.js.map +1 -0
  307. package/packages/mailx-store-web/web-settings.ts +522 -0
  308. package/packages/mailx-store-web/worker-bundle.js +6838 -0
  309. package/packages/mailx-store-web/worker-entry.d.ts +8 -0
  310. package/packages/mailx-store-web/worker-entry.d.ts.map +1 -0
  311. package/packages/mailx-store-web/worker-entry.js +187 -0
  312. package/packages/mailx-store-web/worker-entry.js.map +1 -0
  313. package/packages/mailx-store-web/worker-entry.ts +215 -0
  314. package/packages/mailx-store-web/worker-tcp-transport.d.ts +28 -0
  315. package/packages/mailx-store-web/worker-tcp-transport.d.ts.map +1 -0
  316. package/packages/mailx-store-web/worker-tcp-transport.js +98 -0
  317. package/packages/mailx-store-web/worker-tcp-transport.js.map +1 -0
  318. package/packages/mailx-store-web/worker-tcp-transport.ts +101 -0
  319. package/packages/mailx-types/.gitattributes +10 -0
  320. package/packages/mailx-types/index.d.ts +319 -0
  321. package/packages/mailx-types/index.d.ts.map +1 -0
  322. package/packages/mailx-types/index.js +243 -0
  323. package/packages/mailx-types/index.js.map +1 -0
  324. package/packages/mailx-types/index.ts +498 -0
  325. package/packages/mailx-types/package.json +16 -0
  326. package/packages/mailx-types/tsconfig.json +9 -0
  327. package/packages/mailx-types/tsconfig.tsbuildinfo +1 -0
  328. package/todo.json +13 -0
  329. package/tsconfig.base.json +31 -0
  330. package/tsconfig.json +9 -0
@@ -0,0 +1,2589 @@
1
+ /**
2
+ * @bobfrankston/mailx-service
3
+ * Pure business logic — no HTTP, no Express.
4
+ * Both the Express API (mailx-api) and the Android bridge call these functions.
5
+ */
6
+ import * as dns from "node:dns/promises";
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ import * as gsync from "./google-sync.js";
12
+ import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
13
+ import { sanitizeHtml, encodeQuotedPrintable, htmlToPlainText } from "@bobfrankston/mailx-types";
14
+ import { simpleParser } from "mailparser";
15
+ /** Detect mis-labeled charset and rewrite the part header to `utf-8` when
16
+ * the body bytes are actually valid UTF-8. PHPMailer-driven senders are
17
+ * the chronic offender — they declare `charset=iso-8859-1` (the PHP
18
+ * default) and then encode "—", "'", "…" etc. as UTF-8, which simpleParser
19
+ * faithfully decodes as Latin-1 and produces "â??" garbage. We only
20
+ * override the obviously-wrong declarations (`iso-8859-1`, `us-ascii`,
21
+ * `windows-1252`, `latin1`); anything else passes through unchanged.
22
+ * Operates byte-wise so MIME boundaries / base64 / etc. are preserved.
23
+ * The `utf-8` validity test rejects 0xC0–0xC1 / 0xF5–0xFF and continuation
24
+ * bytes out of place, which would be common in genuine Latin-1 text. */
25
+ function sniffAndFixCharset(raw) {
26
+ const HEAD_LIMIT = 16384;
27
+ const head = raw.subarray(0, Math.min(HEAD_LIMIT, raw.length)).toString("latin1");
28
+ const re = /charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi;
29
+ if (!re.test(head))
30
+ return raw;
31
+ if (!isValidUtf8(raw))
32
+ return raw;
33
+ const fixed = head.replace(/charset\s*=\s*"?(iso-8859-1|us-ascii|windows-1252|latin1)"?/gi, "charset=utf-8");
34
+ return Buffer.concat([Buffer.from(fixed, "latin1"), raw.subarray(head.length)]);
35
+ }
36
+ /** Strict UTF-8 validity check: rejects overlong forms, invalid start
37
+ * bytes, and dangling continuations. Used to confirm the body is really
38
+ * UTF-8 before overriding a Latin-1 declaration. */
39
+ function isValidUtf8(buf) {
40
+ let i = 0;
41
+ while (i < buf.length) {
42
+ const b = buf[i];
43
+ if (b < 0x80) {
44
+ i++;
45
+ continue;
46
+ }
47
+ let need;
48
+ if ((b & 0xE0) === 0xC0) {
49
+ if (b < 0xC2)
50
+ return false;
51
+ need = 1;
52
+ }
53
+ else if ((b & 0xF0) === 0xE0)
54
+ need = 2;
55
+ else if ((b & 0xF8) === 0xF0) {
56
+ if (b > 0xF4)
57
+ return false;
58
+ need = 3;
59
+ }
60
+ else
61
+ return false;
62
+ if (i + need >= buf.length)
63
+ return false;
64
+ for (let k = 1; k <= need; k++) {
65
+ if ((buf[i + k] & 0xC0) !== 0x80)
66
+ return false;
67
+ }
68
+ i += need + 1;
69
+ }
70
+ return true;
71
+ }
72
+ /** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
73
+ * mailparser only exposes ONE of mail/url even when both are present, so we
74
+ * also scan the raw header text for the full set of angle-bracketed URIs. */
75
+ function parseListUnsubscribe(headers) {
76
+ let mail = "";
77
+ let http = "";
78
+ let oneClick = false;
79
+ const raw = headers.get("list-unsubscribe");
80
+ const rawStr = typeof raw === "string" ? raw : (raw && typeof raw.text === "string" ? raw.text : "");
81
+ if (rawStr) {
82
+ const matches = rawStr.match(/<([^>]+)>/g) || [];
83
+ for (const m of matches) {
84
+ const url = m.slice(1, -1).trim();
85
+ if (!mail && /^mailto:/i.test(url))
86
+ mail = url;
87
+ else if (!http && /^https?:/i.test(url))
88
+ http = url;
89
+ }
90
+ }
91
+ if (!mail && !http) {
92
+ const listHeaders = headers.get("list");
93
+ if (listHeaders?.unsubscribe) {
94
+ const unsub = listHeaders.unsubscribe;
95
+ if (unsub.url)
96
+ http = Array.isArray(unsub.url) ? unsub.url[0] : unsub.url;
97
+ if (unsub.mail)
98
+ mail = `mailto:${Array.isArray(unsub.mail) ? unsub.mail[0] : unsub.mail}`;
99
+ }
100
+ }
101
+ const post = headers.get("list-unsubscribe-post");
102
+ const postStr = typeof post === "string" ? post : (post && typeof post.text === "string" ? post.text : "");
103
+ if (postStr && /one-?click/i.test(postStr))
104
+ oneClick = true;
105
+ return { listUnsubscribeMail: mail, listUnsubscribeHttp: http, listUnsubscribeOneClick: oneClick };
106
+ }
107
+ // ── Email provider detection (MX-based) ──
108
+ const GOOGLE_DOMAINS = ["gmail.com", "googlemail.com"];
109
+ const MS_DOMAINS = ["outlook.com", "hotmail.com", "live.com"];
110
+ async function detectEmailProvider(domain) {
111
+ if (GOOGLE_DOMAINS.includes(domain))
112
+ return { cloud: "gdrive", imapHost: "imap.gmail.com", smtpHost: "smtp.gmail.com", auth: "oauth2" };
113
+ if (MS_DOMAINS.includes(domain))
114
+ return { imapHost: "outlook.office365.com", smtpHost: "smtp.office365.com", auth: "oauth2" };
115
+ try {
116
+ const records = await dns.resolveMx(domain);
117
+ for (const mx of records) {
118
+ const host = mx.exchange.toLowerCase();
119
+ if (host.endsWith(".google.com") || host.endsWith(".googlemail.com")) {
120
+ return { cloud: "gdrive", imapHost: "imap.gmail.com", smtpHost: "smtp.gmail.com", auth: "oauth2" };
121
+ }
122
+ if (host.endsWith(".outlook.com") || host.endsWith(".protection.outlook.com")) {
123
+ return { imapHost: "outlook.office365.com", smtpHost: "smtp.office365.com", auth: "oauth2" };
124
+ }
125
+ }
126
+ }
127
+ catch { /* DNS lookup failed */ }
128
+ return null;
129
+ }
130
+ // sanitizeHtml and encodeQuotedPrintable imported from @bobfrankston/mailx-types (shared with Android)
131
+ /** Compare a local task row to an incoming Google task projection. Used by
132
+ * refreshTasks to skip no-op upserts that would otherwise emit `tasksUpdated`
133
+ * on every poll, feeding back into the UI's getTasks-on-event listener and
134
+ * burning the daily Google Tasks API quota. */
135
+ function taskRowEquals(prior, fresh) {
136
+ return prior.providerId === fresh.providerId
137
+ && prior.title === fresh.title
138
+ && (prior.notes || "") === (fresh.notes || "")
139
+ && (prior.dueMs ?? null) === (fresh.dueMs ?? null)
140
+ && (prior.completedMs ?? null) === (fresh.completedMs ?? null)
141
+ && (prior.etag || "") === (fresh.etag || "");
142
+ }
143
+ /** Same shape as taskRowEquals — compares the calendar-event fields the
144
+ * Google projection actually carries, ignoring derived/local-only columns. */
145
+ function calendarRowEquals(prior, fresh) {
146
+ return prior.providerId === fresh.providerId
147
+ && prior.title === fresh.title
148
+ && prior.startMs === fresh.startMs
149
+ && prior.endMs === fresh.endMs
150
+ && !!prior.allDay === !!fresh.allDay
151
+ && (prior.location || "") === (fresh.location || "")
152
+ && (prior.notes || "") === (fresh.notes || "")
153
+ && (prior.etag || "") === (fresh.etag || "")
154
+ && (prior.recurringEventId || null) === (fresh.recurringEventId || null)
155
+ && (prior.htmlLink || null) === (fresh.htmlLink || null);
156
+ }
157
+ // ── Service ──
158
+ export class MailxService {
159
+ db;
160
+ imapManager;
161
+ // Cached accounts — loadSettings() reads from the cloud-mounted
162
+ // accounts.jsonc, which can stall on a flaky GDrive File Stream.
163
+ // Refresh on configChanged (fs.watch) so edits still land.
164
+ _accountsCache = null;
165
+ constructor(db, imapManager) {
166
+ this.db = db;
167
+ this.imapManager = imapManager;
168
+ // Invalidate account cache when accounts.jsonc changes on disk or GDrive.
169
+ this.imapManager.on?.("configChanged", (filename) => {
170
+ if (filename === "accounts.jsonc")
171
+ this._accountsCache = null;
172
+ if (filename === "contacts.jsonc") {
173
+ this.loadContactsConfig().catch(e => console.error(` [contacts] reload failed: ${e?.message || e}`));
174
+ }
175
+ });
176
+ // Wire DB → cloud flush. Debounced to absorb bursts (a sync run can
177
+ // call recordSentAddress hundreds of times). 30s flush window is
178
+ // long enough that the steady state is one cloud write per sync,
179
+ // short enough that quitting after a single send still flushes.
180
+ this.db.setOnContactsChanged(() => this.markContactsDirty());
181
+ // Initial load of contacts.jsonc — fire-and-forget; missing file is fine.
182
+ this.loadContactsConfig().catch(() => { });
183
+ }
184
+ _contactsFlushTimer = null;
185
+ _contactsFlushInFlight = false;
186
+ CONTACTS_FLUSH_DEBOUNCE_MS = 30_000;
187
+ /** Schedule a debounced flush of the local contacts state to GDrive.
188
+ * Multiple changes within the debounce window collapse to one write. */
189
+ markContactsDirty() {
190
+ if (this._contactsFlushTimer)
191
+ clearTimeout(this._contactsFlushTimer);
192
+ this._contactsFlushTimer = setTimeout(() => {
193
+ this._contactsFlushTimer = null;
194
+ this.flushContactsConfig().catch(e => console.error(` [contacts] flush failed: ${e?.message || e}`));
195
+ }, this.CONTACTS_FLUSH_DEBOUNCE_MS);
196
+ }
197
+ /** Write current DB contacts state to GDrive contacts.jsonc. Called via
198
+ * the debounced timer; also exposed for force-flush on shutdown or
199
+ * after a manual seed. Idempotent — safe to call multiple times. */
200
+ async flushContactsConfig() {
201
+ if (this._contactsFlushInFlight)
202
+ return;
203
+ this._contactsFlushInFlight = true;
204
+ try {
205
+ const cfg = this.db.exportContactsConfig();
206
+ const { cloudWrite } = await import("@bobfrankston/mailx-settings");
207
+ await cloudWrite("contacts.jsonc", JSON.stringify(cfg, null, 2));
208
+ console.log(` [contacts] flushed to cloud: ${cfg.preferred.length} preferred + ${cfg.discovered.length} discovered + ${cfg.denylist.length} denylisted`);
209
+ }
210
+ finally {
211
+ this._contactsFlushInFlight = false;
212
+ }
213
+ }
214
+ /** Read contacts.jsonc from cloud + apply (preferred + denylist + discovered)
215
+ * into the DB. On first run with no file, seed from message corpus and
216
+ * write a fresh contacts.jsonc to GDrive — that auto-bootstrap is what
217
+ * makes a new device useful immediately on a shared GDrive setup. */
218
+ async loadContactsConfig() {
219
+ let raw = null;
220
+ let cloudAvailable = false;
221
+ try {
222
+ const { cloudRead } = await import("@bobfrankston/mailx-settings");
223
+ raw = await cloudRead("contacts.jsonc");
224
+ cloudAvailable = true;
225
+ }
226
+ catch { /* cloud unavailable */ }
227
+ if (!raw) {
228
+ // No file (yet). Reset in-memory denylist and seed discovered
229
+ // from the local message corpus so autocomplete works immediately.
230
+ this.db.applyContactsConfig({ preferred: [], denylist: [], discovered: [] });
231
+ try {
232
+ this.db.seedContactsFromMessages();
233
+ }
234
+ catch { /* corpus may be empty */ }
235
+ // Auto-bootstrap GDrive copy if cloud is reachable. The file gets
236
+ // a header comment so a user opening it on Drive sees what it is.
237
+ if (cloudAvailable) {
238
+ try {
239
+ await this.flushContactsConfig();
240
+ console.log(" [contacts] auto-seeded contacts.jsonc on GDrive from local corpus");
241
+ }
242
+ catch (e) {
243
+ console.error(` [contacts] auto-seed flush failed: ${e?.message || e}`);
244
+ }
245
+ }
246
+ return null;
247
+ }
248
+ const { parse: parseJsonc } = await import("jsonc-parser");
249
+ const errors = [];
250
+ const cfg = parseJsonc(raw, errors, { allowTrailingComma: true });
251
+ if (errors.length) {
252
+ console.error(` [contacts] contacts.jsonc has parse errors — applying empty config: ${errors.map((e) => e.error).join(", ")}`);
253
+ this.db.applyContactsConfig({ preferred: [], denylist: [], discovered: [] });
254
+ return null;
255
+ }
256
+ const result = this.db.applyContactsConfig(cfg || {});
257
+ // Run local seeder in case this device has corpus addresses the cloud
258
+ // copy doesn't know about yet. The seeder will fire notifyContactsChanged
259
+ // if it adds anything, which schedules a flush back to GDrive.
260
+ try {
261
+ this.db.seedContactsFromMessages();
262
+ }
263
+ catch { /* corpus may be empty */ }
264
+ return result;
265
+ }
266
+ /** Append an entry to contacts.jsonc#preferred[] and write back to cloud,
267
+ * then re-apply. Mutates the file in place — preserves existing entries
268
+ * and the user's hand-formatting where the parser permits. */
269
+ async addPreferredContact(entry) {
270
+ const { cloudRead, cloudWrite } = await import("@bobfrankston/mailx-settings");
271
+ const { parse: parseJsonc } = await import("jsonc-parser");
272
+ let cfg = {};
273
+ const raw = await cloudRead("contacts.jsonc");
274
+ if (raw) {
275
+ try {
276
+ cfg = parseJsonc(raw, [], { allowTrailingComma: true }) || {};
277
+ }
278
+ catch {
279
+ cfg = {};
280
+ }
281
+ }
282
+ if (!Array.isArray(cfg.preferred))
283
+ cfg.preferred = [];
284
+ // Dedup: skip if an entry with the same email + name + source already exists.
285
+ const dupKey = `${(entry.source || "preferred").toLowerCase()}|${entry.email.toLowerCase()}|${(entry.name || "").toLowerCase()}`;
286
+ const exists = cfg.preferred.some((e) => `${(e?.source || "preferred").toLowerCase()}|${(e?.email || "").toLowerCase()}|${(e?.name || "").toLowerCase()}` === dupKey);
287
+ if (!exists) {
288
+ const row = { name: entry.name || "", email: entry.email };
289
+ if (entry.source)
290
+ row.source = entry.source;
291
+ if (entry.organization)
292
+ row.organization = entry.organization;
293
+ cfg.preferred.push(row);
294
+ }
295
+ await cloudWrite("contacts.jsonc", JSON.stringify(cfg, null, 2));
296
+ await this.loadContactsConfig();
297
+ }
298
+ /** Append an email to contacts.jsonc#denylist[] and write back to cloud,
299
+ * then re-apply (which purges any matching discovered rows). */
300
+ async addToDenylist(email) {
301
+ const lower = (email || "").trim().toLowerCase();
302
+ if (!lower)
303
+ return;
304
+ const { cloudRead, cloudWrite } = await import("@bobfrankston/mailx-settings");
305
+ const { parse: parseJsonc } = await import("jsonc-parser");
306
+ let cfg = {};
307
+ const raw = await cloudRead("contacts.jsonc");
308
+ if (raw) {
309
+ try {
310
+ cfg = parseJsonc(raw, [], { allowTrailingComma: true }) || {};
311
+ }
312
+ catch {
313
+ cfg = {};
314
+ }
315
+ }
316
+ if (!Array.isArray(cfg.denylist))
317
+ cfg.denylist = [];
318
+ if (!cfg.denylist.some((e) => (e || "").toLowerCase() === lower)) {
319
+ cfg.denylist.push(email);
320
+ }
321
+ await cloudWrite("contacts.jsonc", JSON.stringify(cfg, null, 2));
322
+ await this.loadContactsConfig();
323
+ }
324
+ /** Return accounts from cache — load once, reuse until configChanged. */
325
+ getCachedAccounts() {
326
+ if (!this._accountsCache)
327
+ this._accountsCache = loadAccounts();
328
+ return this._accountsCache;
329
+ }
330
+ // ── Accounts ──
331
+ getAccounts() {
332
+ const dbAccounts = this.db.getAccounts();
333
+ const cfgs = this.getCachedAccounts();
334
+ // Order by settings (accounts.jsonc is the source of truth for order)
335
+ const ordered = [];
336
+ for (const cfg of cfgs) {
337
+ const a = dbAccounts.find(d => d.id === cfg.id);
338
+ if (a)
339
+ ordered.push({
340
+ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false,
341
+ primary: !!cfg.primary,
342
+ primaryCalendar: !!cfg.primaryCalendar,
343
+ primaryTasks: !!cfg.primaryTasks,
344
+ primaryContacts: !!cfg.primaryContacts,
345
+ identityDomains: cfg.identityDomains || [],
346
+ });
347
+ }
348
+ // Append any DB accounts not in settings
349
+ for (const a of dbAccounts) {
350
+ if (!ordered.find((o) => o.id === a.id))
351
+ ordered.push(a);
352
+ }
353
+ return ordered;
354
+ }
355
+ // ── Folders ──
356
+ getFolders(accountId) {
357
+ return this.db.getFolders(accountId);
358
+ }
359
+ // ── Messages ──
360
+ getUnifiedInbox(page = 1, pageSize = 50) {
361
+ return this.db.getUnifiedInbox(page, pageSize);
362
+ }
363
+ getMessages(accountId, folderId, page = 1, pageSize = 50, sort = "date", sortDir = "desc", search, flaggedOnly = false) {
364
+ return this.db.getMessages({ accountId, folderId, page, pageSize, sort: sort, sortDir: sortDir, search, flaggedOnly });
365
+ }
366
+ async getMessage(accountId, uid, allowRemote = false, folderId) {
367
+ const envelope = this.db.getMessageByUid(accountId, uid, folderId);
368
+ if (!envelope)
369
+ throw new Error("Message not found");
370
+ let bodyHtml = "";
371
+ let bodyText = "";
372
+ let hasRemoteContent = false;
373
+ let attachments = [];
374
+ // The per-account ops queue inside ImapManager has its own per-task
375
+ // timeout that destroys a wedged client and unblocks the queue. This
376
+ // outer race is a safety net only — the underlying timeout in
377
+ // withConnection should trigger first.
378
+ const BODY_FETCH_TIMEOUT_MS = 60_000;
379
+ let raw = null;
380
+ try {
381
+ raw = await Promise.race([
382
+ this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid),
383
+ new Promise((_, reject) => setTimeout(() => reject(new Error("body fetch timed out — try again")), BODY_FETCH_TIMEOUT_MS)),
384
+ ]);
385
+ }
386
+ catch (fetchErr) {
387
+ // Message was deleted from the server (another device, expunge, etc.) —
388
+ // drop the stale local row so the UI removes it instead of showing a
389
+ // confusing error. Throwing a tagged error lets the client react.
390
+ if (fetchErr?.isNotFound) {
391
+ try {
392
+ this.db.deleteMessage(accountId, envelope.uid);
393
+ this.db.recalcFolderCounts(envelope.folderId);
394
+ }
395
+ catch { /* ignore */ }
396
+ const err = new Error("Message was deleted from the server");
397
+ err.isNotFound = true;
398
+ throw err;
399
+ }
400
+ // Don't stuff the error text into bodyText — it bleeds into the
401
+ // viewer's main content area. Surface as a structured error field
402
+ // so the UI can render a banner with retry UX above the (empty)
403
+ // body. The caller keeps the envelope so the header still shows.
404
+ const rawErr = fetchErr.message || "connection failed";
405
+ const isTransient = /connection|Too many|UNAVAILABLE|rate|429|5\d\d|timeout|ENOTFOUND|ECONNRESET|ETIMEDOUT/i.test(rawErr);
406
+ return {
407
+ ...envelope, bodyHtml: "", bodyText: "",
408
+ bodyError: rawErr,
409
+ bodyErrorTransient: isTransient,
410
+ hasRemoteContent: false, remoteAllowed: false, attachments: [], emlPath: "", deliveredTo: "", returnPath: "", listUnsubscribe: ""
411
+ };
412
+ }
413
+ if (!raw) {
414
+ // Same treatment as the thrown-error case: structured field, not body text.
415
+ return {
416
+ ...envelope, bodyHtml: "", bodyText: "",
417
+ bodyError: "Message body not cached locally and the server fetch returned nothing.",
418
+ bodyErrorTransient: true,
419
+ hasRemoteContent: false, remoteAllowed: false, attachments: [], emlPath: "", deliveredTo: "", returnPath: "", listUnsubscribe: ""
420
+ };
421
+ }
422
+ else {
423
+ // Many senders (esp. PHPMailer-driven marketing) declare
424
+ // `charset=iso-8859-1` but emit UTF-8 bytes. simpleParser
425
+ // honors the declared charset and produces "â??" garbage for
426
+ // every non-ASCII codepoint (em-dash, smart quotes, …). If
427
+ // the raw body bytes are valid UTF-8, rewrite the charset
428
+ // header before parsing. We only override the obviously-
429
+ // wrong legacy declarations; explicit utf-8 / koi8 / etc.
430
+ // pass through.
431
+ const adjusted = sniffAndFixCharset(raw);
432
+ const parsed = await simpleParser(adjusted);
433
+ bodyHtml = parsed.html || "";
434
+ bodyText = parsed.text || "";
435
+ attachments = (parsed.attachments || []).map((a, i) => ({
436
+ id: i,
437
+ filename: a.filename || `attachment-${i}`,
438
+ mimeType: a.contentType || "application/octet-stream",
439
+ size: a.size || 0,
440
+ contentId: a.contentId || ""
441
+ }));
442
+ }
443
+ // Sanitize HTML
444
+ if (bodyHtml && !allowRemote) {
445
+ const allowList = loadAllowlist();
446
+ const senderAddr = envelope.from?.address || "";
447
+ const senderDomain = senderAddr.split("@")[1] || "";
448
+ const toAddrs = (envelope.to || []).map((a) => a.address);
449
+ const isAllowed = allowList.senders.includes(senderAddr) ||
450
+ allowList.domains.includes(senderDomain) ||
451
+ toAddrs.some((a) => allowList.recipients?.includes(a));
452
+ if (isAllowed) {
453
+ allowRemote = true;
454
+ }
455
+ else {
456
+ const result = sanitizeHtml(bodyHtml);
457
+ bodyHtml = result.html;
458
+ hasRemoteContent = result.hasRemoteContent;
459
+ }
460
+ }
461
+ // Extract headers
462
+ let deliveredTo = "";
463
+ let returnPath = "";
464
+ let listUnsubscribe = "";
465
+ let listUnsubscribeMail = "";
466
+ let listUnsubscribeHttp = "";
467
+ let listUnsubscribeOneClick = false;
468
+ if (raw) {
469
+ const parsed2 = await simpleParser(raw);
470
+ const hdr = (key) => {
471
+ let v = parsed2.headers.get(key);
472
+ if (!v)
473
+ return "";
474
+ if (Array.isArray(v))
475
+ v = v[0];
476
+ if (typeof v === "string")
477
+ return v;
478
+ if (typeof v === "object" && v !== null) {
479
+ if ("text" in v)
480
+ return v.text || "";
481
+ if ("value" in v)
482
+ return String(v.value);
483
+ if ("address" in v)
484
+ return v.address || "";
485
+ }
486
+ return String(v);
487
+ };
488
+ const msgSettings = loadSettings();
489
+ const acctConfig = msgSettings.accounts.find((a) => a.id === accountId);
490
+ const relayDomains = acctConfig?.relayDomains || [];
491
+ const prefixes = acctConfig?.deliveredToPrefix || [];
492
+ const rawDelivered = parsed2.headers.get("delivered-to");
493
+ if (rawDelivered) {
494
+ const deliveredList = Array.isArray(rawDelivered) ? rawDelivered : [rawDelivered];
495
+ for (let i = deliveredList.length - 1; i >= 0; i--) {
496
+ const d = deliveredList[i];
497
+ const addr = typeof d === "string" ? d : d?.text || d?.address || String(d);
498
+ if (!relayDomains.some(rd => addr.includes(`@${rd}`))) {
499
+ deliveredTo = addr;
500
+ break;
501
+ }
502
+ }
503
+ if (!deliveredTo && deliveredList.length > 0) {
504
+ const d = deliveredList[deliveredList.length - 1];
505
+ deliveredTo = typeof d === "string" ? d : d?.text || d?.address || String(d);
506
+ }
507
+ if (deliveredTo && prefixes.length > 0) {
508
+ const [local, domain] = deliveredTo.split("@");
509
+ for (const prefix of prefixes) {
510
+ if (local.startsWith(prefix)) {
511
+ deliveredTo = `${local.slice(prefix.length)}@${domain}`;
512
+ break;
513
+ }
514
+ }
515
+ }
516
+ }
517
+ returnPath = hdr("return-path").replace(/[<>]/g, "");
518
+ ({ listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick } =
519
+ parseListUnsubscribe(parsed2.headers));
520
+ listUnsubscribe = listUnsubscribeHttp || listUnsubscribeMail;
521
+ }
522
+ // EML path: re-read the row after the fetch — `fetchMessageBody`
523
+ // writes the body to disk and updates `body_path` on success, but the
524
+ // `envelope` snapshot above pre-dates that write, so trusting it
525
+ // hides the Source button on every just-opened message.
526
+ const refreshed = this.db.getMessageByUid(accountId, uid, folderId);
527
+ const emlPath = refreshed?.bodyPath || envelope.bodyPath || "";
528
+ // Flag check — surfaced in the remote-content banner as a red
529
+ // warning when the sender's address or domain is on the user's
530
+ // flagged list. Cheap lookup; loadAllowlist is already cached.
531
+ const allowList = loadAllowlist();
532
+ const senderAddr = (envelope.from?.address || "").toLowerCase();
533
+ const senderDomain = senderAddr.split("@")[1] || "";
534
+ const isFlagged = !!((allowList.flaggedSenders || []).some((s) => (s || "").toLowerCase() === senderAddr) ||
535
+ (allowList.flaggedDomains || []).some((d) => (d || "").toLowerCase() === senderDomain));
536
+ // External reputation check — Spamhaus DBL + SURBL + URIBL in parallel.
537
+ // Off by default (privacy: the domain leaks to those DNSBLs and the
538
+ // user's local resolver). User opts in via Settings → "Check sender
539
+ // reputation". Each lookup is bounded at 500 ms; the whole check is
540
+ // bounded by the slowest, ~500 ms worst case.
541
+ let reputation = null;
542
+ const settings = loadSettings() || {};
543
+ if (settings.checkDomainReputation && senderDomain && hasRemoteContent) {
544
+ reputation = await this.checkDomainReputation(senderDomain);
545
+ }
546
+ return {
547
+ ...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote,
548
+ attachments, emlPath, deliveredTo, returnPath,
549
+ listUnsubscribe, listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick,
550
+ isFlagged,
551
+ reputation,
552
+ };
553
+ }
554
+ /** RFC 8058 one-click unsubscribe: POST `List-Unsubscribe=One-Click` to the
555
+ * HTTPS URL the message's List-Unsubscribe header advertised. Done server-
556
+ * side because the unsubscribe endpoint usually doesn't set CORS headers,
557
+ * so a browser-side fetch would be blocked. */
558
+ async unsubscribeOneClick(url) {
559
+ if (!/^https:\/\//i.test(url))
560
+ throw new Error("one-click unsubscribe requires an https URL");
561
+ // RFC 8058 POST with List-Unsubscribe=One-Click body. A User-Agent
562
+ // header appeases servers that reject anonymous clients as "malformed".
563
+ const headers = {
564
+ "Content-Type": "application/x-www-form-urlencoded",
565
+ "User-Agent": "mailx/1.0 (https://github.com/BobFrankston/mailx)",
566
+ };
567
+ let resp = await fetch(url, {
568
+ method: "POST",
569
+ headers,
570
+ body: "List-Unsubscribe=One-Click",
571
+ redirect: "follow",
572
+ });
573
+ // Some mailers advertise List-Unsubscribe-Post but their endpoint
574
+ // actually only handles GET (older RFC 2369 style). Fall back once
575
+ // on 4xx so the user doesn't have to open the URL manually.
576
+ if (!resp.ok && resp.status >= 400 && resp.status < 500) {
577
+ const body = await resp.text().catch(() => "");
578
+ console.log(` [unsub] POST ${url} → ${resp.status} ${resp.statusText}; body: ${body.slice(0, 200)}`);
579
+ try {
580
+ const fallback = await fetch(url, { method: "GET", headers, redirect: "follow" });
581
+ if (fallback.ok) {
582
+ return { ok: true, status: fallback.status, statusText: `${fallback.statusText} (via GET)` };
583
+ }
584
+ const fbody = await fallback.text().catch(() => "");
585
+ console.log(` [unsub] GET ${url} → ${fallback.status} ${fallback.statusText}; body: ${fbody.slice(0, 200)}`);
586
+ // Surface the server's own error so the UI shows the real reason.
587
+ return { ok: false, status: fallback.status, statusText: (fbody.trim().split("\n")[0] || fallback.statusText).slice(0, 200) };
588
+ }
589
+ catch { /* fall through to POST error */ }
590
+ return { ok: false, status: resp.status, statusText: (body.trim().split("\n")[0] || resp.statusText).slice(0, 200) };
591
+ }
592
+ return { ok: resp.ok, status: resp.status, statusText: resp.statusText };
593
+ }
594
+ // ── External edit in Microsoft Word ──
595
+ /** Per-session map: editId → temp file path + watcher cleanup.
596
+ * Lives in memory only — cleared when the user closes compose or sends. */
597
+ wordEdits = new Map();
598
+ /** Hand the current compose body off to Microsoft Word for editing. Writes
599
+ * the HTML to `~/.mailx/external-edit/<editId>.html`, opens it via the
600
+ * default OS handler (Word on Windows when .html is associated; otherwise
601
+ * the user's chosen editor for HTML), and starts an fs.watch that emits
602
+ * `wordEditUpdated` when Word saves. The compose UI listens for that
603
+ * event and reloads the editor.
604
+ *
605
+ * Windows-only by current default — on Mac/Linux there's no equivalent
606
+ * reliable round-trip. The compose toolbar should hide the button on
607
+ * non-win32 platforms. */
608
+ async openInWord(editId, html) {
609
+ const dir = path.join(getConfigDir(), "external-edit");
610
+ fs.mkdirSync(dir, { recursive: true });
611
+ const filePath = path.join(dir, `${editId}.html`);
612
+ // Wrap in a minimal HTML doc so Word picks up encoding + treats the
613
+ // body content as the document. Word imports the <body> contents and
614
+ // converts them to its own model; saving HTML preserves enough of
615
+ // the structure for re-import (paragraphs, links, basic formatting).
616
+ const wrapped = `<!doctype html>
617
+ <html><head><meta charset="utf-8"><title>mailx draft</title></head>
618
+ <body>${html || "<p></p>"}</body></html>
619
+ `;
620
+ fs.writeFileSync(filePath, wrapped, "utf-8");
621
+ // Stop any existing watcher for this edit (re-open re-arms cleanly).
622
+ this.wordEdits.get(editId)?.stop();
623
+ // Try Word explicitly first; on failure (Word not installed, exec not
624
+ // in PATH) fall back to the OS default handler so the user still gets
625
+ // *some* editor. Report which one ran so the UI can say "Opening in
626
+ // Word…" vs "Opening in default editor…".
627
+ //
628
+ // CRITICAL: must use async spawn (not spawnSync). spawnSync blocks
629
+ // the Node event loop until the spawned process exits — and on
630
+ // Windows, `cmd /c start ... <gui-app>` sometimes does not return
631
+ // immediately when the GUI app hangs around. That froze the entire
632
+ // mailx IPC bridge on Edit-in-Word click; subsequent clicks
633
+ // (Discard, X, anything) hung waiting for a response that never
634
+ // came back. Async spawn launches and returns immediately;
635
+ // success/failure of the GUI launch is invisible from here, but
636
+ // the file is written and the watcher is armed regardless.
637
+ const { spawn } = await import("node:child_process");
638
+ const tryLaunch = (cmd, args) => {
639
+ try {
640
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
641
+ child.on("error", () => { });
642
+ child.unref();
643
+ return true;
644
+ }
645
+ catch {
646
+ return false;
647
+ }
648
+ };
649
+ // Editor preference: settings.externalEditor in `~/.mailx/config.jsonc`
650
+ // can be "word" | "libreoffice" | "auto" (default). Auto means try
651
+ // Word first, then LibreOffice, then OS default — gives Word users the
652
+ // expected experience while still working when Word isn't installed.
653
+ // LibreOffice tends to round-trip email-shaped HTML cleaner than
654
+ // Word, so users on either platform may want to flip it via the
655
+ // config editor.
656
+ const settings = loadSettings() || {};
657
+ const pref = (settings.externalEditor || "auto");
658
+ const tryWord = () => {
659
+ if (process.platform === "win32")
660
+ return tryLaunch("cmd", ["/c", "start", "", "/B", "winword.exe", filePath]);
661
+ if (process.platform === "darwin")
662
+ return tryLaunch("open", ["-a", "Microsoft Word", filePath]);
663
+ return false; // no MS Word on Linux
664
+ };
665
+ const tryLibreOffice = () => {
666
+ if (process.platform === "win32")
667
+ return tryLaunch("cmd", ["/c", "start", "", "/B", "soffice.exe", "--writer", filePath]);
668
+ if (process.platform === "darwin")
669
+ return tryLaunch("open", ["-a", "LibreOffice", filePath]);
670
+ return tryLaunch("soffice", ["--writer", filePath]) || tryLaunch("libreoffice", ["--writer", filePath]);
671
+ };
672
+ const tryDefault = () => {
673
+ if (process.platform === "win32")
674
+ return tryLaunch("cmd", ["/c", "start", "", filePath]);
675
+ if (process.platform === "darwin")
676
+ return tryLaunch("open", [filePath]);
677
+ return tryLaunch("xdg-open", [filePath]);
678
+ };
679
+ let opener = "none";
680
+ const order = pref === "libreoffice"
681
+ ? [["libreoffice", tryLibreOffice], ["word", tryWord], ["default", tryDefault]]
682
+ : pref === "word"
683
+ ? [["word", tryWord], ["default", tryDefault]]
684
+ : [["word", tryWord], ["libreoffice", tryLibreOffice], ["default", tryDefault]]; // auto
685
+ for (const [name, fn] of order) {
686
+ if (fn()) {
687
+ opener = name;
688
+ break;
689
+ }
690
+ }
691
+ if (opener === "none") {
692
+ console.error(` [word-edit] no editor found on this platform — file written to ${filePath}`);
693
+ }
694
+ else {
695
+ console.log(` [word-edit] opened ${filePath} via ${opener}`);
696
+ }
697
+ // Watch for save events. fs.watch on Windows fires multiple events
698
+ // per save (rename + change for atomic replacement); debounce so the
699
+ // UI only reloads once per save. Watch the directory rather than the
700
+ // file directly because Word writes via temp-file rename, which can
701
+ // invalidate a file-level watch.
702
+ let debounce = null;
703
+ let lastSize = -1;
704
+ const watcher = fs.watch(dir, (eventType, name) => {
705
+ if (name !== `${editId}.html`)
706
+ return;
707
+ if (debounce)
708
+ clearTimeout(debounce);
709
+ debounce = setTimeout(() => {
710
+ let stat;
711
+ try {
712
+ stat = fs.statSync(filePath);
713
+ }
714
+ catch {
715
+ return;
716
+ }
717
+ // Skip duplicate events with the same size — Word fires several
718
+ // change notifications per save and we only want one reload.
719
+ if (stat.size === lastSize)
720
+ return;
721
+ lastSize = stat.size;
722
+ try {
723
+ const updatedHtml = fs.readFileSync(filePath, "utf-8");
724
+ // Strip the wrapper to extract just the body content.
725
+ const bodyMatch = updatedHtml.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
726
+ const inner = bodyMatch ? bodyMatch[1] : updatedHtml;
727
+ this.imapManager.emit("wordEditUpdated", { editId, html: inner });
728
+ }
729
+ catch (e) {
730
+ console.error(` [word-edit] read after save failed: ${e.message}`);
731
+ }
732
+ }, 300);
733
+ });
734
+ const stop = () => {
735
+ try {
736
+ watcher.close();
737
+ }
738
+ catch { /* */ }
739
+ if (debounce)
740
+ clearTimeout(debounce);
741
+ };
742
+ this.wordEdits.set(editId, { path: filePath, stop });
743
+ return { ok: opener !== "none", path: filePath, opener };
744
+ }
745
+ /** End external editing. Stops the watcher, removes the temp file.
746
+ * Caller is the compose UI when the user closes the window or sends. */
747
+ async closeWordEdit(editId) {
748
+ const entry = this.wordEdits.get(editId);
749
+ if (!entry)
750
+ return;
751
+ entry.stop();
752
+ this.wordEdits.delete(editId);
753
+ try {
754
+ fs.unlinkSync(entry.path);
755
+ }
756
+ catch { /* file already gone — fine */ }
757
+ }
758
+ async updateFlags(accountId, uid, flags) {
759
+ const envelope = this.db.getMessageByUid(accountId, uid);
760
+ await this.imapManager.updateFlagsLocal(accountId, uid, envelope?.folderId || 0, flags);
761
+ }
762
+ // ── Remote content allow-list ──
763
+ async allowRemoteContent(type, value) {
764
+ const list = loadAllowlist();
765
+ if (type === "sender" && !list.senders.includes(value))
766
+ list.senders.push(value);
767
+ else if (type === "domain" && !list.domains.includes(value))
768
+ list.domains.push(value);
769
+ else if (type === "recipient") {
770
+ if (!list.recipients)
771
+ list.recipients = [];
772
+ if (!list.recipients.includes(value))
773
+ list.recipients.push(value);
774
+ }
775
+ await saveAllowlist(list);
776
+ console.log(` [allow] Added ${type}: ${value}`);
777
+ }
778
+ /** Domain-reputation cache. Lookups are fast (~50ms each, three in
779
+ * parallel) but we still don't want to redo them on every render of
780
+ * the same sender's mail. Five-minute TTL — long enough that scrolling
781
+ * a folder fans out one query set, short enough that a newly-listed
782
+ * domain surfaces within minutes. */
783
+ reputationCache = new Map();
784
+ static REPUTATION_TTL_MS = 5 * 60_000;
785
+ static REPUTATION_TIMEOUT_MS = 500;
786
+ /** Check a domain against three free no-key DNS blocklists in parallel:
787
+ *
788
+ * Spamhaus DBL — `<d>.dbl.spamhaus.org` spam/phish/malware
789
+ * SURBL multi — `<d>.multi.surbl.org` mixed (ph/mw/abuse)
790
+ * URIBL multi — `<d>.multi.uribl.com` black/grey/red lists
791
+ *
792
+ * Each lookup is bounded at 500 ms; missing/slow services are treated
793
+ * as "unknown" (don't poison the cache). Returns the aggregate plus
794
+ * the per-service detail so the UI can show "N of 3 services flag
795
+ * this domain" with the contributing source list.
796
+ *
797
+ * Privacy: each query leaks the bare domain to that DNSBL's
798
+ * infrastructure plus the user's local resolver. Opt-in via Settings.
799
+ *
800
+ * No API keys, free for personal use across all three services. */
801
+ async checkDomainReputation(domain) {
802
+ domain = (domain || "").toLowerCase().trim();
803
+ if (!domain)
804
+ return null;
805
+ const cached = this.reputationCache.get(domain);
806
+ if (cached && cached.expiresAt > Date.now())
807
+ return cached.result;
808
+ const probe = async (service, host, mapVerdict) => {
809
+ try {
810
+ const lookup = dns.resolve4(`${domain}.${host}`);
811
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("dnsbl-timeout")), MailxService.REPUTATION_TIMEOUT_MS));
812
+ const records = await Promise.race([lookup, timeout]);
813
+ const last = records[0]?.split(".").pop() || "";
814
+ return { service, flagged: true, verdict: mapVerdict(last) };
815
+ }
816
+ catch (e) {
817
+ const code = e?.code || "";
818
+ if (code === "ENOTFOUND" || code === "ENODATA") {
819
+ return { service, flagged: false, verdict: "clean" };
820
+ }
821
+ return null; // timeout / network — unknown
822
+ }
823
+ };
824
+ const dblVerdict = (last) => last === "2" ? "spam" :
825
+ last === "4" ? "phishing" :
826
+ last === "5" ? "malware" :
827
+ last === "6" ? "botnet" :
828
+ "listed";
829
+ // SURBL/URIBL encode multiple list memberships in a bitfield; the
830
+ // distinction matters less to the end user than "how many sources
831
+ // agree", so we keep a generic "listed" verdict for both.
832
+ const generic = (_last) => "listed";
833
+ const sources = await Promise.all([
834
+ probe("Spamhaus DBL", "dbl.spamhaus.org", dblVerdict),
835
+ probe("SURBL", "multi.surbl.org", generic),
836
+ probe("URIBL", "multi.uribl.com", generic),
837
+ ]);
838
+ const known = sources.filter((s) => s !== null);
839
+ const flagged = known.filter(s => s.flagged);
840
+ const result = {
841
+ flagged: flagged.length > 0,
842
+ listedCount: flagged.length,
843
+ checkedCount: known.length,
844
+ sources: flagged,
845
+ // Pick the most specific verdict if Spamhaus contributed (since
846
+ // DBL distinguishes phishing/malware/etc); otherwise generic.
847
+ verdict: flagged.find(s => s.service === "Spamhaus DBL")?.verdict || flagged[0]?.verdict || "clean",
848
+ service: flagged.map(s => s.service).join(", ") || "Spamhaus DBL / SURBL / URIBL",
849
+ };
850
+ this.reputationCache.set(domain, { result, expiresAt: Date.now() + MailxService.REPUTATION_TTL_MS });
851
+ return result;
852
+ }
853
+ /** Mark a sender or domain as suspect. Surfaced in the remote-content
854
+ * banner as a red warning on subsequent messages. Toggle: calling with
855
+ * the same value removes it. Returns the new state for UI feedback. */
856
+ async flagSenderOrDomain(type, value) {
857
+ const list = loadAllowlist();
858
+ const key = type === "sender" ? "flaggedSenders" : "flaggedDomains";
859
+ if (!Array.isArray(list[key]))
860
+ list[key] = [];
861
+ const lower = (value || "").toLowerCase();
862
+ const idx = list[key].findIndex((v) => (v || "").toLowerCase() === lower);
863
+ if (idx >= 0) {
864
+ list[key].splice(idx, 1);
865
+ await saveAllowlist(list);
866
+ console.log(` [flag] Removed ${type}: ${value}`);
867
+ return { flagged: false };
868
+ }
869
+ list[key].push(value);
870
+ await saveAllowlist(list);
871
+ console.log(` [flag] Added ${type}: ${value}`);
872
+ return { flagged: true };
873
+ }
874
+ // ── Search ──
875
+ async search(q, page = 1, pageSize = 50, scope = "all", accountId, folderId) {
876
+ q = (q || "").trim();
877
+ if (!q)
878
+ return { items: [], total: 0, page, pageSize };
879
+ if (scope === "server") {
880
+ // Parse qualifiers once; SEARCH runs per folder.
881
+ const criteria = {};
882
+ const fromMatch = q.match(/from:(\S+)/i);
883
+ const toMatch = q.match(/to:(\S+)/i);
884
+ const subjectMatch = q.match(/subject:(.+?)(?:\s+\w+:|$)/i);
885
+ const bodyText = q.replace(/(?:from|to|subject):\S+/gi, "").trim();
886
+ if (fromMatch)
887
+ criteria.from = fromMatch[1];
888
+ if (toMatch)
889
+ criteria.to = toMatch[1];
890
+ if (subjectMatch)
891
+ criteria.subject = subjectMatch[1].trim();
892
+ if (bodyText)
893
+ criteria.body = bodyText;
894
+ // Server search spans every selectable folder on every enabled
895
+ // account — otherwise a message that got moved / was in Sent /
896
+ // only exists in an archive folder silently fails to turn up.
897
+ // Each folder runs as its own SEARCH; we dedupe by messageId.
898
+ const dbAccounts = accountId
899
+ ? [{ id: accountId }]
900
+ : this.db.getAccounts();
901
+ const seen = new Set();
902
+ const items = [];
903
+ let total = 0;
904
+ for (const acct of dbAccounts) {
905
+ const folders = this.db.getFolders(acct.id)
906
+ .filter((f) => !(f.flags || []).some((x) => /noselect/i.test(x)));
907
+ const results = await Promise.allSettled(folders.map(f => this.imapManager.searchAndFetchOnServer(acct.id, f.id, f.path, criteria)
908
+ .then(uids => ({ folderId: f.id, uids }))));
909
+ for (const r of results) {
910
+ if (r.status !== "fulfilled")
911
+ continue;
912
+ for (const uid of r.value.uids) {
913
+ const msg = this.db.getMessageByUid(acct.id, uid, r.value.folderId);
914
+ if (!msg)
915
+ continue;
916
+ const key = msg.messageId || `${acct.id}:${r.value.folderId}:${uid}`;
917
+ if (seen.has(key))
918
+ continue;
919
+ seen.add(key);
920
+ items.push(msg);
921
+ total++;
922
+ }
923
+ }
924
+ }
925
+ // Newest first, then paginate.
926
+ items.sort((a, b) => (b.date?.getTime?.() || 0) - (a.date?.getTime?.() || 0));
927
+ const sliced = items.slice((page - 1) * pageSize, page * pageSize);
928
+ return { items: sliced, total, page, pageSize };
929
+ }
930
+ else if (scope === "current" && accountId && folderId) {
931
+ return this.db.searchMessages(q, page, pageSize, accountId, folderId);
932
+ }
933
+ else {
934
+ return this.db.searchMessages(q, page, pageSize);
935
+ }
936
+ }
937
+ rebuildSearchIndex() {
938
+ const count = this.db.rebuildSearchIndex();
939
+ console.log(` Rebuilt search index: ${count} messages`);
940
+ return count;
941
+ }
942
+ // ── Sync ──
943
+ getSyncPending() {
944
+ return { pending: this.db.getTotalPendingSyncCount() };
945
+ }
946
+ /** Outbox queue depth + retry status for the UI status bar. Cheap to call. */
947
+ getOutboxStatus() {
948
+ return this.imapManager.getOutboxStatus();
949
+ }
950
+ /** Per-account health snapshot: inactivity-timeout count, conn-cap hits,
951
+ * last failed IMAP command. Drives the diagnostics ⚠ badge in the UI. */
952
+ getDiagnostics() {
953
+ return this.imapManager.getDiagnosticsSnapshot();
954
+ }
955
+ /** Return the account that supplies `feature` data (calendar / tasks /
956
+ * contacts). Resolution order:
957
+ * 1. Any account with `primary<Feature>: true` (per-feature override)
958
+ * 2. Any account with `primary: true` (catch-all default)
959
+ * 3. First account (fallback)
960
+ * Called without `feature` it returns the catch-all primary — same
961
+ * semantics as the original single-flag version for back-compat. */
962
+ getPrimaryAccount(feature) {
963
+ const all = this.getAccounts();
964
+ if (feature) {
965
+ const perFeatureKey = "primary" + feature.charAt(0).toUpperCase() + feature.slice(1);
966
+ const perFeature = all.find((a) => a[perFeatureKey]);
967
+ if (perFeature)
968
+ return perFeature;
969
+ }
970
+ return all.find((a) => a.primary) || all[0] || null;
971
+ }
972
+ // ── Calendar / Tasks / Contacts: two-way cache (2026-04-23) ──
973
+ /** Feature names that have already emitted authScopeError this session.
974
+ * Stops the "banner flashing on and off continually" loop where every
975
+ * 5-min poll / sidebar nav re-fired the event and the client re-rendered
976
+ * the red banner. Cleared when the user hits Re-authenticate. */
977
+ scopeErrorEmitted = new Set();
978
+ /** Quota cooldown — feature → epoch-ms when the next API call is allowed.
979
+ * Set when Google returns 429 (rate limit / daily-quota exceeded). While
980
+ * cooldown is in effect, getCalendarEvents/getTasks return local DB rows
981
+ * without firing a refresh. Heuristic cooldown is one hour; the daily
982
+ * Google Tasks quota actually resets at Pacific midnight, but a one-hour
983
+ * short-circuit keeps the log clean and avoids hammering after a burst. */
984
+ quotaCooldown = new Map();
985
+ /** Sticky "quota exceeded" emit guard — same shape as scopeErrorEmitted. */
986
+ quotaErrorEmitted = new Set();
987
+ /** In-flight refresh promises keyed by feature, so concurrent UI calls
988
+ * share one Google round-trip instead of stacking N parallel fetches.
989
+ * The fire-and-forget loop where `tasksUpdated` re-triggers `getTasks`
990
+ * used to spawn a new refresh on every event RTT — this dedupes them. */
991
+ refreshingCalendar = new Map();
992
+ refreshingTasks = new Map();
993
+ /** Delete the cached Google OAuth token (the one used for Calendar / Tasks
994
+ * / Contacts scopes — NOT the IMAP token which `reauthenticate()` handles)
995
+ * and clear the sticky auth-error state so a subsequent refresh can
996
+ * re-trigger browser consent with the current scope set. Equivalent of
997
+ * `mailx -reauth` but callable from the UI. Returns `{ cleared: N }` so
998
+ * the caller can tell the user what happened. */
999
+ reauthGoogleScopes() {
1000
+ const tokensDir = path.join(getConfigDir(), "tokens");
1001
+ let cleared = 0;
1002
+ if (fs.existsSync(tokensDir)) {
1003
+ for (const entry of fs.readdirSync(tokensDir)) {
1004
+ const userDir = path.join(tokensDir, entry);
1005
+ try {
1006
+ if (!fs.statSync(userDir).isDirectory())
1007
+ continue;
1008
+ const tokenFile = path.join(userDir, "oauth-token.json");
1009
+ if (fs.existsSync(tokenFile)) {
1010
+ fs.unlinkSync(tokenFile);
1011
+ console.log(` [reauth-google] cleared ${tokenFile}`);
1012
+ cleared++;
1013
+ }
1014
+ }
1015
+ catch { /* skip */ }
1016
+ }
1017
+ }
1018
+ // Reset the sticky set so the next failure (if re-consent didn't take)
1019
+ // can fire a fresh banner. Also trigger a kickoff refresh so the
1020
+ // browser consent pops open now instead of on next sidebar nav.
1021
+ this.scopeErrorEmitted.clear();
1022
+ this.quotaCooldown.clear();
1023
+ this.quotaErrorEmitted.clear();
1024
+ const now = Date.now();
1025
+ const horizonMs = 90 * 86400_000;
1026
+ this.getCalendarEvents(now, now + horizonMs); // fire-and-forget — triggers consent via primaryTokenProvider
1027
+ this.getTasks(false); // same path, `tasks` scope
1028
+ return { cleared };
1029
+ }
1030
+ async primaryTokenProvider(feature) {
1031
+ const acct = this.getPrimaryAccount(feature);
1032
+ if (!acct)
1033
+ throw new Error(`No primary account for ${feature}`);
1034
+ return async () => {
1035
+ const tok = await this.imapManager.getOAuthToken(acct.id);
1036
+ if (!tok)
1037
+ throw new Error(`No OAuth token for ${acct.id}`);
1038
+ return tok;
1039
+ };
1040
+ }
1041
+ /** Return cal events visible in [fromMs..toMs), refreshing from Google
1042
+ * in the background. Caller displays local results immediately; after
1043
+ * the refresh completes the service emits `calendarUpdated` so the UI
1044
+ * re-renders with pulled-in rows. Fire-and-forget-with-event, not
1045
+ * fire-and-forget-and-pray. */
1046
+ async getCalendarEvents(fromMs, toMs) {
1047
+ const acct = this.getPrimaryAccount("calendar");
1048
+ if (!acct)
1049
+ return [];
1050
+ const acctId = acct.id;
1051
+ // Skip the network entirely while in quota cooldown — return DB rows.
1052
+ if (!this.inQuotaCooldown("calendar")) {
1053
+ let promise = this.refreshingCalendar.get(acctId);
1054
+ if (!promise) {
1055
+ promise = this.refreshCalendarEvents(acctId, fromMs, toMs)
1056
+ .finally(() => this.refreshingCalendar.delete(acctId));
1057
+ this.refreshingCalendar.set(acctId, promise);
1058
+ promise
1059
+ .then(changed => {
1060
+ if (changed)
1061
+ this.imapManager.emit("calendarUpdated", { accountId: acctId });
1062
+ })
1063
+ .catch(e => this.handleGoogleRefreshError("calendar", e));
1064
+ }
1065
+ }
1066
+ return this.db.getCalendarEvents(acctId, fromMs, toMs);
1067
+ }
1068
+ /** Returns true if the feature is currently in a quota-exceeded cooldown. */
1069
+ inQuotaCooldown(feature) {
1070
+ const until = this.quotaCooldown.get(feature);
1071
+ if (!until)
1072
+ return false;
1073
+ if (Date.now() < until)
1074
+ return true;
1075
+ this.quotaCooldown.delete(feature);
1076
+ this.quotaErrorEmitted.delete(feature);
1077
+ return false;
1078
+ }
1079
+ /** Single error-handling path for Google refresh failures.
1080
+ * Distinguishes 429 (quota) from 401/403 (scope) so each gets the right
1081
+ * cooldown + sticky-emit treatment without duplicating the regex blocks. */
1082
+ handleGoogleRefreshError(feature, e) {
1083
+ const msg = String(e?.message || e);
1084
+ const status = e instanceof gsync.GoogleHttpError ? e.status : 0;
1085
+ const is429 = status === 429 || /\b429\b|rateLimitExceeded|quotaExceeded|userRateLimitExceeded/i.test(msg);
1086
+ const isScope = !is429 && (status === 401 || status === 403
1087
+ || /insufficient (authentication )?scope|PERMISSION_DENIED|\b403\b/i.test(msg));
1088
+ console.error(`[${feature}] refresh failed: ${msg}`);
1089
+ if (is429) {
1090
+ const cooldownMs = 60 * 60_000; // one hour heuristic
1091
+ this.quotaCooldown.set(feature, Date.now() + cooldownMs);
1092
+ if (!this.quotaErrorEmitted.has(feature)) {
1093
+ this.quotaErrorEmitted.add(feature);
1094
+ this.imapManager.emit("quotaError", {
1095
+ feature,
1096
+ message: `Google ${feature} daily quota exceeded — try again later.`,
1097
+ untilMs: Date.now() + cooldownMs,
1098
+ });
1099
+ }
1100
+ return;
1101
+ }
1102
+ if (isScope) {
1103
+ if (!this.scopeErrorEmitted.has(feature)) {
1104
+ this.scopeErrorEmitted.add(feature);
1105
+ const labels = {
1106
+ calendar: "Google Calendar", tasks: "Google Tasks", contacts: "Google Contacts",
1107
+ };
1108
+ this.imapManager.emit("authScopeError", {
1109
+ feature,
1110
+ message: `${labels[feature] || feature} access needs re-consent.`,
1111
+ });
1112
+ }
1113
+ }
1114
+ }
1115
+ /** Pull events in [fromMs..toMs) from Google, upsert locally, reconcile
1116
+ * server-side deletions. Returns true if anything changed so callers
1117
+ * can decide whether to emit a refresh event. `changed` is only true
1118
+ * when at least one row's data actually differs — without this guard
1119
+ * the UI's `calendarUpdated` listener re-triggers `getCalendarEvents`,
1120
+ * which fires another `refreshCalendarEvents`, which emits again, etc.
1121
+ * Tight loop = 429 quota burn. */
1122
+ async refreshCalendarEvents(accountId, fromMs, toMs) {
1123
+ const tp = await this.primaryTokenProvider("calendar");
1124
+ const events = await gsync.listCalendarEvents(tp, fromMs, toMs);
1125
+ console.log(` [calendar] pulled ${events.length} events from ${new Date(fromMs).toISOString().slice(0, 10)} to ${new Date(toMs).toISOString().slice(0, 10)}`);
1126
+ let changed = false;
1127
+ // Upsert by provider_id — dedup globally, not just within the window,
1128
+ // so an event whose start moves outside the prior query range doesn't
1129
+ // get a second row on the next pull.
1130
+ const seenProviderIds = new Set();
1131
+ for (const ev of events) {
1132
+ const local = gsync.calendarEventToLocal(ev, accountId);
1133
+ seenProviderIds.add(ev.id);
1134
+ const existing = this.db.getCalendarEventByProviderId(accountId, ev.id);
1135
+ if (existing && calendarRowEquals(existing, local))
1136
+ continue;
1137
+ this.db.upsertCalendarEvent({ uuid: existing?.uuid, ...local });
1138
+ changed = true;
1139
+ }
1140
+ // Server-side delete reconciliation: any local non-dirty row whose
1141
+ // start falls in the queried window and whose provider_id wasn't
1142
+ // returned must have been deleted on Google. Purge it. Dirty rows
1143
+ // are local-only edits that haven't been pushed yet — don't touch.
1144
+ const localWindow = this.db.getCalendarEvents(accountId, fromMs, toMs);
1145
+ for (const row of localWindow) {
1146
+ if (!row.providerId)
1147
+ continue; // local-only, never pushed
1148
+ if (row.dirty)
1149
+ continue; // locally edited, pending push
1150
+ if (seenProviderIds.has(row.providerId))
1151
+ continue;
1152
+ this.db.purgeCalendarEvent(row.uuid);
1153
+ changed = true;
1154
+ }
1155
+ return changed;
1156
+ }
1157
+ async createCalendarEventLocal(ev) {
1158
+ const acct = this.getPrimaryAccount("calendar");
1159
+ if (!acct)
1160
+ throw new Error("No primary calendar account");
1161
+ const uuid = this.db.upsertCalendarEvent({
1162
+ accountId: acct.id, ...ev, dirty: true,
1163
+ });
1164
+ this.db.enqueueStoreSync("calendar", "create", acct.id, uuid, ev);
1165
+ this.drainStoreSync().catch(() => { });
1166
+ return uuid;
1167
+ }
1168
+ async updateCalendarEventLocal(uuid, patch) {
1169
+ // Merge with existing row before writing so partial patches don't
1170
+ // null-out unspecified fields in upsert.
1171
+ const existing = this.db.getCalendarEventByUuid(uuid);
1172
+ if (!existing)
1173
+ throw new Error(`No calendar event ${uuid}`);
1174
+ this.db.upsertCalendarEvent({
1175
+ uuid, accountId: existing.accountId, providerId: existing.providerId,
1176
+ calendarId: existing.calendarId, dirty: true,
1177
+ title: patch.title ?? existing.title,
1178
+ startMs: patch.startMs ?? existing.startMs,
1179
+ endMs: patch.endMs ?? existing.endMs,
1180
+ allDay: patch.allDay ?? existing.allDay,
1181
+ location: patch.location ?? existing.location,
1182
+ notes: patch.notes ?? existing.notes,
1183
+ });
1184
+ this.db.enqueueStoreSync("calendar", "update", existing.accountId, uuid, { providerId: existing.providerId, patch });
1185
+ this.drainStoreSync().catch(() => { });
1186
+ }
1187
+ async deleteCalendarEventLocal(uuid) {
1188
+ const ev = this.db.getCalendarEventByUuid(uuid);
1189
+ if (!ev)
1190
+ return;
1191
+ this.db.deleteCalendarEventLocal(uuid);
1192
+ if (ev.providerId) {
1193
+ this.db.enqueueStoreSync("calendar", "delete", ev.accountId, uuid, { providerId: ev.providerId });
1194
+ this.drainStoreSync().catch(() => { });
1195
+ }
1196
+ else {
1197
+ // Never made it to the server; just purge locally.
1198
+ this.db.purgeCalendarEvent(uuid);
1199
+ }
1200
+ }
1201
+ async getTasks(includeCompleted = false) {
1202
+ const acct = this.getPrimaryAccount("tasks");
1203
+ if (!acct)
1204
+ return [];
1205
+ const acctId = acct.id;
1206
+ if (!this.inQuotaCooldown("tasks")) {
1207
+ const key = `${acctId}:${includeCompleted ? 1 : 0}`;
1208
+ let promise = this.refreshingTasks.get(key);
1209
+ if (!promise) {
1210
+ promise = this.refreshTasks(acctId, includeCompleted)
1211
+ .finally(() => this.refreshingTasks.delete(key));
1212
+ this.refreshingTasks.set(key, promise);
1213
+ promise
1214
+ .then(changed => {
1215
+ if (changed)
1216
+ this.imapManager.emit("tasksUpdated", { accountId: acctId });
1217
+ })
1218
+ .catch(e => this.handleGoogleRefreshError("tasks", e));
1219
+ }
1220
+ }
1221
+ return this.db.getTasks(acctId, includeCompleted);
1222
+ }
1223
+ async refreshTasks(accountId, includeCompleted) {
1224
+ const tp = await this.primaryTokenProvider("tasks");
1225
+ const tasks = await gsync.listTasks(tp, "@default", includeCompleted);
1226
+ console.log(` [tasks] pulled ${tasks.length} tasks`);
1227
+ const existing = this.db.getTasks(accountId, true);
1228
+ let changed = false;
1229
+ const seen = new Set();
1230
+ for (const t of tasks) {
1231
+ const local = gsync.taskToLocal(t, accountId);
1232
+ const prior = existing.find(e => e.providerId === t.id);
1233
+ seen.add(t.id);
1234
+ // Skip the upsert when nothing actually differs. Otherwise every
1235
+ // refresh emits `tasksUpdated`, the UI listener calls `getTasks`,
1236
+ // which fires another `refreshTasks` — tight loop, 429 quota burn.
1237
+ if (prior && taskRowEquals(prior, local))
1238
+ continue;
1239
+ this.db.upsertTask({ uuid: prior?.uuid, ...local });
1240
+ changed = true;
1241
+ }
1242
+ // Server-side delete reconciliation: any non-dirty local task whose
1243
+ // provider_id wasn't returned has been deleted on Google. Purge.
1244
+ for (const row of existing) {
1245
+ if (!row.providerId || row.dirty)
1246
+ continue;
1247
+ if (seen.has(row.providerId))
1248
+ continue;
1249
+ this.db.purgeTask(row.uuid);
1250
+ changed = true;
1251
+ }
1252
+ return changed;
1253
+ }
1254
+ async createTaskLocal(t) {
1255
+ const acct = this.getPrimaryAccount("tasks");
1256
+ if (!acct)
1257
+ throw new Error("No primary tasks account");
1258
+ const uuid = this.db.upsertTask({ accountId: acct.id, ...t, dirty: true });
1259
+ this.db.enqueueStoreSync("tasks", "create", acct.id, uuid, t);
1260
+ this.drainStoreSync().catch(() => { });
1261
+ return uuid;
1262
+ }
1263
+ async updateTaskLocal(uuid, patch) {
1264
+ const existing = this.db.getTaskByUuid(uuid);
1265
+ if (!existing)
1266
+ throw new Error(`No task ${uuid}`);
1267
+ this.db.upsertTask({
1268
+ uuid, accountId: existing.accountId, providerId: existing.providerId,
1269
+ listId: existing.listId, dirty: true,
1270
+ title: patch.title ?? existing.title,
1271
+ notes: patch.notes ?? existing.notes,
1272
+ dueMs: patch.dueMs ?? existing.dueMs,
1273
+ completedMs: patch.completedMs ?? existing.completedMs,
1274
+ });
1275
+ this.db.enqueueStoreSync("tasks", "update", existing.accountId, uuid, { providerId: existing.providerId, patch });
1276
+ this.drainStoreSync().catch(() => { });
1277
+ }
1278
+ async deleteTaskLocal(uuid) {
1279
+ const task = this.db.getTaskByUuid(uuid);
1280
+ if (!task)
1281
+ return;
1282
+ this.db.deleteTaskLocal(uuid);
1283
+ if (task.providerId) {
1284
+ this.db.enqueueStoreSync("tasks", "delete", task.accountId, uuid, { providerId: task.providerId });
1285
+ this.drainStoreSync().catch(() => { });
1286
+ }
1287
+ else {
1288
+ this.db.purgeTask(uuid);
1289
+ }
1290
+ }
1291
+ /** Drain the store_sync queue — calendar / tasks / contacts push-to-server.
1292
+ * Called on every local edit, and on a periodic tick from the outbox worker. */
1293
+ async drainStoreSync() {
1294
+ const queue = this.db.getStoreSyncQueue();
1295
+ for (const entry of queue) {
1296
+ try {
1297
+ if (entry.kind === "calendar") {
1298
+ const tp = await this.primaryTokenProvider("calendar");
1299
+ if (entry.op === "create") {
1300
+ const created = await gsync.createCalendarEvent(tp, gsync.localToCalendarEvent(entry.payload));
1301
+ this.db.markCalendarEventClean(entry.targetUuid, created.id, created.etag || "");
1302
+ }
1303
+ else if (entry.op === "update") {
1304
+ const updated = await gsync.updateCalendarEvent(tp, entry.payload.providerId, gsync.localToCalendarEvent(entry.payload.patch));
1305
+ this.db.markCalendarEventClean(entry.targetUuid, updated.id, updated.etag || "");
1306
+ }
1307
+ else if (entry.op === "delete") {
1308
+ await gsync.deleteCalendarEvent(tp, entry.payload.providerId);
1309
+ this.db.purgeCalendarEvent(entry.targetUuid);
1310
+ }
1311
+ }
1312
+ else if (entry.kind === "tasks") {
1313
+ const tp = await this.primaryTokenProvider("tasks");
1314
+ if (entry.op === "create") {
1315
+ const created = await gsync.createTask(tp, gsync.localToTask(entry.payload));
1316
+ this.db.markTaskClean(entry.targetUuid, created.id, created.etag || "");
1317
+ }
1318
+ else if (entry.op === "update") {
1319
+ const updated = await gsync.updateTask(tp, entry.payload.providerId, gsync.localToTask(entry.payload.patch));
1320
+ this.db.markTaskClean(entry.targetUuid, updated.id, updated.etag || "");
1321
+ }
1322
+ else if (entry.op === "delete") {
1323
+ await gsync.deleteTask(tp, entry.payload.providerId);
1324
+ this.db.purgeTask(entry.targetUuid);
1325
+ }
1326
+ }
1327
+ else if (entry.kind === "contacts") {
1328
+ const tp = await this.primaryTokenProvider("contacts");
1329
+ if (entry.op === "create") {
1330
+ await gsync.createContact(tp, entry.payload);
1331
+ }
1332
+ else if (entry.op === "update") {
1333
+ await gsync.updateContact(tp, entry.payload.resourceName, entry.payload.updatePersonFields || "names,emailAddresses", entry.payload.person);
1334
+ }
1335
+ else if (entry.op === "delete") {
1336
+ await gsync.deleteContact(tp, entry.payload.resourceName);
1337
+ }
1338
+ }
1339
+ this.db.completeStoreSync(entry.id);
1340
+ }
1341
+ catch (e) {
1342
+ console.error(`[store_sync] ${entry.kind}/${entry.op}/${entry.targetUuid} failed: ${e.message}`);
1343
+ this.db.failStoreSync(entry.id, e.message || String(e));
1344
+ }
1345
+ }
1346
+ }
1347
+ /** List queued outgoing messages with parsed envelope headers so the UI
1348
+ * can render a pink-row "pending" view before IMAP APPEND succeeds. */
1349
+ listQueuedOutgoing() {
1350
+ const configDir = getConfigDir();
1351
+ const outboxRoot = path.join(configDir, "outbox");
1352
+ const sendingRoot = path.join(configDir, "sending");
1353
+ const out = [];
1354
+ const parseEnv = (raw, file, dir, accountId) => {
1355
+ const headerEnd = raw.search(/\r?\n\r?\n/);
1356
+ const headers = headerEnd >= 0 ? raw.slice(0, headerEnd) : raw;
1357
+ const get = (name) => {
1358
+ const re = new RegExp(`^${name}:\\s*(.+(?:\\r?\\n\\s+.+)*)`, "mi");
1359
+ const m = headers.match(re);
1360
+ return m ? m[1].replace(/\r?\n\s+/g, " ").trim() : "";
1361
+ };
1362
+ const retries = (headers.match(/^X-Mailx-Retry:/gmi) || []).length;
1363
+ const st = (() => { try {
1364
+ return fs.statSync(path.join(dir, file));
1365
+ }
1366
+ catch {
1367
+ return null;
1368
+ } })();
1369
+ return {
1370
+ accountId,
1371
+ file,
1372
+ path: path.join(dir, file),
1373
+ dir,
1374
+ from: get("From"),
1375
+ to: get("To"),
1376
+ cc: get("Cc"),
1377
+ bcc: get("Bcc"),
1378
+ subject: get("Subject"),
1379
+ date: get("Date"),
1380
+ messageId: get("Message-ID"),
1381
+ attempts: retries,
1382
+ sizeBytes: st?.size || 0,
1383
+ createdAt: st?.mtimeMs || 0,
1384
+ claimed: /\.sending-[^-]+-\d+$/.test(file),
1385
+ };
1386
+ };
1387
+ const scanDir = (accountId, dir) => {
1388
+ if (!fs.existsSync(dir))
1389
+ return;
1390
+ for (const f of fs.readdirSync(dir)) {
1391
+ if (!f.endsWith(".ltr") && !f.endsWith(".eml") && !/\.sending-/.test(f))
1392
+ continue;
1393
+ const fp = path.join(dir, f);
1394
+ try {
1395
+ const raw = fs.readFileSync(fp, "utf-8");
1396
+ out.push(parseEnv(raw, f, dir, accountId));
1397
+ }
1398
+ catch (err) {
1399
+ // Unreadable file — still show it so the user can cancel.
1400
+ // Previously silently skipped, which produced the user-reported
1401
+ // "outbox badge shows 1 but the modal is empty" symptom:
1402
+ // getOutboxStatus counted the file, listQueuedOutgoing dropped it.
1403
+ const st = (() => { try {
1404
+ return fs.statSync(fp);
1405
+ }
1406
+ catch {
1407
+ return null;
1408
+ } })();
1409
+ out.push({
1410
+ accountId,
1411
+ file: f,
1412
+ path: fp,
1413
+ dir,
1414
+ from: "",
1415
+ to: "",
1416
+ cc: "",
1417
+ bcc: "",
1418
+ subject: `[unreadable: ${err?.code || err?.message || "read failed"}]`,
1419
+ date: "",
1420
+ messageId: "",
1421
+ attempts: 0,
1422
+ sizeBytes: st?.size || 0,
1423
+ createdAt: st?.mtimeMs || 0,
1424
+ claimed: /\.sending-[^-]+-\d+$/.test(f),
1425
+ });
1426
+ }
1427
+ }
1428
+ };
1429
+ try {
1430
+ if (fs.existsSync(outboxRoot)) {
1431
+ for (const acct of fs.readdirSync(outboxRoot))
1432
+ scanDir(acct, path.join(outboxRoot, acct));
1433
+ }
1434
+ if (fs.existsSync(sendingRoot)) {
1435
+ for (const acct of fs.readdirSync(sendingRoot))
1436
+ scanDir(acct, path.join(sendingRoot, acct, "queued"));
1437
+ }
1438
+ }
1439
+ catch { /* */ }
1440
+ out.sort((a, b) => b.createdAt - a.createdAt);
1441
+ return out;
1442
+ }
1443
+ /** Manually drop a queued message (not yet sent). Removes the .ltr file. */
1444
+ cancelQueuedOutgoing(filePath) {
1445
+ // Safety: refuse anything outside the ~/.mailx tree.
1446
+ const dir = getConfigDir();
1447
+ if (!filePath.startsWith(dir))
1448
+ throw new Error("path outside mailx data dir");
1449
+ if (fs.existsSync(filePath))
1450
+ fs.unlinkSync(filePath);
1451
+ return { ok: true };
1452
+ }
1453
+ async syncAll() {
1454
+ await this.imapManager.syncAll();
1455
+ }
1456
+ async syncAccount(accountId) {
1457
+ const folders = await this.imapManager.syncFolders(accountId);
1458
+ folders.sort((a, b) => {
1459
+ if (a.specialUse === "inbox")
1460
+ return -1;
1461
+ if (b.specialUse === "inbox")
1462
+ return 1;
1463
+ return 0;
1464
+ });
1465
+ for (const folder of folders) {
1466
+ try {
1467
+ await this.imapManager.syncFolder(accountId, folder.id);
1468
+ }
1469
+ catch (e) {
1470
+ console.error(` Skipping folder ${folder.path}: ${e.message}`);
1471
+ }
1472
+ }
1473
+ }
1474
+ /** Force re-authentication for an account (deletes token, opens browser consent) */
1475
+ async reauthenticate(accountId) {
1476
+ return this.imapManager.reauthenticate(accountId);
1477
+ }
1478
+ // ── Send ──
1479
+ async send(msg) {
1480
+ // Local-first: the critical path is validate → build raw → queue
1481
+ // locally. Everything else (contacts recording, IMAP APPEND,
1482
+ // SMTP) happens after the IPC ACK. Settings come from cache so
1483
+ // a stalled GDrive mount doesn't block the send.
1484
+ const t0 = Date.now();
1485
+ const lap = (label) => console.log(` [send] +${Date.now() - t0}ms ${label}`);
1486
+ console.log(` [send] ENTRY from=${msg?.from} to=${JSON.stringify(msg?.to)} subject="${msg?.subject}" attachments=${msg?.attachments?.length || 0}`);
1487
+ const accounts = this.getCachedAccounts();
1488
+ let account = accounts.find(a => a.id === msg.from);
1489
+ if (!account) {
1490
+ // Cache miss — invalidate and try one authoritative read.
1491
+ this._accountsCache = null;
1492
+ account = this.getCachedAccounts().find(a => a.id === msg.from);
1493
+ }
1494
+ if (!account) {
1495
+ const ids = accounts.map(a => a.id).join(", ");
1496
+ console.error(` [send] FAIL: Unknown account "${msg.from}". Known accounts: [${ids}]`);
1497
+ throw new Error(`Unknown account: ${msg.from}`);
1498
+ }
1499
+ lap("account resolved");
1500
+ // Vet every recipient address — refuse to send if any field contains a
1501
+ // non-email (e.g. "Bob Frankston <Bob Frankston>" from a bad contact
1502
+ // autocomplete). This catches garbage BEFORE it hits SMTP, where the
1503
+ // server would either accept-and-bounce or reject the whole envelope.
1504
+ const emailRe = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
1505
+ const validateList = (label, list) => {
1506
+ if (!list)
1507
+ return;
1508
+ for (const a of list) {
1509
+ const addr = (a?.address || "").trim();
1510
+ if (!addr)
1511
+ throw new Error(`${label} has an empty address`);
1512
+ if (!emailRe.test(addr))
1513
+ throw new Error(`${label} has an invalid address: "${addr}"${a?.name ? ` (displayed as "${a.name}")` : ""}`);
1514
+ }
1515
+ };
1516
+ validateList("To", msg.to);
1517
+ validateList("Cc", msg.cc);
1518
+ validateList("Bcc", msg.bcc);
1519
+ if (!msg.to?.length)
1520
+ throw new Error("No To recipients");
1521
+ // Extract bare email from fromAddress (may be "Name <addr>" or just "addr")
1522
+ let fromAddr = msg.fromAddress || account.email;
1523
+ const angleMatch = fromAddr.match(/<([^>]+)>/);
1524
+ if (angleMatch)
1525
+ fromAddr = angleMatch[1];
1526
+ if (!emailRe.test(fromAddr))
1527
+ throw new Error(`From address is not a valid email: "${fromAddr}"`);
1528
+ const fromHeader = `${account.name} <${fromAddr}>`;
1529
+ const to = msg.to.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
1530
+ const cc = msg.cc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
1531
+ const bcc = msg.bcc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
1532
+ // HTML-bodied mail gets a text/plain alternative part too — spam
1533
+ // filters (SpamAssassin / Rspamd / Google) penalise HTML-only mail
1534
+ // by 1-2 points, and plain-text-only readers still exist. The text
1535
+ // part is derived from the HTML via htmlToPlainText when the caller
1536
+ // didn't supply an explicit bodyText.
1537
+ const hasHtml = !!msg.bodyHtml;
1538
+ const htmlBody = msg.bodyHtml || "";
1539
+ const textBody = msg.bodyText || (hasHtml ? htmlToPlainText(htmlBody) : "");
1540
+ const htmlEncoded = hasHtml ? encodeQuotedPrintable(htmlBody) : "";
1541
+ const textEncoded = encodeQuotedPrintable(textBody);
1542
+ // Generate a unique Message-ID (required for threading, dedup, and RFC compliance)
1543
+ const domain = account.email.split("@")[1] || "mailx.local";
1544
+ const messageId = `<${Date.now()}.${Math.random().toString(36).slice(2)}@${domain}>`;
1545
+ const hasAttachments = Array.isArray(msg.attachments) && msg.attachments.length > 0;
1546
+ const commonHeaders = [
1547
+ `From: ${fromHeader}`, `To: ${to}`,
1548
+ cc ? `Cc: ${cc}` : null, bcc ? `Bcc: ${bcc}` : null,
1549
+ `Subject: ${msg.subject}`, `Date: ${new Date().toUTCString()}`,
1550
+ `Message-ID: ${messageId}`,
1551
+ msg.inReplyTo ? `In-Reply-To: ${msg.inReplyTo}` : null,
1552
+ msg.references?.length ? `References: ${msg.references.join(" ")}` : null,
1553
+ `MIME-Version: 1.0`,
1554
+ ].filter(h => h !== null);
1555
+ let rawMessage;
1556
+ const newBoundary = () => `mailx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
1557
+ // Inner body: either a multipart/alternative (text+html) or a single
1558
+ // text/plain. `innerBody` is the body-only portion (no envelope
1559
+ // headers) that will be wrapped by the attachments multipart if any.
1560
+ const makeInner = () => {
1561
+ if (hasHtml) {
1562
+ const altBoundary = newBoundary();
1563
+ const body = `--${altBoundary}\r\n` +
1564
+ `Content-Type: text/plain; charset=UTF-8\r\n` +
1565
+ `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
1566
+ `${textEncoded}\r\n` +
1567
+ `--${altBoundary}\r\n` +
1568
+ `Content-Type: text/html; charset=UTF-8\r\n` +
1569
+ `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
1570
+ `${htmlEncoded}\r\n` +
1571
+ `--${altBoundary}--\r\n`;
1572
+ return {
1573
+ headers: [`Content-Type: multipart/alternative; boundary="${altBoundary}"`],
1574
+ body,
1575
+ };
1576
+ }
1577
+ // Plain-text-only send — no HTML supplied, no alternative needed.
1578
+ return {
1579
+ headers: [
1580
+ `Content-Type: text/plain; charset=UTF-8`,
1581
+ `Content-Transfer-Encoding: quoted-printable`,
1582
+ ],
1583
+ body: textEncoded,
1584
+ };
1585
+ };
1586
+ if (hasAttachments) {
1587
+ // multipart/mixed wrapping (multipart/alternative | text/plain)
1588
+ // + one base64 attachment part per file. Attachment chunks are
1589
+ // wrapped at 76-char lines per RFC 2045.
1590
+ const mixedBoundary = newBoundary();
1591
+ const wrap76 = (s) => s.replace(/.{1,76}/g, m => m).match(/.{1,76}/g)?.join("\r\n") || s;
1592
+ const inner = makeInner();
1593
+ const parts = [];
1594
+ parts.push(`--${mixedBoundary}\r\n` +
1595
+ inner.headers.join("\r\n") +
1596
+ `\r\n\r\n` +
1597
+ inner.body);
1598
+ for (const att of msg.attachments) {
1599
+ const filename = (att.filename || "attachment").replace(/[\r\n"]/g, "_");
1600
+ const mime = att.mimeType || "application/octet-stream";
1601
+ const wrapped = wrap76(att.dataBase64 || "");
1602
+ parts.push(`--${mixedBoundary}\r\n` +
1603
+ `Content-Type: ${mime}; name="${filename}"\r\n` +
1604
+ `Content-Disposition: attachment; filename="${filename}"\r\n` +
1605
+ `Content-Transfer-Encoding: base64\r\n\r\n` +
1606
+ `${wrapped}\r\n`);
1607
+ }
1608
+ const headers = [
1609
+ ...commonHeaders,
1610
+ `Content-Type: multipart/mixed; boundary="${mixedBoundary}"`,
1611
+ ].join("\r\n");
1612
+ rawMessage = `${headers}\r\n\r\n${parts.join("")}--${mixedBoundary}--\r\n`;
1613
+ }
1614
+ else {
1615
+ const inner = makeInner();
1616
+ const headers = [
1617
+ ...commonHeaders,
1618
+ ...inner.headers,
1619
+ ].join("\r\n");
1620
+ rawMessage = `${headers}\r\n\r\n${inner.body}`;
1621
+ }
1622
+ lap(`MIME assembled (${rawMessage.length} bytes${hasAttachments ? `, ${msg.attachments.length} attachment(s)` : ""})`);
1623
+ this.imapManager.queueOutgoingLocal(account.id, rawMessage);
1624
+ lap("queued to disk");
1625
+ // Local-first Sent: don't wait for SMTP+APPEND+sync — put a pink row
1626
+ // into the local Sent folder right now so the user sees their letter
1627
+ // the instant they click Send. upsertMessage's Message-ID rebind
1628
+ // picks up the real APPENDUID later (same row, different UID).
1629
+ // Fire-and-forget: failure here must not hold up the send ACK.
1630
+ this.imapManager.insertOptimisticSentRow(account.id, {
1631
+ messageId,
1632
+ inReplyTo: msg.inReplyTo || "",
1633
+ references: msg.references || [],
1634
+ subject: msg.subject || "",
1635
+ from: { name: account.name, address: fromAddr },
1636
+ to: msg.to || [],
1637
+ cc: msg.cc || [],
1638
+ bcc: msg.bcc || [],
1639
+ date: Date.now(),
1640
+ }, rawMessage).catch(() => { });
1641
+ console.log(` Queued locally: ${msg.subject} via ${account.id} from ${fromHeader}`);
1642
+ // Contacts recording is off the critical path — deferred until after
1643
+ // the IPC ACK so a slow DB write can't stall the send.
1644
+ setImmediate(() => {
1645
+ try {
1646
+ for (const addr of msg.to)
1647
+ this.db.recordSentAddress(addr.name, addr.address);
1648
+ if (msg.cc)
1649
+ for (const addr of msg.cc)
1650
+ this.db.recordSentAddress(addr.name, addr.address);
1651
+ if (msg.bcc)
1652
+ for (const addr of msg.bcc)
1653
+ this.db.recordSentAddress(addr.name, addr.address);
1654
+ }
1655
+ catch (e) {
1656
+ console.error(` recordSentAddress failed: ${e?.message || e}`);
1657
+ }
1658
+ });
1659
+ }
1660
+ // ── Delete / Move / Undelete ──
1661
+ async deleteMessage(accountId, uid) {
1662
+ const envelope = this.db.getMessageByUid(accountId, uid);
1663
+ if (!envelope)
1664
+ throw new Error("Message not found");
1665
+ // Tombstone the Message-ID so a subsequent sync pass can't resurrect
1666
+ // the row if the server's EXPUNGE hasn't propagated yet. `undelete`
1667
+ // removes the tombstone.
1668
+ if (envelope.messageId)
1669
+ this.db.addTombstone(accountId, envelope.messageId, envelope.subject || "");
1670
+ await this.imapManager.trashMessage(accountId, envelope.folderId, envelope.uid);
1671
+ }
1672
+ async deleteMessages(accountId, uids) {
1673
+ const messages = uids.map(uid => {
1674
+ const env = this.db.getMessageByUid(accountId, uid);
1675
+ if (!env)
1676
+ return null;
1677
+ // Tombstone each — same reason as single-delete above.
1678
+ if (env.messageId)
1679
+ this.db.addTombstone(accountId, env.messageId, env.subject || "");
1680
+ return { uid: env.uid, folderId: env.folderId };
1681
+ }).filter(m => m !== null);
1682
+ await this.imapManager.trashMessages(accountId, messages);
1683
+ }
1684
+ async moveMessage(accountId, uid, targetFolderId, targetAccountId) {
1685
+ const envelope = this.db.getMessageByUid(accountId, uid);
1686
+ if (!envelope)
1687
+ throw new Error("Message not found");
1688
+ // Update local DB immediately (local-first)
1689
+ this.db.updateMessageFolder(accountId, uid, targetFolderId);
1690
+ this.db.recalcFolderCounts(envelope.folderId);
1691
+ this.db.recalcFolderCounts(targetFolderId);
1692
+ // Sync to server in background
1693
+ if (targetAccountId && targetAccountId !== accountId) {
1694
+ this.imapManager.moveMessageCrossAccount(accountId, envelope.uid, envelope.folderId, targetAccountId, targetFolderId).catch(e => console.error(` Move sync failed: ${e.message}`));
1695
+ }
1696
+ else {
1697
+ this.imapManager.moveMessage(accountId, envelope.uid, envelope.folderId, targetFolderId).catch(e => console.error(` Move sync failed: ${e.message}`));
1698
+ }
1699
+ }
1700
+ async moveMessages(accountId, uids, targetFolderId) {
1701
+ const messages = uids.map(uid => {
1702
+ const env = this.db.getMessageByUid(accountId, uid);
1703
+ if (!env)
1704
+ return null;
1705
+ return { uid: env.uid, folderId: env.folderId };
1706
+ }).filter(m => m !== null);
1707
+ // Update local DB immediately
1708
+ for (const msg of messages) {
1709
+ if (msg) {
1710
+ this.db.updateMessageFolder(accountId, msg.uid, targetFolderId);
1711
+ this.db.recalcFolderCounts(msg.folderId);
1712
+ }
1713
+ }
1714
+ this.db.recalcFolderCounts(targetFolderId);
1715
+ // Sync to server in background
1716
+ this.imapManager.moveMessages(accountId, messages, targetFolderId).catch(e => console.error(` Move sync failed: ${e.message}`));
1717
+ }
1718
+ /** Move messages to the account's configured spam folder (accounts.jsonc "spam" path).
1719
+ * Throws if the account has no spam folder configured or the folder doesn't exist locally. */
1720
+ async markAsSpamMessages(accountId, uids) {
1721
+ // The spam folder is whatever the provider's getSpecialFolders() said
1722
+ // it is. iflow-direct's compat client fills this in from RFC 6154
1723
+ // \Junk / \Spam flags (with sensible defaults if the server doesn't
1724
+ // advertise them); Gmail has SPAM built in. mailx stores the result
1725
+ // as `specialUse: "junk"` on the matching folder row.
1726
+ //
1727
+ // Earlier versions required an explicit `spam:` field in accounts.jsonc
1728
+ // and the button erroring out when that was absent. That's obsolete —
1729
+ // the provider knows where spam goes. Just look up the flagged folder.
1730
+ const folders = this.db.getFolders(accountId);
1731
+ const target = folders.find(f => f.specialUse === "junk");
1732
+ if (!target)
1733
+ throw new Error(`No \\Junk/\\Spam folder found for ${accountId}`);
1734
+ await this.moveMessages(accountId, uids, target.id);
1735
+ return { targetFolderId: target.id, moved: uids.length };
1736
+ }
1737
+ /** Append a spam report row to `~/.mailx/spam.csv` — placeholder mechanism
1738
+ * per user 2026-04-23 ("let's make it smart later; no auto-delete until
1739
+ * safety issues are addressed"). One row per click. Columns: timestamp
1740
+ * (ms since epoch), ISO date, ISO time, accountId, Delivered-To, From
1741
+ * address, Subject, eml file path. CSV fields RFC 4180-quoted so commas
1742
+ * and quotes in subjects survive. No move, no flag change, no server
1743
+ * hit — just the log. Useful as training data for a future classifier.
1744
+ */
1745
+ async recordSpamReport(accountId, uid, folderId) {
1746
+ const env = this.db.getMessageByUid(accountId, uid, folderId);
1747
+ if (!env)
1748
+ throw new Error(`Message not found: ${accountId}/${uid}`);
1749
+ const bodyPath = env.bodyPath || "";
1750
+ // Prefer `body_path` (authoritative). `Delivered-To` isn't in the
1751
+ // envelope struct, so parse from the cached `.eml` if available.
1752
+ let deliveredTo = "";
1753
+ if (bodyPath) {
1754
+ try {
1755
+ const raw = fs.readFileSync(bodyPath, "utf-8").slice(0, 4096);
1756
+ const m = raw.match(/^Delivered-To:\s*(.+)$/mi);
1757
+ if (m)
1758
+ deliveredTo = m[1].trim();
1759
+ }
1760
+ catch { /* not fatal — leave blank */ }
1761
+ }
1762
+ const now = new Date();
1763
+ const isoDate = now.toISOString().slice(0, 10);
1764
+ const isoTime = now.toISOString().slice(11, 19);
1765
+ const fromAddr = env.from?.address || "";
1766
+ const subject = env.subject || "";
1767
+ const csvEscape = (s) => `"${String(s).replace(/"/g, '""')}"`;
1768
+ const row = [
1769
+ String(now.getTime()),
1770
+ csvEscape(isoDate),
1771
+ csvEscape(isoTime),
1772
+ csvEscape(accountId),
1773
+ csvEscape(deliveredTo),
1774
+ csvEscape(fromAddr),
1775
+ csvEscape(subject),
1776
+ csvEscape(bodyPath),
1777
+ ].join(",") + "\n";
1778
+ const spamCsvPath = path.join(getConfigDir(), "spam.csv");
1779
+ // Write a header if the file doesn't exist yet so the CSV is self-describing.
1780
+ if (!fs.existsSync(spamCsvPath)) {
1781
+ fs.writeFileSync(spamCsvPath, "timestamp_ms,date,time,account,delivered_to,from,subject,eml_path\n", "utf-8");
1782
+ }
1783
+ fs.appendFileSync(spamCsvPath, row, "utf-8");
1784
+ console.log(` [spam] reported ${accountId}/${uid} → spam.csv`);
1785
+ return { ok: true, row };
1786
+ }
1787
+ async undeleteMessage(accountId, uid, folderId) {
1788
+ // Clear the tombstone first so a subsequent sync can re-import if
1789
+ // the server still has the row. Messages with no Message-ID just
1790
+ // didn't get a tombstone — this is a no-op for them.
1791
+ const envelope = this.db.getMessageByUid(accountId, uid, folderId);
1792
+ if (envelope?.messageId)
1793
+ this.db.removeTombstone(accountId, envelope.messageId);
1794
+ await this.imapManager.undeleteMessage(accountId, uid, folderId);
1795
+ }
1796
+ async deleteOnServer(accountId, folderPath, uid) {
1797
+ await this.imapManager.deleteOnServer(accountId, folderPath, uid);
1798
+ }
1799
+ // ── Folder management ──
1800
+ async createFolder(accountId, parentPath, name) {
1801
+ const fullPath = parentPath ? `${parentPath}.${name}` : name;
1802
+ const client = await this.imapManager.createPublicClient(accountId);
1803
+ try {
1804
+ await client.createmailbox(fullPath);
1805
+ await this.imapManager.syncFolders(accountId, client);
1806
+ await client.logout();
1807
+ }
1808
+ finally {
1809
+ try {
1810
+ await client.logout();
1811
+ }
1812
+ catch { /* */ }
1813
+ }
1814
+ }
1815
+ async renameFolder(accountId, folderId, newName) {
1816
+ const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
1817
+ if (!folder)
1818
+ throw new Error("Folder not found");
1819
+ const parts = folder.path.split(folder.delimiter || ".");
1820
+ parts[parts.length - 1] = newName;
1821
+ const newPath = parts.join(folder.delimiter || ".");
1822
+ const client = await this.imapManager.createPublicClient(accountId);
1823
+ try {
1824
+ if (client.renameMailbox) {
1825
+ await client.renameMailbox(folder.path, newPath);
1826
+ }
1827
+ else {
1828
+ await client.withConnection(async () => {
1829
+ await client.client.mailboxRename(folder.path, newPath);
1830
+ });
1831
+ }
1832
+ await this.imapManager.syncFolders(accountId, client);
1833
+ await client.logout();
1834
+ }
1835
+ finally {
1836
+ try {
1837
+ await client.logout();
1838
+ }
1839
+ catch { /* */ }
1840
+ }
1841
+ }
1842
+ async deleteFolder(accountId, folderId) {
1843
+ const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
1844
+ if (!folder)
1845
+ throw new Error("Folder not found");
1846
+ const client = await this.imapManager.createPublicClient(accountId);
1847
+ try {
1848
+ try {
1849
+ if (client.deleteMailbox) {
1850
+ await client.deleteMailbox(folder.path);
1851
+ }
1852
+ else {
1853
+ await client.withConnection(async () => {
1854
+ await client.client.mailboxDelete(folder.path);
1855
+ });
1856
+ }
1857
+ }
1858
+ catch (e) {
1859
+ // Server already doesn't have this folder — common case when
1860
+ // the user deleted / renamed it from another client and mailx
1861
+ // is still showing the stale local row. Silently treat as
1862
+ // success and proceed with local cleanup; the user's intent
1863
+ // ("make this go away") is met either way.
1864
+ const msg = String(e?.message || e || "").toLowerCase();
1865
+ const alreadyGone = /nonexistent|does not exist|no such|not found|NO \[.*\] Mailbox|404/i.test(msg);
1866
+ if (!alreadyGone)
1867
+ throw e;
1868
+ console.log(` [folder] ${accountId} delete "${folder.path}": server says already gone — cleaning local DB`);
1869
+ }
1870
+ this.db.deleteFolder(folderId);
1871
+ try {
1872
+ await client.logout();
1873
+ }
1874
+ catch { /* ignore */ }
1875
+ }
1876
+ finally {
1877
+ try {
1878
+ await client.logout();
1879
+ }
1880
+ catch { /* */ }
1881
+ }
1882
+ }
1883
+ markFolderRead(folderId) {
1884
+ this.db.markFolderRead(folderId);
1885
+ }
1886
+ async emptyFolder(accountId, folderId) {
1887
+ const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
1888
+ if (!folder)
1889
+ throw new Error("Folder not found");
1890
+ this.db.deleteAllMessages(accountId, folderId);
1891
+ // Recalc + broadcast so the folder-tree badge drops to 0 immediately.
1892
+ // Without this, the badge kept showing the old unread count even
1893
+ // though the list was empty (user-reported bug).
1894
+ this.db.recalcFolderCounts(folderId);
1895
+ try {
1896
+ this.imapManager.emit?.("folderCountsChanged", accountId, {});
1897
+ }
1898
+ catch { /* non-fatal */ }
1899
+ const client = await this.imapManager.createPublicClient(accountId);
1900
+ try {
1901
+ const uids = await client.getUids(folder.path);
1902
+ for (const uid of uids)
1903
+ await client.deleteMessageByUid(folder.path, uid);
1904
+ await client.logout();
1905
+ }
1906
+ finally {
1907
+ try {
1908
+ await client.logout();
1909
+ }
1910
+ catch { /* */ }
1911
+ }
1912
+ }
1913
+ // ── Attachments ──
1914
+ async getAttachment(accountId, uid, attachmentId, folderId) {
1915
+ const envelope = this.db.getMessageByUid(accountId, uid, folderId);
1916
+ if (!envelope)
1917
+ throw new Error("Message not found");
1918
+ const raw = await this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
1919
+ if (!raw)
1920
+ throw new Error("Message body not available");
1921
+ const parsed = await simpleParser(raw);
1922
+ const att = parsed.attachments?.[attachmentId];
1923
+ if (!att)
1924
+ throw new Error("Attachment not found");
1925
+ return {
1926
+ content: att.content,
1927
+ contentType: att.contentType || "application/octet-stream",
1928
+ filename: (att.filename || "attachment").replace(/"/g, ""),
1929
+ };
1930
+ }
1931
+ // ── Drafts ──
1932
+ async saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId) {
1933
+ // Local-first: commit the draft to the local filesystem synchronously
1934
+ // and return immediately. The IMAP APPEND (and the previous-draft
1935
+ // delete) run in the background. Previously this method awaited IMAP
1936
+ // inline, which produced the 30/120s `mailxapi timeout: saveDraft`
1937
+ // the user reported — every IMAP stall (slow server, hung OAuth,
1938
+ // maxed connection pool) froze autosave. The local `.eml` written
1939
+ // below is the user's crash-safety net; IMAP is a sync target, not
1940
+ // a prerequisite. X-Mailx-Draft-ID is carried in the MIME headers
1941
+ // so the reconciler can de-duplicate on the server by header search
1942
+ // even without the previousDraftUid round-trip.
1943
+ // Account lookup uses the cached list — `loadSettings()` reads
1944
+ // accounts.jsonc from the GDrive mount and could itself stall for
1945
+ // 120s, which was the actual `mailxapi timeout: saveDraft` source
1946
+ // (the IMAP work was fire-and-forget, but loadSettings wasn't).
1947
+ let account = this.getCachedAccounts().find(a => a.id === accountId);
1948
+ if (!account) {
1949
+ this._accountsCache = null;
1950
+ account = this.getCachedAccounts().find(a => a.id === accountId);
1951
+ }
1952
+ if (!account)
1953
+ throw new Error(`Unknown account: ${accountId}`);
1954
+ // Generate or reuse a stable draft ID for dedup
1955
+ const id = draftId || `mailx-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1956
+ const body = bodyHtml || bodyText || "";
1957
+ const bodyEncoded = encodeQuotedPrintable(body);
1958
+ const headers = [
1959
+ `From: ${account.name} <${account.email}>`,
1960
+ to ? `To: ${to}` : null, cc ? `Cc: ${cc}` : null,
1961
+ `Subject: ${subject || "(no subject)"}`, `Date: ${new Date().toUTCString()}`,
1962
+ `X-Mailx-Draft-ID: ${id}`,
1963
+ `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: quoted-printable`,
1964
+ ].filter(h => h !== null).join("\r\n");
1965
+ const raw = `${headers}\r\n\r\n${bodyEncoded}`;
1966
+ // Local commit: write editing copy to disk. Crash recovery lives in
1967
+ // the last 3 files. Synchronous fs (~ms) so the caller returns fast.
1968
+ try {
1969
+ const editingDir = path.join(getConfigDir(), "sending", accountId, "editing");
1970
+ fs.mkdirSync(editingDir, { recursive: true });
1971
+ const pad2 = (n) => String(n).padStart(2, "0");
1972
+ const now = new Date();
1973
+ const ts = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`;
1974
+ fs.writeFileSync(path.join(editingDir, `${ts}.eml`), raw);
1975
+ // Keep only last 3
1976
+ const files = fs.readdirSync(editingDir).filter(f => f.endsWith(".eml")).sort();
1977
+ while (files.length > 3) {
1978
+ fs.unlinkSync(path.join(editingDir, files.shift()));
1979
+ }
1980
+ }
1981
+ catch { /* non-fatal — draft stays in memory at least */ }
1982
+ // Background reconcile to server Drafts folder. Fire-and-forget —
1983
+ // the ACK to the client is already on its way.
1984
+ this.imapManager.saveDraft(accountId, raw, previousDraftUid, id).catch((e) => {
1985
+ console.error(` [draft] background IMAP save failed for ${id}: ${e?.message || e}`);
1986
+ // Surface as an event so the UI can show a status-bar hint without
1987
+ // blocking the caller. Draft is preserved on disk regardless.
1988
+ this.emit?.("draftSaveDeferred", { accountId, draftId: id, error: String(e?.message || e) });
1989
+ });
1990
+ return { draftUid: null, draftId: id };
1991
+ }
1992
+ async deleteDraft(accountId, draftUid, draftId) {
1993
+ await this.imapManager.deleteDraft(accountId, draftUid, draftId);
1994
+ }
1995
+ // ── Contacts ──
1996
+ searchContacts(query) {
1997
+ query = (query || "").trim();
1998
+ if (query.length < 1)
1999
+ return [];
2000
+ return this.db.searchContacts(query);
2001
+ }
2002
+ /** Q49: boolean hint for compose to auto-expand Cc when replying to this
2003
+ * address. True when at least one past sent message to the same recipient
2004
+ * had a non-empty Cc field. */
2005
+ hasCcHistoryTo(email) {
2006
+ return this.db.hasCcHistoryTo(email);
2007
+ }
2008
+ /** Q49: same shape, for Bcc. Sent folder is the only place Bcc appears,
2009
+ * so the signal is local-only but still reflects the user's habit. */
2010
+ hasBccHistoryTo(email) {
2011
+ return this.db.hasBccHistoryTo(email);
2012
+ }
2013
+ async syncGoogleContacts() {
2014
+ await this.imapManager.syncAllContacts();
2015
+ }
2016
+ seedContacts() {
2017
+ const added = this.db.seedContactsFromMessages();
2018
+ console.log(` Seeded ${added} contacts from message history`);
2019
+ return added;
2020
+ }
2021
+ /** Explicit add to address book — used by the right-click "Add to contacts"
2022
+ * action on From/To/Cc addresses in the message viewer. Just calls the same
2023
+ * validated upsert path as recordSentAddress. */
2024
+ addContact(name, email) {
2025
+ if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email))
2026
+ return false;
2027
+ this.db.recordSentAddress(name || "", email);
2028
+ return true;
2029
+ }
2030
+ /** Address-book listing — paginated, filterable. */
2031
+ listContacts(query, page = 1, pageSize = 100) {
2032
+ return this.db.listContacts(query || "", page, pageSize);
2033
+ }
2034
+ /** Upsert a contact from the address book UI (edit name). Two-way cache:
2035
+ * commits locally, queues a Google People push. */
2036
+ upsertContact(name, email) {
2037
+ this.db.upsertContact(name || "", email);
2038
+ const acct = this.getPrimaryAccount("contacts");
2039
+ if (acct) {
2040
+ // Google People `createContact` — resourceName is assigned by the
2041
+ // server and stored back as google_id once the drainer gets an ACK.
2042
+ this.db.enqueueStoreSync("contacts", "create", acct.id, email, {
2043
+ names: [{ givenName: name || "" }],
2044
+ emailAddresses: [{ value: email }],
2045
+ });
2046
+ this.drainStoreSync().catch(() => { });
2047
+ }
2048
+ return { ok: true };
2049
+ }
2050
+ /** Delete a contact from the address book. Also pushes the deletion to
2051
+ * Google People if the contact had a resourceName (i.e. was synced). */
2052
+ deleteContact(email) {
2053
+ const acct = this.getPrimaryAccount("contacts");
2054
+ // Look up the resourceName before deleting so we can push to Google.
2055
+ const contacts = this.db.listContacts("", 1, 10_000);
2056
+ const contact = (contacts.items || []).find((c) => c.email === email);
2057
+ this.db.deleteContactLocal(email);
2058
+ if (acct && contact?.googleId) {
2059
+ this.db.enqueueStoreSync("contacts", "delete", acct.id, email, {
2060
+ resourceName: contact.googleId,
2061
+ });
2062
+ this.drainStoreSync().catch(() => { });
2063
+ }
2064
+ return { ok: true };
2065
+ }
2066
+ /** Open a configured local path in the OS file explorer. Whitelisted to
2067
+ * avoid the UI poking at arbitrary paths. */
2068
+ async openLocalPath(which) {
2069
+ const dir = getConfigDir();
2070
+ let target = dir;
2071
+ if (which === "log") {
2072
+ const today = new Date();
2073
+ const pad = (n) => String(n).padStart(2, "0");
2074
+ const fname = `mailx-${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}.log`;
2075
+ target = path.join(dir, "logs", fname);
2076
+ }
2077
+ const { spawn } = await import("child_process");
2078
+ const cmd = process.platform === "win32" ? "explorer"
2079
+ : process.platform === "darwin" ? "open"
2080
+ : "xdg-open";
2081
+ const args = process.platform === "win32" && which === "log"
2082
+ ? ["/select,", target]
2083
+ : [target];
2084
+ spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true }).unref();
2085
+ return { ok: true, path: target };
2086
+ }
2087
+ /** Get all messages in a thread (across folders) for an account. */
2088
+ getThreadMessages(accountId, threadId) {
2089
+ return this.db.getThreadMessages(accountId, threadId);
2090
+ }
2091
+ /** Read a JSONC config file from the shared cloud dir or local ~/.mailx.
2092
+ * Names are whitelisted so the UI can't read arbitrary files.
2093
+ * `config.jsonc` is the local per-machine config (not cloud-synced). */
2094
+ async readJsoncFile(name) {
2095
+ const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
2096
+ if (!WHITELIST.includes(name))
2097
+ throw new Error(`File not allowed: ${name}`);
2098
+ if (name === "config.jsonc") {
2099
+ const configPath = path.join(getConfigDir(), "config.jsonc");
2100
+ try {
2101
+ return fs.readFileSync(configPath, "utf-8");
2102
+ }
2103
+ catch {
2104
+ return null;
2105
+ }
2106
+ }
2107
+ const { cloudRead } = await import("@bobfrankston/mailx-settings");
2108
+ return cloudRead(name);
2109
+ }
2110
+ // Reformat JSONC preserving comments — applyEdits returns whitespace-only edits.
2111
+ async formatJsonc(content) {
2112
+ const { format, applyEdits } = await import("jsonc-parser");
2113
+ const edits = format(content, undefined, {
2114
+ tabSize: 2,
2115
+ insertSpaces: true,
2116
+ eol: "\n",
2117
+ insertFinalNewline: true,
2118
+ });
2119
+ return applyEdits(content, edits);
2120
+ }
2121
+ /** Return the help section for a named config file, extracted from docs/config-help.md.
2122
+ * Matches a level-2 heading whose text equals the filename. Returns markdown. */
2123
+ async readConfigHelp(name) {
2124
+ const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
2125
+ if (!WHITELIST.includes(name))
2126
+ return "";
2127
+ // Look in the repo root (dev) and in the installed package dir (production).
2128
+ const candidates = [
2129
+ path.join(__dirname, "..", "..", "docs", "config-help.md"),
2130
+ path.join(__dirname, "config-help.md"),
2131
+ ];
2132
+ let md = "";
2133
+ for (const p of candidates) {
2134
+ try {
2135
+ md = fs.readFileSync(p, "utf-8");
2136
+ break;
2137
+ }
2138
+ catch { /* try next */ }
2139
+ }
2140
+ if (!md)
2141
+ return "";
2142
+ const lines = md.split(/\r?\n/);
2143
+ let inSection = false;
2144
+ const out = [];
2145
+ for (const line of lines) {
2146
+ const h2 = /^##\s+(.+?)\s*$/.exec(line);
2147
+ if (h2) {
2148
+ if (inSection)
2149
+ break; // next section — stop
2150
+ if (h2[1].trim() === name) {
2151
+ inSection = true;
2152
+ continue;
2153
+ }
2154
+ }
2155
+ if (inSection)
2156
+ out.push(line);
2157
+ }
2158
+ return out.join("\n").trim();
2159
+ }
2160
+ /** Write a JSONC config file. Validates that the content parses as JSONC
2161
+ * (loosely — strips comments/trailing commas) before writing.
2162
+ * Saves the prior content to a dated backup file first — manual edits
2163
+ * occasionally have typos that survive validation (semantically wrong
2164
+ * but syntactically OK), and a one-key undo isn't enough; the user
2165
+ * asked to be able to recover yesterday's accounts.jsonc. Automatic
2166
+ * saveAccounts/saveAllowlist paths skip backups (they're driven by
2167
+ * trusted code, not the JSONC editor). */
2168
+ async writeJsoncFile(name, content) {
2169
+ const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
2170
+ if (!WHITELIST.includes(name))
2171
+ throw new Error(`File not allowed: ${name}`);
2172
+ // Validate the content parses before writing
2173
+ const { parse: parseJsonc } = await import("jsonc-parser");
2174
+ const errors = [];
2175
+ parseJsonc(content, errors, { allowTrailingComma: true });
2176
+ if (errors.length) {
2177
+ throw new Error(`JSONC parse error: ${errors.map(e => e.error).join(", ")}`);
2178
+ }
2179
+ const previous = await this.readJsoncForBackup(name);
2180
+ await this.backupJsoncIfChanged(name, previous, content);
2181
+ if (name === "config.jsonc") {
2182
+ const configPath = path.join(getConfigDir(), "config.jsonc");
2183
+ fs.writeFileSync(configPath, content);
2184
+ return;
2185
+ }
2186
+ const { cloudWrite } = await import("@bobfrankston/mailx-settings");
2187
+ await cloudWrite(name, content); // throws on failure with descriptive error
2188
+ }
2189
+ /** Read the current content of a config file (cloud or local) so it can
2190
+ * be saved as a backup before being overwritten. Returns null if the
2191
+ * file doesn't exist yet (first save — nothing to back up). */
2192
+ async readJsoncForBackup(name) {
2193
+ if (name === "config.jsonc") {
2194
+ const configPath = path.join(getConfigDir(), "config.jsonc");
2195
+ try {
2196
+ return fs.readFileSync(configPath, "utf-8");
2197
+ }
2198
+ catch {
2199
+ return null;
2200
+ }
2201
+ }
2202
+ try {
2203
+ const { cloudRead } = await import("@bobfrankston/mailx-settings");
2204
+ return await cloudRead(name);
2205
+ }
2206
+ catch {
2207
+ return null;
2208
+ }
2209
+ }
2210
+ /** Write the prior content to `<configDir>/backup/<name>.<ts>.bak` and
2211
+ * prune so at most 10 backups per file remain AND none are older than 7
2212
+ * days. Skipped when previous content is null (first write) or
2213
+ * identical to the new content (no-op save). */
2214
+ async backupJsoncIfChanged(name, previous, next) {
2215
+ if (previous == null || previous === next)
2216
+ return;
2217
+ const backupDir = path.join(getConfigDir(), "backup");
2218
+ try {
2219
+ fs.mkdirSync(backupDir, { recursive: true });
2220
+ }
2221
+ catch { /* */ }
2222
+ // Filename-safe ISO timestamp (colons become hyphens on Windows).
2223
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
2224
+ const backupPath = path.join(backupDir, `${name}.${stamp}.bak`);
2225
+ try {
2226
+ fs.writeFileSync(backupPath, previous);
2227
+ }
2228
+ catch (e) {
2229
+ console.error(`[backup] failed to write ${backupPath}: ${e.message}`);
2230
+ return; // don't block the save just because backup failed
2231
+ }
2232
+ // Prune: keep at most 10 most-recent for this filename, drop anything
2233
+ // older than 7 days. Whichever cuts more wins.
2234
+ const MAX_KEEP = 10;
2235
+ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
2236
+ const now = Date.now();
2237
+ let entries;
2238
+ try {
2239
+ entries = fs.readdirSync(backupDir)
2240
+ .filter(f => f.startsWith(`${name}.`) && f.endsWith(".bak"))
2241
+ .map(f => {
2242
+ const p = path.join(backupDir, f);
2243
+ return { path: p, mtime: fs.statSync(p).mtimeMs };
2244
+ })
2245
+ .sort((a, b) => b.mtime - a.mtime); // newest first
2246
+ }
2247
+ catch {
2248
+ return;
2249
+ }
2250
+ for (let i = 0; i < entries.length; i++) {
2251
+ const tooOld = now - entries[i].mtime > MAX_AGE_MS;
2252
+ const tooMany = i >= MAX_KEEP;
2253
+ if (tooOld || tooMany) {
2254
+ try {
2255
+ fs.unlinkSync(entries[i].path);
2256
+ }
2257
+ catch { /* */ }
2258
+ }
2259
+ }
2260
+ }
2261
+ // ── Settings ──
2262
+ getSettings() {
2263
+ return loadSettings();
2264
+ }
2265
+ saveSettings(settings) {
2266
+ saveSettings(settings);
2267
+ }
2268
+ getStorageInfo() {
2269
+ return getStorageInfo();
2270
+ }
2271
+ // ── Setup & Repair ──
2272
+ async setupAccount(name, email, password) {
2273
+ if (!email)
2274
+ return { ok: false, error: "Email address required" };
2275
+ const domain = email.split("@")[1]?.toLowerCase() || "";
2276
+ const detected = await detectEmailProvider(domain);
2277
+ if (detected?.cloud) {
2278
+ await initCloudConfig(detected.cloud);
2279
+ }
2280
+ // Check cloud for existing accounts — if found, just load them all
2281
+ let accounts = loadAccounts();
2282
+ if (accounts.length === 0) {
2283
+ accounts = await loadAccountsAsync();
2284
+ }
2285
+ if (accounts.length > 0) {
2286
+ // Existing accounts found on cloud — use them directly
2287
+ console.log(` Found ${accounts.length} existing account(s) from cloud settings`);
2288
+ const settings = loadSettings();
2289
+ for (const acct of settings.accounts) {
2290
+ if (!acct.enabled)
2291
+ continue;
2292
+ try {
2293
+ await this.imapManager.addAccount(acct);
2294
+ console.log(` Account loaded: ${acct.label || acct.name} (${acct.id})`);
2295
+ }
2296
+ catch (e) {
2297
+ console.error(` Account ${acct.id} error: ${e.message}`);
2298
+ }
2299
+ }
2300
+ this.imapManager.syncAll().catch(() => { });
2301
+ return { ok: true, message: `Loaded ${accounts.length} existing account(s) from cloud.` };
2302
+ }
2303
+ // No existing accounts — create new one
2304
+ const isGoogle = ["gmail.com", "googlemail.com"].includes(domain) || detected?.cloud === "gdrive";
2305
+ const isOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain);
2306
+ const account = { email, name: name || email.split("@")[0] };
2307
+ if (password)
2308
+ account.password = password;
2309
+ if (detected && !isOAuth) {
2310
+ account.imap = { host: detected.imapHost, port: 993, tls: true, auth: detected.auth, user: email };
2311
+ account.smtp = { host: detected.smtpHost, port: 587, tls: true, auth: detected.auth, user: email };
2312
+ }
2313
+ account.id = domain.split(".")[0] || "account";
2314
+ // Save provisional account so addAccount can register it. Cloud failures
2315
+ // surface via onCloudError listeners (UI banner) — don't fail setup itself.
2316
+ try {
2317
+ await saveAccounts([account]);
2318
+ }
2319
+ catch (e) {
2320
+ console.error(` [setup] saveAccounts failed: ${e.message}`);
2321
+ return { ok: false, error: `Failed to save account: ${e.message}` };
2322
+ }
2323
+ // Re-read normalized settings and register
2324
+ let settings = loadSettings();
2325
+ for (const acct of settings.accounts) {
2326
+ if (!acct.enabled)
2327
+ continue;
2328
+ try {
2329
+ await this.imapManager.addAccount(acct);
2330
+ console.log(` Account loaded: ${acct.label || acct.name} (${acct.id})`);
2331
+ }
2332
+ catch (e) {
2333
+ console.error(` Account ${acct.id} error: ${e.message}`);
2334
+ }
2335
+ }
2336
+ // For Google accounts where the user didn't supply a name, fetch the
2337
+ // display name from the People API now that addAccount has authenticated.
2338
+ // contacts.readonly is in the Gmail OAuth scope, so the same token works.
2339
+ if (!name && isGoogle) {
2340
+ try {
2341
+ const tok = await this.imapManager.getOAuthToken(account.id);
2342
+ if (tok) {
2343
+ const { getGoogleProfile } = await import("@bobfrankston/mailx-settings/cloud.js");
2344
+ const profile = await getGoogleProfile(tok);
2345
+ if (profile?.name && profile.name !== account.name) {
2346
+ console.log(` [setup] Display name from Google: ${profile.name}`);
2347
+ account.name = profile.name;
2348
+ // Re-save with the resolved name (best-effort; cloud errors
2349
+ // surface via onCloudError).
2350
+ try {
2351
+ await saveAccounts([account]);
2352
+ }
2353
+ catch (e) {
2354
+ console.error(` [setup] re-saveAccounts with profile name failed: ${e.message}`);
2355
+ }
2356
+ settings = loadSettings();
2357
+ }
2358
+ }
2359
+ }
2360
+ catch (e) {
2361
+ console.error(` [setup] getGoogleProfile failed: ${e.message}`);
2362
+ }
2363
+ }
2364
+ this.imapManager.syncAll().catch(() => { });
2365
+ return { ok: true, message: `${settings.accounts.length} account(s) configured and syncing.` };
2366
+ }
2367
+ async repairAccounts() {
2368
+ const dbAccounts = this.db.getAccountConfigs();
2369
+ if (dbAccounts.length === 0) {
2370
+ return { ok: false, error: "No cached accounts in database" };
2371
+ }
2372
+ const restored = [];
2373
+ for (const a of dbAccounts) {
2374
+ try {
2375
+ restored.push(JSON.parse(a.configJson));
2376
+ }
2377
+ catch { /* skip corrupt */ }
2378
+ }
2379
+ if (restored.length === 0) {
2380
+ return { ok: false, error: "Could not parse cached account configs" };
2381
+ }
2382
+ await saveAccounts(restored);
2383
+ for (const acct of restored) {
2384
+ try {
2385
+ await this.imapManager.addAccount(acct);
2386
+ console.log(` [repair] Re-registered account: ${acct.name} (${acct.id})`);
2387
+ }
2388
+ catch (e) {
2389
+ console.error(` [repair] Failed to register ${acct.id}: ${e.message}`);
2390
+ }
2391
+ }
2392
+ this.imapManager.syncAll().catch(() => { });
2393
+ return { ok: true, message: `Restored ${restored.length} account(s) and started sync.` };
2394
+ }
2395
+ // ── Autocomplete ──
2396
+ getAutocompleteSettings() {
2397
+ return loadAutocomplete();
2398
+ }
2399
+ saveAutocompleteSettings(settings) {
2400
+ saveAutocomplete(settings);
2401
+ }
2402
+ async autocomplete(req) {
2403
+ const acConfig = loadAutocomplete();
2404
+ if (!acConfig.enabled || acConfig.provider === "off") {
2405
+ return { suggestion: "" };
2406
+ }
2407
+ const bodyText = req.bodyText || "";
2408
+ const prompt = `You are an email writing assistant. Complete the following email naturally.\nOutput ONLY the completion text — no explanation, no greeting repeat.\nKeep it to 1-2 sentences max.\n\nTo: ${req.to || ""}\nSubject: ${req.subject || ""}\n\n${bodyText}`;
2409
+ try {
2410
+ if (acConfig.provider === "ollama") {
2411
+ const truncated = bodyText.slice(-500);
2412
+ const ollamaPrompt = prompt.replace(bodyText, truncated);
2413
+ const res = await fetch(`${acConfig.ollamaUrl}/api/generate`, {
2414
+ method: "POST",
2415
+ headers: { "Content-Type": "application/json" },
2416
+ body: JSON.stringify({
2417
+ model: acConfig.ollamaModel,
2418
+ prompt: ollamaPrompt,
2419
+ stream: false,
2420
+ options: { num_predict: acConfig.maxTokens },
2421
+ }),
2422
+ });
2423
+ if (!res.ok)
2424
+ return { suggestion: "" };
2425
+ const data = await res.json();
2426
+ return { suggestion: trimSuggestion(data.response || "") };
2427
+ }
2428
+ if (acConfig.provider === "claude") {
2429
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
2430
+ method: "POST",
2431
+ headers: {
2432
+ "Content-Type": "application/json",
2433
+ "x-api-key": acConfig.cloudApiKey,
2434
+ "anthropic-version": "2023-06-01",
2435
+ },
2436
+ body: JSON.stringify({
2437
+ model: acConfig.cloudModel,
2438
+ max_tokens: acConfig.maxTokens,
2439
+ messages: [{ role: "user", content: prompt }],
2440
+ }),
2441
+ });
2442
+ if (!res.ok)
2443
+ return { suggestion: "" };
2444
+ const data = await res.json();
2445
+ const text = data.content?.[0]?.text || "";
2446
+ return { suggestion: trimSuggestion(text) };
2447
+ }
2448
+ if (acConfig.provider === "openai") {
2449
+ const res = await fetch("https://api.openai.com/v1/chat/completions", {
2450
+ method: "POST",
2451
+ headers: {
2452
+ "Content-Type": "application/json",
2453
+ "Authorization": `Bearer ${acConfig.cloudApiKey}`,
2454
+ },
2455
+ body: JSON.stringify({
2456
+ model: acConfig.cloudModel,
2457
+ max_tokens: acConfig.maxTokens,
2458
+ messages: [
2459
+ { role: "system", content: "You are an email writing assistant. Output ONLY the completion text." },
2460
+ { role: "user", content: prompt },
2461
+ ],
2462
+ }),
2463
+ });
2464
+ if (!res.ok)
2465
+ return { suggestion: "" };
2466
+ const data = await res.json();
2467
+ const text = data.choices?.[0]?.message?.content || "";
2468
+ return { suggestion: trimSuggestion(text) };
2469
+ }
2470
+ }
2471
+ catch (e) {
2472
+ console.error(` [autocomplete] ${acConfig.provider} error: ${e.message}`);
2473
+ }
2474
+ return { suggestion: "" };
2475
+ }
2476
+ /** Generic AI text transform — translate / proofread / summarize.
2477
+ * Shares the autocomplete provider config (provider, key, model). Each
2478
+ * feature has its own opt-in toggle (translateEnabled / proofreadEnabled),
2479
+ * default false. Returns empty text + reason when disabled or on error. */
2480
+ async aiTransform(req) {
2481
+ const cfg = loadAutocomplete();
2482
+ if (cfg.provider === "off")
2483
+ return { text: "", reason: "AI provider not configured" };
2484
+ const featureGate = {
2485
+ translate: cfg.translateEnabled,
2486
+ proofread: cfg.proofreadEnabled,
2487
+ summarize: cfg.proofreadEnabled, // bundled with proofread for now
2488
+ };
2489
+ if (!featureGate[req.action])
2490
+ return { text: "", reason: `AI ${req.action} disabled in settings` };
2491
+ const text = (req.text || "").slice(0, 8000); // sanity cap
2492
+ if (!text.trim())
2493
+ return { text: "", reason: "no input" };
2494
+ const target = req.targetLang || "en";
2495
+ let systemPrompt;
2496
+ let userPrompt;
2497
+ switch (req.action) {
2498
+ case "translate":
2499
+ systemPrompt = `You are a translator. Render the user's text into ${target}. Preserve formatting (paragraphs, lists). Output ONLY the translation, no explanation.`;
2500
+ userPrompt = text;
2501
+ break;
2502
+ case "proofread":
2503
+ systemPrompt = `You are an editor. Return the user's text with grammar, spelling, and clarity fixed. Preserve voice and meaning. Output ONLY the corrected text, no explanation.`;
2504
+ userPrompt = text;
2505
+ break;
2506
+ case "summarize":
2507
+ systemPrompt = `You are a summarizer. Render the user's text as a short paragraph (2-4 sentences). Output ONLY the summary.`;
2508
+ userPrompt = text;
2509
+ break;
2510
+ }
2511
+ try {
2512
+ if (cfg.provider === "ollama") {
2513
+ const res = await fetch(`${cfg.ollamaUrl}/api/generate`, {
2514
+ method: "POST",
2515
+ headers: { "Content-Type": "application/json" },
2516
+ body: JSON.stringify({
2517
+ model: cfg.ollamaModel,
2518
+ prompt: `${systemPrompt}\n\n${userPrompt}`,
2519
+ stream: false,
2520
+ options: { num_predict: 1024 },
2521
+ }),
2522
+ });
2523
+ if (!res.ok)
2524
+ return { text: "", reason: `ollama ${res.status}` };
2525
+ const data = await res.json();
2526
+ return { text: (data.response || "").trim() };
2527
+ }
2528
+ if (cfg.provider === "claude") {
2529
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
2530
+ method: "POST",
2531
+ headers: {
2532
+ "Content-Type": "application/json",
2533
+ "x-api-key": cfg.cloudApiKey,
2534
+ "anthropic-version": "2023-06-01",
2535
+ },
2536
+ body: JSON.stringify({
2537
+ model: cfg.cloudModel,
2538
+ max_tokens: 2048,
2539
+ system: systemPrompt,
2540
+ messages: [{ role: "user", content: userPrompt }],
2541
+ }),
2542
+ });
2543
+ if (!res.ok)
2544
+ return { text: "", reason: `claude ${res.status}` };
2545
+ const data = await res.json();
2546
+ return { text: (data.content?.[0]?.text || "").trim() };
2547
+ }
2548
+ if (cfg.provider === "openai") {
2549
+ const res = await fetch("https://api.openai.com/v1/chat/completions", {
2550
+ method: "POST",
2551
+ headers: {
2552
+ "Content-Type": "application/json",
2553
+ "Authorization": `Bearer ${cfg.cloudApiKey}`,
2554
+ },
2555
+ body: JSON.stringify({
2556
+ model: cfg.cloudModel,
2557
+ max_tokens: 2048,
2558
+ messages: [
2559
+ { role: "system", content: systemPrompt },
2560
+ { role: "user", content: userPrompt },
2561
+ ],
2562
+ }),
2563
+ });
2564
+ if (!res.ok)
2565
+ return { text: "", reason: `openai ${res.status}` };
2566
+ const data = await res.json();
2567
+ return { text: (data.choices?.[0]?.message?.content || "").trim() };
2568
+ }
2569
+ }
2570
+ catch (e) {
2571
+ console.error(` [aiTransform] ${cfg.provider} ${req.action} error: ${e.message}`);
2572
+ return { text: "", reason: e.message };
2573
+ }
2574
+ return { text: "", reason: "no provider matched" };
2575
+ }
2576
+ }
2577
+ /** Trim suggestion: remove leading/trailing whitespace, cap at sentence boundary */
2578
+ function trimSuggestion(text) {
2579
+ let s = text.trim();
2580
+ if (!s)
2581
+ return "";
2582
+ // Cap at 2 sentences
2583
+ const sentences = s.match(/[^.!?]*[.!?]/g);
2584
+ if (sentences && sentences.length > 2) {
2585
+ s = sentences.slice(0, 2).join("").trim();
2586
+ }
2587
+ return s;
2588
+ }
2589
+ //# sourceMappingURL=index.js.map