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