@bobfrankston/mailx 1.0.451 → 1.0.452

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