@bobfrankston/mailx-service 0.1.47

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 (54) hide show
  1. package/ai-usage.d.ts +32 -0
  2. package/ai-usage.d.ts.map +1 -0
  3. package/ai-usage.js +98 -0
  4. package/ai-usage.js.map +1 -0
  5. package/charset.d.ts +15 -0
  6. package/charset.d.ts.map +1 -0
  7. package/charset.js +61 -0
  8. package/charset.js.map +1 -0
  9. package/db-worker-client.d.ts +32 -0
  10. package/db-worker-client.d.ts.map +1 -0
  11. package/db-worker-client.js +66 -0
  12. package/db-worker-client.js.map +1 -0
  13. package/db-worker.d.ts +39 -0
  14. package/db-worker.d.ts.map +1 -0
  15. package/db-worker.js +115 -0
  16. package/db-worker.js.map +1 -0
  17. package/google-sync.d.ts +151 -0
  18. package/google-sync.d.ts.map +1 -0
  19. package/google-sync.js +259 -0
  20. package/google-sync.js.map +1 -0
  21. package/html-to-docx.d.ts +1 -0
  22. package/index.d.ts +1102 -0
  23. package/index.d.ts.map +1 -0
  24. package/index.js +5476 -0
  25. package/index.js.map +1 -0
  26. package/jsonrpc.d.ts +29 -0
  27. package/jsonrpc.d.ts.map +1 -0
  28. package/jsonrpc.js +481 -0
  29. package/jsonrpc.js.map +1 -0
  30. package/local-store.d.ts +10 -0
  31. package/local-store.d.ts.map +1 -0
  32. package/local-store.js +9 -0
  33. package/local-store.js.map +1 -0
  34. package/package.json +53 -0
  35. package/reconciler.d.ts +90 -0
  36. package/reconciler.d.ts.map +1 -0
  37. package/reconciler.js +277 -0
  38. package/reconciler.js.map +1 -0
  39. package/sync-queue.d.ts +127 -0
  40. package/sync-queue.d.ts.map +1 -0
  41. package/sync-queue.js +271 -0
  42. package/sync-queue.js.map +1 -0
  43. package/sync-worker-client.d.ts +33 -0
  44. package/sync-worker-client.d.ts.map +1 -0
  45. package/sync-worker-client.js +89 -0
  46. package/sync-worker-client.js.map +1 -0
  47. package/sync-worker.d.ts +33 -0
  48. package/sync-worker.d.ts.map +1 -0
  49. package/sync-worker.js +143 -0
  50. package/sync-worker.js.map +1 -0
  51. package/word-html.d.ts +41 -0
  52. package/word-html.d.ts.map +1 -0
  53. package/word-html.js +122 -0
  54. package/word-html.js.map +1 -0
package/index.d.ts ADDED
@@ -0,0 +1,1102 @@
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
+ interface MimeLeafPart {
7
+ start: number;
8
+ end: number;
9
+ filename: string;
10
+ contentType: string;
11
+ decoded: Buffer | null;
12
+ }
13
+ export declare function enumerateMimeLeafParts(raw: string): MimeLeafPart[];
14
+ import { ImapManager } from "@bobfrankston/mailx-imap";
15
+ import { Store } from "@bobfrankston/mailx-store";
16
+ import { type AiUsageSummary } from "./ai-usage.js";
17
+ export { spawnSyncWorker, type SpawnedSyncWorker } from "./sync-worker-client.js";
18
+ import type { Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSettings, AiTransformRequest, AiTransformResponse, MailxApi } from "@bobfrankston/mailx-types";
19
+ import { type TrustListType } from "@bobfrankston/mailx-types";
20
+ interface ReputationResult {
21
+ flagged: boolean;
22
+ listedCount: number;
23
+ checkedCount: number;
24
+ sources: Array<{
25
+ service: string;
26
+ flagged: boolean;
27
+ verdict: string;
28
+ }>;
29
+ verdict: string;
30
+ service: string;
31
+ /** Services that answered something that is NOT a listing verdict —
32
+ * "query came through a public resolver", "volume limit exceeded",
33
+ * SERVFAIL. Kept out of listedCount/checkedCount so a refusal can never
34
+ * read as a conviction, and carried to the UI/log so the feature can say
35
+ * it is blind rather than silently reporting "clean". */
36
+ unavailable: Array<{
37
+ service: string;
38
+ reason: string;
39
+ }>;
40
+ }
41
+ export type DnsblOutcome = {
42
+ service: string;
43
+ status: "listed";
44
+ verdict: string;
45
+ } | {
46
+ service: string;
47
+ status: "clean";
48
+ } | {
49
+ service: string;
50
+ status: "unavailable";
51
+ reason: string;
52
+ };
53
+ /** Interpret one A record from a DNSBL. Returns a verdict for a documented
54
+ * listing code, or the reason the answer cannot be read as one. */
55
+ export type DnsblInterpret = (ip: string) => {
56
+ verdict: string;
57
+ } | {
58
+ reason: string;
59
+ };
60
+ export declare const interpretSpamhausDbl: DnsblInterpret;
61
+ /** URIBL multi: 2 black, 4 grey, 8 red. */
62
+ export declare const interpretUribl: DnsblInterpret;
63
+ /** SURBL multi: 8 phishing, 16 malware, 32 abuse, 64 cracked. */
64
+ export declare const interpretSurbl: DnsblInterpret;
65
+ /** Injected popup function — wraps mailx-host's showMessageBox so the
66
+ * service can pop OS-level always-on-top reminder windows without
67
+ * itself depending on mailx-host (keeps the host abstraction clean).
68
+ * Set via setPopupFn in bin/mailx.ts after construction. Returns the
69
+ * label of the button the user clicked, or "closed" / "dismissed" /
70
+ * "" when the window was closed without picking one. */
71
+ export type PopupFn = (opts: any) => Promise<{
72
+ button?: string;
73
+ closed?: boolean;
74
+ dismissed?: boolean;
75
+ }>;
76
+ /** Injected popout-window function — bin/mailx.ts wraps mailx-host's
77
+ * showMessageBoxEx pointed at the loopback popout server, so "open this
78
+ * message in its own OS window" spawns a native msger WebView window
79
+ * instead of falling back to the OS browser. Injection (not import)
80
+ * keeps mailx-service host-agnostic, same as PopupFn. */
81
+ export type PopoutWindowFn = (opts: {
82
+ accountId: string;
83
+ uid: number;
84
+ folderId?: number;
85
+ subject?: string;
86
+ }) => Promise<{
87
+ ok: boolean;
88
+ reason?: string;
89
+ }>;
90
+ /** Injected compose-popout functions — bin/mailx.ts wraps mailx-host's
91
+ * showMessageBoxEx pointed at the popout server's static compose page, so
92
+ * "take this compose into its own OS window" spawns a native msger window.
93
+ * `open` receives the stash id the page will consume; `close` tears the
94
+ * window down after send/discard. Injection keeps mailx-service host-agnostic. */
95
+ export type ComposePopoutFns = {
96
+ open: (opts: {
97
+ id: string;
98
+ subject?: string;
99
+ }) => Promise<{
100
+ ok: boolean;
101
+ reason?: string;
102
+ }>;
103
+ close: (id: string) => void;
104
+ };
105
+ export declare class MailxService implements MailxApi {
106
+ /** The Store is the nexus — owns DB + .eml files + bus + operations.
107
+ * MailxService is the IPC adapter on top: it routes UI requests to
108
+ * Store reads/writes and to the SyncQueue/Reconciler for server-
109
+ * mirror work. The raw `db` getter below is a transitional shim so
110
+ * the ~50 `this.db.X(...)` callsites in this file don't all need
111
+ * touching at once; all writes should migrate to `this.localStore.X(...)`. */
112
+ private store;
113
+ private imapManager;
114
+ private _accountsCache;
115
+ private _contactsCorpusSeeded;
116
+ private _contactsRetries;
117
+ private _settingsCache;
118
+ private popupFn;
119
+ private popoutWindowFn;
120
+ /** Inject the popup implementation. Called once at startup from
121
+ * bin/mailx.ts with mailx-host's showMessageBox. Without this,
122
+ * showReminderPopup returns a "no host" reason. */
123
+ setPopupFn(fn: PopupFn): void;
124
+ /** Inject the popup CLOSER — closes an open reminder popup by its
125
+ * occurrence key (bin keeps the handle registry). Used to retract a
126
+ * popup when the cross-device state sync says another machine already
127
+ * dismissed/snoozed the same reminder. */
128
+ private popupCloserFn;
129
+ setPopupCloserFn(fn: (key: string) => boolean): void;
130
+ closeReminderPopup(key: string): {
131
+ ok: boolean;
132
+ };
133
+ /** Inject the popout-window implementation (native msger window per
134
+ * message). Without this, popoutWindow returns a "no host" reason and
135
+ * the client falls back to its in-window overlay. */
136
+ setPopoutWindowFn(fn: PopoutWindowFn): void;
137
+ private mailtoRegisterFn;
138
+ /** Inject the OS mailto:-handler registration (Settings → "Make rmfmail
139
+ * the default email app"). The registry/xdg work lives in bin/mailx.ts —
140
+ * platform-specific code stays out of this package. */
141
+ setMailtoRegisterFn(fn: () => Promise<{
142
+ ok: boolean;
143
+ message: string;
144
+ }> | {
145
+ ok: boolean;
146
+ message: string;
147
+ }): void;
148
+ registerMailto(): Promise<{
149
+ ok: boolean;
150
+ message: string;
151
+ }>;
152
+ /** Open a message in its own native OS window (msger WebView). The
153
+ * client's 🗗 button lands here via jsonrpc. */
154
+ popoutWindow(accountId: string, uid: number, folderId?: number, subject?: string): Promise<{
155
+ ok: boolean;
156
+ reason?: string;
157
+ }>;
158
+ private composePopoutFns;
159
+ /** Compose-popout init stash, keyed by the id embedded in the popout
160
+ * window's URL. One-shot: consumePopoutComposeInit deletes on read. */
161
+ private composePopoutInits;
162
+ setComposePopoutFns(fns: ComposePopoutFns): void;
163
+ /** Take a live compose into its own native OS window. The overlay hands
164
+ * us its full state (fields, body, attachments, draft identity); we
165
+ * stash it under a one-shot id and spawn the window pointed at the
166
+ * popout server's compose page with that id in the URL. */
167
+ popoutCompose(init: any): Promise<{
168
+ ok: boolean;
169
+ reason?: string;
170
+ }>;
171
+ /** One-shot read of a stashed compose-popout init (popout page boot). */
172
+ consumePopoutComposeInit(id: string): {
173
+ init: any | null;
174
+ };
175
+ /** Fetch a remote image server-side and return it as a data: URI. Used
176
+ * by compose paste: clipboard HTML often references remote images that
177
+ * the editor cannot load (hotlink protection, blocked remote content,
178
+ * auth cookies) — Bob 2026-07-12 pasted a GIF and got the broken-image
179
+ * alt text. Inlining also makes the outgoing mail self-contained. */
180
+ fetchImageAsDataUri(url: string): Promise<{
181
+ dataUri?: string;
182
+ error?: string;
183
+ }>;
184
+ /** Close a compose-popout window (after send / discard in the popout). */
185
+ closeComposePopout(id: string): {
186
+ ok: boolean;
187
+ };
188
+ private mainPopoutFn;
189
+ setMainPopoutFn(fn: (target?: {
190
+ accountId?: string;
191
+ folderId?: number;
192
+ name?: string;
193
+ }) => Promise<{
194
+ ok: boolean;
195
+ reason?: string;
196
+ }>): void;
197
+ /** Open ANOTHER full mailx window (View → New window) — e.g. Drafts in
198
+ * one OS window, Sent in another. Served by the popout server with the
199
+ * HTTP bridge, so all request/response IPC works; push events do not
200
+ * reach secondary windows (no live new-mail update — known limit). */
201
+ popoutMainWindow(target?: {
202
+ accountId?: string;
203
+ folderId?: number;
204
+ name?: string;
205
+ }): Promise<{
206
+ ok: boolean;
207
+ reason?: string;
208
+ }>;
209
+ /** Local-first read/write facade. Every UI IPC handler that touches the
210
+ * local DB or body store goes through this — no awaiting IMAP, no
211
+ * awaiting Gmail API, no awaiting SMTP. See docs/local-first-plan.md. */
212
+ private localStore;
213
+ /** Phase 0 read isolation (docs/clean-architecture.md §5). The DB read
214
+ * surface runs on its OWN thread with its own read-only SQLite handle, so
215
+ * UI list/search/open reads can NEVER be head-of-line-blocked behind a
216
+ * synchronous sync write or a hung IMAP op on the main event loop. Null
217
+ * until the worker has finished its init handshake (a few ms at startup)
218
+ * and null forever if spawning failed — in both cases reads fall back to
219
+ * the in-process `localStore`, same code, same answers. `readReady`
220
+ * resolves once the spawn attempt settles (either outcome). */
221
+ private dbWorker;
222
+ private readReady;
223
+ /** Persistent (and in-memory body-fetch) queue. UI handlers commit
224
+ * locally, then enqueue a server-mirror task here. */
225
+ private syncQueue;
226
+ /** Background loop: drains body-fetch tasks, retries failed message
227
+ * actions, emits sync-state events for the status pill. */
228
+ private reconciler;
229
+ constructor(
230
+ /** The Store is the nexus — owns DB + .eml files + bus + operations.
231
+ * MailxService is the IPC adapter on top: it routes UI requests to
232
+ * Store reads/writes and to the SyncQueue/Reconciler for server-
233
+ * mirror work. The raw `db` getter below is a transitional shim so
234
+ * the ~50 `this.db.X(...)` callsites in this file don't all need
235
+ * touching at once; all writes should migrate to `this.localStore.X(...)`. */
236
+ store: Store, imapManager: ImapManager);
237
+ private _walCheckpointTimer;
238
+ /** Bring up the read-worker. Resolves whether it succeeded or not — on
239
+ * failure `dbWorker` stays null and every read uses `localStore`. */
240
+ private spawnReadWorker;
241
+ /** In-flight read coalescing. A slow daemon makes the UI re-fire the same
242
+ * read (scroll loadMore retries, poll loops, re-render storms): the log
243
+ * showed the IDENTICAL getUnifiedInbox queued dozens of times and all
244
+ * draining at once after a 78s stall. Keyed by method+args, a duplicate
245
+ * request that arrives while one is already running shares that single
246
+ * promise instead of enqueuing another worker job. Same args ⇒ same
247
+ * result, so this is always safe; different args (other pages) key
248
+ * separately. This caps the worker queue at one job per distinct read. */
249
+ private inflightReads;
250
+ /** Route a read through the worker bus if it's up, else run it in-process.
251
+ * A worker-side error (or a dead worker) transparently falls back so a
252
+ * read never fails just because the isolation layer hiccuped. Identical
253
+ * concurrent reads are coalesced (see inflightReads). */
254
+ private read;
255
+ /** Transitional getter — direct DB access from MailxService for the
256
+ * ~50 callsites that haven't yet migrated to Store methods. Future:
257
+ * every mutation routes through `this.localStore.X(...)`. */
258
+ private get db();
259
+ private _contactsFlushTimer;
260
+ private _contactsFlushInFlight;
261
+ private readonly CONTACTS_FLUSH_DEBOUNCE_MS;
262
+ /** Schedule a debounced flush of the local contacts state to GDrive.
263
+ * Multiple changes within the debounce window collapse to one write. */
264
+ markContactsDirty(): void;
265
+ /** Serializes read-modify-write cycles on the cloud contacts.jsonc within
266
+ * this process. Without it, the debounced flush can read the file, an
267
+ * addToDenylist/setPriority write can land, and the flush write-back then
268
+ * clobbers it — the "never suggest didn't stick" bug. Cross-device
269
+ * overlap is handled separately by flushContactsConfig merging instead
270
+ * of overwriting. */
271
+ private _contactsCloudChain;
272
+ private contactsCloudSerial;
273
+ /** Write current DB contacts state to GDrive contacts.jsonc. Called via
274
+ * the debounced timer; also exposed for force-flush on shutdown or
275
+ * after a manual seed. Idempotent — safe to call multiple times. */
276
+ flushContactsConfig(): Promise<void>;
277
+ private flushContactsConfigNow;
278
+ /** Read contacts.jsonc from cloud + apply (preferred + denylist + discovered)
279
+ * into the DB. On first run with no file, seed from message corpus and
280
+ * write a fresh contacts.jsonc to GDrive — that auto-bootstrap is what
281
+ * makes a new device useful immediately on a shared GDrive setup. */
282
+ loadContactsConfig(): Promise<{
283
+ preferred: number;
284
+ discovered: number;
285
+ purged: number;
286
+ conflicts: string[];
287
+ } | null>;
288
+ /** Append an entry to contacts.jsonc#preferred[] and write back to cloud,
289
+ * then re-apply. Mutates the file in place — preserves existing entries
290
+ * and the user's hand-formatting where the parser permits. */
291
+ addPreferredContact(entry: {
292
+ name: string;
293
+ email: string;
294
+ source?: string;
295
+ organization?: string;
296
+ }): Promise<void>;
297
+ /** Return the priority sender / domain index — derived from the most
298
+ * recently loaded contacts.jsonc. Used by the client to flag list
299
+ * rows visually. */
300
+ getPriorityLists(): {
301
+ senders: string[];
302
+ domains: string[];
303
+ };
304
+ /** Mark / unmark a sender as priority. Finds the matching preferred[]
305
+ * entry (by lowercased email), sets `priority: true|false`, and writes
306
+ * contacts.jsonc back to cloud. If no preferred entry exists, one is
307
+ * inserted with the given name. Reloads the in-memory index. */
308
+ setPrioritySender(email: string, value: boolean, name?: string): Promise<void>;
309
+ /** Mark / unmark a domain as priority. Maintained in
310
+ * contacts.jsonc → priorityDomains[] (separate from preferred[]
311
+ * because per-address contacts don't have a domain shape). */
312
+ setPriorityDomain(domain: string, value: boolean): Promise<void>;
313
+ /** Append an email to contacts.jsonc#denylist[] and write back to cloud,
314
+ * then re-apply (which purges any matching discovered rows). */
315
+ addToDenylist(email: string): Promise<void>;
316
+ /** Return accounts from cache — load once, reuse until configChanged. */
317
+ private getCachedAccounts;
318
+ /** Return full settings from cache — load once, reuse until
319
+ * configChanged invalidates. Hot-path callers (getMessage on every
320
+ * preview click, refreshCalendarEvents on every poll) MUST go
321
+ * through here; calling raw `loadSettings()` blocks the event loop
322
+ * with a sync GDrive-mounted readFileSync. See `_settingsCache`
323
+ * comment for the wedge-28-seconds story. */
324
+ private getCachedSettings;
325
+ /** Resolve the reminder sound for the client when an alarm fires.
326
+ * Returns `{mute:true}` for "none", the raw sound bytes (base64 + mime)
327
+ * for a custom file, or `{}` to mean "play the built-in chime" (the
328
+ * default, AND the fallback when a configured file can't be read). The
329
+ * custom path is resolved relative to the config: the LOCAL config dir
330
+ * (~/.rmfmail/<name>, binary-safe, subpaths OK) first, then the shared
331
+ * cloud config folder on Drive (flat, basename only) — so the sound can
332
+ * live next to the JSONC config and follow the user across machines. */
333
+ getReminderSound(): Promise<{
334
+ mute?: boolean;
335
+ dataBase64?: string;
336
+ mime?: string;
337
+ }>;
338
+ private reminderStateCache;
339
+ getReminderState(): Promise<any>;
340
+ /** Merge a device's local state into the shared copy; returns the merged
341
+ * result (push + pull in one round-trip — the client adopts it). */
342
+ mergeReminderState(patch: any): Promise<any>;
343
+ getAccounts(): any[];
344
+ getFolders(accountId: string): Folder[] | Promise<Folder[]>;
345
+ getUnifiedInbox(page?: number, pageSize?: number, flaggedOnly?: boolean, dateBasis?: "sent" | "received"): any;
346
+ getMessages(accountId: string, folderId: number, page?: number, pageSize?: number, sort?: string, sortDir?: string, search?: string, flaggedOnly?: boolean, dateBasis?: "sent" | "received"): any;
347
+ /** UI body read — local-first. Returns immediately from the local cache:
348
+ * `cached: true` with the full parsed body when on disk, or `cached: false`
349
+ * with envelope-only when not. In the cache-miss case we kick off a
350
+ * fire-and-forget IMAP fetch in the background and emit `bodyAvailable`
351
+ * when the body lands; the UI listens for that event and re-requests.
352
+ *
353
+ * No `await imap*` in the click → render path. The 60s body-fetch race
354
+ * and structured `bodyError` shape that lived here previously are gone —
355
+ * step 1 of the local-first refactor (docs/local-first-plan.md). */
356
+ getMessage(accountId: string, uid: number, allowRemote?: boolean, folderId?: number): Promise<any>;
357
+ /** Diagnostic accessor — exposed for the sync-status pill / debug UI.
358
+ * Returns the current queue counts so the UI can render
359
+ * "Sync OK / Syncing N items" without polling per-account state. */
360
+ getSyncStatus(): {
361
+ messageActions: number;
362
+ bodyFetches: number;
363
+ };
364
+ /** RFC 8058 one-click unsubscribe: POST `List-Unsubscribe=One-Click` to the
365
+ * HTTPS URL the message's List-Unsubscribe header advertised. Done server-
366
+ * side because the unsubscribe endpoint usually doesn't set CORS headers,
367
+ * so a browser-side fetch would be blocked. */
368
+ unsubscribeOneClick(url: string): Promise<{
369
+ ok: boolean;
370
+ status: number;
371
+ statusText: string;
372
+ }>;
373
+ /** AI spend since `sinceMs` (default: start of this month). Reads the
374
+ * per-call log written by aiTransform. `unpricedCalls` are excluded from
375
+ * `costUsd` — a model with no published price contributes tokens, not a
376
+ * made-up dollar figure. */
377
+ getAiUsage(sinceMs?: number): AiUsageSummary;
378
+ /** Fetch a remote image so the WebView can put it on the clipboard.
379
+ *
380
+ * The client cannot do this itself: an image hosted by a sender is
381
+ * cross-origin and virtually never carries `Access-Control-Allow-Origin`,
382
+ * so `fetch` rejects and a canvas draw of the rendered <img> is tainted.
383
+ * Node has no same-origin policy, so the daemon fetches the bytes and
384
+ * hands them back base64. Only reachable from the viewer's "Copy image"
385
+ * action, and only for a URL the message already rendered.
386
+ *
387
+ * Note this is a deliberate remote request: the caller has already shown
388
+ * the image (remote content allowed for that message), so nothing new is
389
+ * disclosed to the sender beyond the load that already happened. */
390
+ fetchRemoteImage(url: string): Promise<{
391
+ base64: string;
392
+ contentType: string;
393
+ }>;
394
+ /** Per-session map: editId → temp file path, which editor got it, and
395
+ * watcher cleanup. Lives in memory only — cleared when the user closes
396
+ * compose or sends. */
397
+ private wordEdits;
398
+ /** In-flight opens, keyed by editId. Converting a body to .docx and
399
+ * launching Word takes seconds, during which the button looks dead — so
400
+ * the user clicks it again, and again. Each of those clicks used to
401
+ * convert its own timestamped file and spawn its own Word window, and
402
+ * because each open re-armed the watcher on ITS file, only the LAST
403
+ * window's saves came back: editing in one of the earlier windows was
404
+ * silently discarded (Bob 2026-08-09: "it shouldn't spawn multiple
405
+ * instances but just ignore extras"). Extra clicks now join the first
406
+ * open's promise instead of starting a second one. */
407
+ private wordEditOpens;
408
+ /** Hand the current compose body off to Microsoft Word for editing. Writes
409
+ * the HTML to `~/.mailx/external-edit/<editId>.html`, opens it via the
410
+ * default OS handler (Word on Windows when .html is associated; otherwise
411
+ * the user's chosen editor for HTML), and starts an fs.watch that emits
412
+ * `wordEditUpdated` when Word saves. The compose UI listens for that
413
+ * event and reloads the editor.
414
+ *
415
+ * Windows-only by current default — on Mac/Linux there's no equivalent
416
+ * reliable round-trip. The compose toolbar should hide the button on
417
+ * non-win32 platforms. */
418
+ openInWord(editId: string, html: string, popoutId?: string): Promise<{
419
+ ok: boolean;
420
+ path: string;
421
+ opener: string;
422
+ reused?: boolean;
423
+ superseded?: boolean;
424
+ }>;
425
+ /** Re-launch an already-written external-edit file. Same launch commands
426
+ * as the first open; the OS activates the existing window when the
427
+ * document is already loaded. Failure is not fatal — the file is there
428
+ * and the watcher is still armed. */
429
+ private relaunchExternal;
430
+ /** Age out the external-edit scratch directory.
431
+ *
432
+ * Every Edit-in-Word writes a fresh timestamped .docx (the filename is
433
+ * unique per open so a still-locked previous file cannot cause EBUSY),
434
+ * plus Word's ~$ lock files and the autosave sidecar's .ps1. Nothing
435
+ * ever deleted them, so the directory accumulated every document the
436
+ * user had ever edited — copies of real letters, sitting in the clear
437
+ * indefinitely (Bob 2026-08-10: "clean up external edits more than a
438
+ * week old"). A week is comfortably longer than any live editing session
439
+ * and short enough that the folder stops being an archive.
440
+ *
441
+ * Files belonging to a LIVE session are skipped regardless of age — a
442
+ * document open in Word for eight days is still in use. */
443
+ private sweepExternalEdits;
444
+ private openInWordUncached;
445
+ /** Fallback path used when html-to-docx fails. Reproduces the old
446
+ * .html-based flow: writes <editId>.html, watches it, emits the body
447
+ * content on save. User has to Save-As-Web-Page in Word for the file
448
+ * to update — but at least the edit path works. */
449
+ private openExternalHtmlFallback;
450
+ /** Show an OS-level always-on-top reminder popup. Spawns a separate
451
+ * msger window (via the injected popupFn) with the supplied HTML
452
+ * and button list; the chosen button label is returned to the
453
+ * client which then snoozes/dismisses/opens the underlying event.
454
+ * No-op (returns reason) when no popupFn was injected — keeps the
455
+ * service usable in test harnesses without a UI host. */
456
+ showReminderPopup(opts: {
457
+ title: string;
458
+ html: string;
459
+ buttons: string[];
460
+ size?: {
461
+ width: number;
462
+ height: number;
463
+ };
464
+ pos?: {
465
+ x: number;
466
+ y: number;
467
+ };
468
+ /** Occurrence key for the retraction registry (see setPopupCloserFn). */
469
+ popupKey?: string;
470
+ }): Promise<{
471
+ button: string;
472
+ form?: any;
473
+ reason?: string;
474
+ }>;
475
+ /** Read + delete the pending mailto: file (P115). Client calls this
476
+ * on startup so a `mailx --mailto <url>` invocation that spawned us
477
+ * doesn't lose its compose payload to the daemon-fires-before-app-
478
+ * registers race window. Returns null when no pending file is present.
479
+ * The file lives at `~/.rmfmail/pending-mailto.json` and is one-shot —
480
+ * reading it deletes it. */
481
+ consumePendingMailto(): {
482
+ to: string[];
483
+ cc: string[];
484
+ bcc: string[];
485
+ subject: string;
486
+ body: string;
487
+ inReplyTo: string;
488
+ } | null;
489
+ /** Read + delete the pending Windows-share file (C46). Client calls
490
+ * this on startup so a share that spawned the daemon (rmfshare.exe →
491
+ * `node mailx.js`) isn't lost to the fires-before-app-registers race —
492
+ * same contract as consumePendingMailto above. Staged files are
493
+ * returned base64-encoded (the WebView can't read disk) and their
494
+ * staging dirs reaped afterwards. Mirrors bin/share-target.ts, which
495
+ * serves the daemon's live fs.watch path — packages/ can't import
496
+ * bin/, so the two implementations stay in lockstep by hand. */
497
+ consumePendingShare(): {
498
+ title: string;
499
+ text: string;
500
+ url: string;
501
+ attachments: {
502
+ filename: string;
503
+ mimeType: string;
504
+ dataBase64: string;
505
+ }[];
506
+ skipped: {
507
+ name: string;
508
+ reason: string;
509
+ }[];
510
+ } | null;
511
+ /** End external editing. Stops the watcher, removes the temp file.
512
+ * Caller is the compose UI when the user closes the window or sends. */
513
+ closeWordEdit(editId: string): Promise<void>;
514
+ /** Toggle flags. Local DB write + Store event + server-mirror enqueue.
515
+ * No IMAP code path runs in the click→ack window — the queued drain
516
+ * fires 1s later, coalescing rapid toggles. */
517
+ updateFlags(accountId: string, uid: number, flags: string[], folderId?: number): Promise<void>;
518
+ allowRemoteContent(type: "sender" | "domain" | "recipient", value: string): Promise<void>;
519
+ /**
520
+ * Silence the spam note for a sender WITHOUT loading their images.
521
+ *
522
+ * Bob 2026-08-28, on a newsletter he subscribed to: "I don't need the spam
523
+ * reminder but I don't want to say show remote content in order to prevent
524
+ * tracking." Those are two different questions and the allowlist was
525
+ * answering both with one list — the only way to say "this correspondent
526
+ * is fine" was the remote-content banner, which buys a tracking pixel with
527
+ * every vote of confidence.
528
+ *
529
+ * So: `senders`/`domains` mean "load their images, and I trust them";
530
+ * `trustedSenders`/`trustedDomains` mean only the second half. Both feed
531
+ * `senderTrusted` in the store; only the first feeds `allowRemote`.
532
+ * Toggles, so a mistake is one click to undo.
533
+ */
534
+ trustSenderOrDomain(type: TrustListType, value: string): Promise<{
535
+ trusted: boolean;
536
+ }>;
537
+ /**
538
+ * Read the shared allowlist so the UI can ask "is this sender approved?"
539
+ *
540
+ * The write side (allowRemoteContent / flagSenderOrDomain) already
541
+ * existed; nothing could read it back, so the message list had no way to
542
+ * know a sender had been vouched for. Returns plain arrays — the caller
543
+ * builds whatever index it wants.
544
+ */
545
+ getAllowlist(): Promise<{
546
+ senders: string[];
547
+ domains: string[];
548
+ recipients: string[];
549
+ flaggedSenders: string[];
550
+ flaggedDomains: string[];
551
+ trustedSenders: string[];
552
+ trustedDomains: string[];
553
+ trustedIntermediaries: string[];
554
+ trustedLists: string[];
555
+ trustedSendersVia: string[];
556
+ }>;
557
+ getUserDict(): Promise<string[]>;
558
+ addUserDictWord(word: string): Promise<string[]>;
559
+ /** Bulk add — used to reconcile a client's localStorage cache up to the
560
+ * cloud file (e.g. words added before the cloud round-trip existed). */
561
+ addUserDictWords(words: string[]): Promise<string[]>;
562
+ removeUserDictWord(word: string): Promise<string[]>;
563
+ /** Domain-reputation cache. Lookups are fast (~50ms each, three in
564
+ * parallel) but we still don't want to redo them on every render of
565
+ * the same sender's mail. Five-minute TTL — long enough that scrolling
566
+ * a folder fans out one query set, short enough that a newly-listed
567
+ * domain surfaces within minutes. */
568
+ private reputationCache;
569
+ private static readonly REPUTATION_TTL_MS;
570
+ private static readonly REPUTATION_TIMEOUT_MS;
571
+ /** Check a domain against three free no-key DNS blocklists in parallel:
572
+ *
573
+ * Spamhaus DBL — `<d>.dbl.spamhaus.org` spam/phish/malware
574
+ * SURBL multi — `<d>.multi.surbl.org` mixed (ph/mw/abuse)
575
+ * URIBL multi — `<d>.multi.uribl.com` black/grey/red lists
576
+ *
577
+ * A DNSBL answers in 127.0.0.0/8, and only SOME of those addresses mean
578
+ * "listed" — the rest are STATUS codes. Treating any A record as a
579
+ * listing is the bug that flagged e.nytimes.com as spam on 2 of 2
580
+ * services (Bob 2026-08-13). From a consumer connection every DBL query
581
+ * comes back `127.255.255.254` ("query via a public/open resolver"),
582
+ * including for google.com and for Spamhaus's own dbltest.com, and
583
+ * URIBL answers `127.0.0.1` ("query refused") the same way — so the old
584
+ * code convicted every domain it looked at. Each service now gets an
585
+ * interpreter that returns a verdict ONLY for a documented listing code;
586
+ * anything else is "unavailable" and never reaches the banner.
587
+ *
588
+ * Each lookup is bounded at 500 ms; missing/slow services are treated
589
+ * as "unknown" (don't poison the cache). Returns the aggregate plus
590
+ * the per-service detail so the UI can show "N of 3 services flag
591
+ * this domain" with the contributing source list.
592
+ *
593
+ * Spamhaus DQS: a free personal key (https://www.spamhaus.com/free-trial/)
594
+ * makes DBL answer from any resolver — queries go to
595
+ * `<domain>.<key>.dbl.dq.spamhaus.net` instead. Set `spamhausDqsKey` in
596
+ * Settings; without it, DBL is simply reported as unavailable rather
597
+ * than guessed at.
598
+ *
599
+ * Privacy: each query leaks the bare domain to that DNSBL's
600
+ * infrastructure plus the user's local resolver. Opt-in via Settings. */
601
+ checkDomainReputation(domain: string): Promise<ReputationResult | null>;
602
+ /** Mark a sender or domain as suspect. Surfaced in the remote-content
603
+ * banner as a red warning on subsequent messages. Toggle: calling with
604
+ * the same value removes it. Returns the new state for UI feedback. */
605
+ flagSenderOrDomain(type: "sender" | "domain", value: string): Promise<{
606
+ flagged: boolean;
607
+ }>;
608
+ /** Monotonic generation for in-flight server searches. A new server
609
+ * search or a cancelServerSearch() call bumps it; the server-search
610
+ * loop checks it between folder batches and bails when superseded. */
611
+ private serverSearchGen;
612
+ /** Abort any in-flight server search. The client calls this when the
613
+ * search box is edited or cleared so a 90-folder IMAP sweep doesn't
614
+ * keep churning for a query the user has already moved on from. */
615
+ cancelServerSearch(): void;
616
+ search(q: string, page?: number, pageSize?: number, scope?: string, accountId?: string, folderId?: number, includeTrashSpam?: boolean): Promise<any>;
617
+ rebuildSearchIndex(): number;
618
+ getSyncPending(): {
619
+ pending: number;
620
+ };
621
+ /** Outbox queue depth + retry status for the UI status bar. Cheap to call. */
622
+ getOutboxStatus(): any;
623
+ /** Per-account health snapshot: inactivity-timeout count, conn-cap hits,
624
+ * last failed IMAP command. Drives the diagnostics ⚠ badge in the UI. */
625
+ getDiagnostics(): any;
626
+ /** Return the account that supplies `feature` data (calendar / tasks /
627
+ * contacts). Resolution order:
628
+ * 1. Any account with `primary<Feature>: true` (per-feature override)
629
+ * 2. Any account with `primary: true` (catch-all default)
630
+ * 3. First account (fallback)
631
+ * Called without `feature` it returns the catch-all primary — same
632
+ * semantics as the original single-flag version for back-compat. */
633
+ getPrimaryAccount(feature?: string): any;
634
+ /** Feature names that have already emitted authScopeError this session.
635
+ * Stops the "banner flashing on and off continually" loop where every
636
+ * 5-min poll / sidebar nav re-fired the event and the client re-rendered
637
+ * the red banner. Cleared when the user hits Re-authenticate. */
638
+ private scopeErrorEmitted;
639
+ /** Quota cooldown — feature → epoch-ms when the next API call is allowed.
640
+ * Set when Google returns 429 (rate limit / daily-quota exceeded). While
641
+ * cooldown is in effect, getCalendarEvents/getTasks return local DB rows
642
+ * without firing a refresh. Heuristic cooldown is one hour; the daily
643
+ * Google Tasks quota actually resets at Pacific midnight, but a one-hour
644
+ * short-circuit keeps the log clean and avoids hammering after a burst. */
645
+ private quotaCooldown;
646
+ /** Sticky "quota exceeded" emit guard — same shape as scopeErrorEmitted. */
647
+ private quotaErrorEmitted;
648
+ /** In-flight refresh promises keyed by feature, so concurrent UI calls
649
+ * share one Google round-trip instead of stacking N parallel fetches.
650
+ * The fire-and-forget loop where `tasksUpdated` re-triggers `getTasks`
651
+ * used to spawn a new refresh on every event RTT — this dedupes them. */
652
+ private refreshingCalendar;
653
+ private refreshingTasks;
654
+ /** Wall-clock of the last Google refresh per account, for calendar and
655
+ * tasks. The alarm poll calls getCalendarEvents/getTasks every 30 s;
656
+ * without a throttle that hammered Google's APIs and exhausted the
657
+ * Tasks "Queries per day" project quota (Bob 2026-05-16). These gate
658
+ * the actual network refresh to GOOGLE_REFRESH_MIN_INTERVAL_MS — the
659
+ * call still returns local rows instantly, it just doesn't re-pull. */
660
+ private lastCalendarRefresh;
661
+ private lastTasksRefresh;
662
+ /** Delete the cached Google OAuth token (the one used for Calendar / Tasks
663
+ * / Contacts scopes — NOT the IMAP token which `reauthenticate()` handles)
664
+ * and clear the sticky auth-error state so a subsequent refresh can
665
+ * re-trigger browser consent with the current scope set. Equivalent of
666
+ * `mailx -reauth` but callable from the UI. Returns `{ cleared: N }` so
667
+ * the caller can tell the user what happened. */
668
+ reauthGoogleScopes(): {
669
+ cleared: number;
670
+ };
671
+ private primaryTokenProvider;
672
+ /** Return cal events visible in [fromMs..toMs), refreshing from Google
673
+ * in the background. Caller displays local results immediately; after
674
+ * the refresh completes the service emits `calendarUpdated` so the UI
675
+ * re-renders with pulled-in rows. Fire-and-forget-with-event, not
676
+ * fire-and-forget-and-pray. */
677
+ getCalendarEvents(fromMs: number, toMs: number): Promise<any[]>;
678
+ /** Pull from Google NOW, ignoring the 5-minute throttle once. The New
679
+ * event dialog hands the event to Google Calendar in the browser and
680
+ * the reader saves it there; until now the sidebar showed it only when
681
+ * the next throttled refresh happened to run (Bob 2026-09-10: "creating
682
+ * an event should trigger an immediate update for the calendar view").
683
+ * Emits calendarUpdated through the usual path when anything changed.
684
+ * A 20 s floor of its own keeps a focus-flapping window from becoming
685
+ * a Google call per flap — the throttle exists because the alarm poll
686
+ * once burned the Tasks quota, and this must not reopen that door.
687
+ * Returns whether a refresh was actually started. (Claude Code) */
688
+ refreshCalendarNow(): Promise<{
689
+ ok: boolean;
690
+ reason?: string;
691
+ }>;
692
+ /** List the user's *selected* Google calendars (id, display name, color,
693
+ * primary flag) so the sidebar can render one checkbox + icon per
694
+ * calendar. The user curates the set by selecting calendars in Google;
695
+ * mailx only reflects it. Returns [] on quota cooldown / auth failure
696
+ * — the sidebar then just shows no per-calendar controls. */
697
+ getCalendars(): Promise<Array<{
698
+ id: string;
699
+ name: string;
700
+ color: string;
701
+ primary: boolean;
702
+ }>>;
703
+ /** Returns true if the feature is currently in a quota-exceeded cooldown. */
704
+ private inQuotaCooldown;
705
+ /** Single error-handling path for Google refresh failures.
706
+ * Distinguishes 429 (quota) from 401/403 (scope) so each gets the right
707
+ * cooldown + sticky-emit treatment without duplicating the regex blocks. */
708
+ private handleGoogleRefreshError;
709
+ /** Pull events in [fromMs..toMs) from Google, upsert locally, reconcile
710
+ * server-side deletions. Returns true if anything changed so callers
711
+ * can decide whether to emit a refresh event. `changed` is only true
712
+ * when at least one row's data actually differs — without this guard
713
+ * the UI's `calendarUpdated` listener re-triggers `getCalendarEvents`,
714
+ * which fires another `refreshCalendarEvents`, which emits again, etc.
715
+ * Tight loop = 429 quota burn. */
716
+ private refreshCalendarEvents;
717
+ createCalendarEventLocal(ev: {
718
+ title: string;
719
+ startMs: number;
720
+ endMs: number;
721
+ allDay?: boolean;
722
+ location?: string;
723
+ notes?: string;
724
+ /** Google birthday event (all-day, yearly) / not-busy event. Not DB
725
+ * columns: they ride in the store_sync payload, which is what
726
+ * localToCalendarEvent builds the POST from (2026-09-21). */
727
+ birthday?: boolean;
728
+ free?: boolean;
729
+ /** Google calendar id to create on; "primary" when absent. A shared
730
+ * calendar's id is its owner's address or a group id — the sidebar
731
+ * offers the ones Google says the reader can write to (2026-09-10). */
732
+ calendarId?: string;
733
+ }): Promise<string>;
734
+ updateCalendarEventLocal(uuid: string, patch: {
735
+ title?: string;
736
+ startMs?: number;
737
+ endMs?: number;
738
+ allDay?: boolean;
739
+ location?: string;
740
+ notes?: string;
741
+ }): Promise<void>;
742
+ deleteCalendarEventLocal(uuid: string): Promise<void>;
743
+ getTasks(includeCompleted?: boolean): Promise<any[]>;
744
+ private refreshTasks;
745
+ createTaskLocal(t: {
746
+ title: string;
747
+ notes?: string;
748
+ dueMs?: number;
749
+ }): Promise<string>;
750
+ updateTaskLocal(uuid: string, patch: {
751
+ title?: string;
752
+ notes?: string;
753
+ dueMs?: number;
754
+ completedMs?: number;
755
+ }): Promise<void>;
756
+ deleteTaskLocal(uuid: string): Promise<void>;
757
+ /** Drain the store_sync queue — calendar / tasks / contacts push-to-server.
758
+ * Called on every local edit, and on a periodic tick from the outbox worker. */
759
+ drainStoreSync(): Promise<void>;
760
+ /** List queued outgoing messages with parsed envelope headers so the UI
761
+ * can render a pink-row "pending" view before IMAP APPEND succeeds. */
762
+ listQueuedOutgoing(): any[];
763
+ /** Manually drop a queued message (not yet sent). Removes the .ltr file. */
764
+ cancelQueuedOutgoing(filePath: string): {
765
+ ok: true;
766
+ };
767
+ syncAll(): Promise<void>;
768
+ syncAccount(accountId: string): Promise<void>;
769
+ /** Sync ONE folder now. Backs the lazy-folder-sync model (Bob 2026-05-28):
770
+ * the client fires this when the user opens a folder, so an on-demand
771
+ * folder is fresh without the app full-sweeping all 79 folders every
772
+ * 5 minutes. Gmail folders are API-synced; IMAP folders go through
773
+ * syncFolder. Fire-and-forget from the caller's view — the folderSynced
774
+ * event refreshes the list when it lands. */
775
+ syncFolderNow(accountId: string, folderId: number): Promise<void>;
776
+ /** Force re-authentication for an account (deletes token, opens browser consent) */
777
+ reauthenticate(accountId: string): Promise<boolean>;
778
+ send(msg: any): Promise<void>;
779
+ /** Trash a single message. Local-first: local row moves to Trash (or
780
+ * expunges if already there); the server mirror is queued. */
781
+ deleteMessage(accountId: string, uid: number, folderId?: number): Promise<void>;
782
+ /** Bulk trash. Same shape as deleteMessage but loops; each row gets its
783
+ * own pending-delete flag, local move/expunge, and queue entry. */
784
+ deleteMessages(accountId: string, uids: number[], folderIds?: number[]): Promise<void>;
785
+ /** Move a message to another folder. Same-account moves go through the
786
+ * Store + queue. Cross-account moves still synchronously bridge two
787
+ * IMAP connections (rare; local-first violation noted as future work). */
788
+ moveMessage(accountId: string, uid: number, targetFolderId: number, targetAccountId?: string): Promise<void>;
789
+ /** Bulk COPY: the messages stay where they are; a copy is queued to the
790
+ * target folder (server APPEND via the sync queue). No local row is
791
+ * moved/removed — the target copy is imported when its folder syncs
792
+ * (the copy action triggers that). folderIds[] disambiguates each uid to
793
+ * its source folder, index-aligned with uids (same contract as
794
+ * deleteMessages). */
795
+ copyMessages(accountId: string, uids: number[], folderIds: number[] | undefined, targetFolderId: number): Promise<void>;
796
+ /** Bulk move. Loops the single-move pattern under a withBatch so the
797
+ * one folder-counts re-render fires once per affected folder, not per
798
+ * message. */
799
+ moveMessages(accountId: string, uids: number[], targetFolderId: number, folderIds?: number[]): Promise<void>;
800
+ /** Move messages to the account's configured spam folder (accounts.jsonc "spam" path).
801
+ * Throws if the account has no spam folder configured or the folder doesn't exist locally. */
802
+ markAsSpamMessages(accountId: string, uids: number[], folderIds?: number[]): Promise<{
803
+ targetFolderId: number;
804
+ moved: number;
805
+ }>;
806
+ /** Append a spam report row to `~/.mailx/spam.csv` — placeholder mechanism
807
+ * per user 2026-04-23 ("let's make it smart later; no auto-delete until
808
+ * safety issues are addressed"). One row per click. Columns: timestamp
809
+ * (ms since epoch), ISO date, ISO time, accountId, Delivered-To, From
810
+ * address, Subject, eml file path. CSV fields RFC 4180-quoted so commas
811
+ * and quotes in subjects survive. No move, no flag change, no server
812
+ * hit — just the log. Useful as training data for a future classifier.
813
+ */
814
+ recordSpamReport(accountId: string, uid: number, folderId: number): Promise<{
815
+ ok: true;
816
+ row: string;
817
+ }>;
818
+ /** Restore from Trash. Local-first: move the row back, clear tombstone
819
+ * and pending-delete flag, then either retract the still-queued MOVE
820
+ * to trash or queue a counter-MOVE (trash → original). */
821
+ undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void>;
822
+ deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void>;
823
+ createFolder(accountId: string, parentPath: string, name: string): Promise<void>;
824
+ /** Rename a folder in place, or — when `newParentId` is given — reparent it.
825
+ * Both are a single server-side mailbox rename (no copy-and-delete). Path
826
+ * computation, guard rails (special-use refusal, name validation, collision
827
+ * / self-descendant checks), the API-vs-IMAP branch, and the local
828
+ * folder-row + descendant-prefix rewrite all live in ImapManager (on the
829
+ * sync worker) so this stays a thin delegate. */
830
+ renameFolder(accountId: string, folderId: number, newName: string, newParentId?: number): Promise<void>;
831
+ deleteFolder(accountId: string, folderId: number): Promise<void>;
832
+ /** Move a folder into the account's Trash. Default delete action; user
833
+ * has to opt in to permanent removal via Shift+Delete (which routes
834
+ * to `deleteFolder` above).
835
+ *
836
+ * Strategy:
837
+ * - IMAP RENAME `<path>` → `<trashPath><delim><name>`. Most servers
838
+ * bring the message contents and any subfolders along automatically.
839
+ * - Name collision in Trash → append " (YYYY-MM-DD)", then a counter
840
+ * " (YYYY-MM-DD 2)", ... up to a sane cap.
841
+ * - RENAME rejected (server forbids subfolders under Trash, e.g. some
842
+ * Dovecot configs flag Trash \Noinferiors) → fall back to moving the
843
+ * folder's messages into Trash root via the existing trash path,
844
+ * then `mailboxDelete` on the now-empty folder. Children lost in
845
+ * that fallback get the same treatment recursively.
846
+ * - Folder is itself Trash, or already inside Trash → throw (caller
847
+ * should detect and route to `deleteFolder` instead).
848
+ */
849
+ moveFolderToTrash(accountId: string, folderId: number): Promise<void>;
850
+ markFolderRead(folderId: number): void;
851
+ emptyFolder(accountId: string, folderId: number): Promise<void>;
852
+ getAttachment(accountId: string, uid: number, attachmentId: number, folderId?: number): Promise<{
853
+ content: Buffer;
854
+ contentType: string;
855
+ filename: string;
856
+ }>;
857
+ /** Delete attachment(s) from a RECEIVED message — local-first message
858
+ * surgery. The raw .eml is rewritten without the selected MIME parts
859
+ * (original kept next to it as .pre-strip.bak), the DB row updates, and
860
+ * a `replaceMessage` sync action queues the server-side swap, which
861
+ * appends the modified copy and only deletes the original after the
862
+ * server confirms the append (never the other order). IMAP accounts
863
+ * only for now — Gmail's REST path needs its own insert+trash pass.
864
+ * `ids` are the viewer's chip ids (= mailparser attachment indexes);
865
+ * "all" means every chip-visible attachment. Every strip is verified by
866
+ * re-parsing the result before anything is written. */
867
+ deleteAttachments(accountId: string, uid: number, folderId: number | undefined, ids: number[] | "all"): Promise<{
868
+ ok: boolean;
869
+ removed: number;
870
+ remaining: number;
871
+ }>;
872
+ /** Raw RFC 822 source of a message — for "Save message as .eml". Reads the
873
+ * on-disk body file (same resolution as getAttachment), falling back to a
874
+ * server fetch. Returned base64 so it survives the IPC JSON channel. */
875
+ getMessageSource(accountId: string, uid: number, folderId?: number): Promise<{
876
+ dataBase64: string;
877
+ filename: string;
878
+ }>;
879
+ /** Save an attachment to a local temp dir and open it with the OS default
880
+ * application. The desktop UI uses this instead of a browser download —
881
+ * a programmatic `<a download>` click is silently dropped inside msger's
882
+ * WebView2, so the open must happen in the Node process (same pattern as
883
+ * openInWord / openLocalPath). Cross-platform: start / open / xdg-open. */
884
+ openAttachment(accountId: string, uid: number, attachmentId: number, folderId?: number, filename?: string): Promise<{
885
+ ok: boolean;
886
+ path: string;
887
+ }>;
888
+ /** Open a file the user is composing WITH — an attachment sitting in the
889
+ * compose window that has never been in a message. The bytes come from
890
+ * the client (it already holds them for the send), so there is nothing
891
+ * to fetch; everything after that is identical to opening a received
892
+ * attachment, which is why both go through stageAndOpenFile. */
893
+ openPendingAttachment(filename: string, mimeType: string, dataBase64: string): Promise<{
894
+ ok: boolean;
895
+ path: string;
896
+ }>;
897
+ /** Stage bytes to the attachment temp dir and hand them to the OS opener.
898
+ * Shared by received attachments and compose attachments — the collision
899
+ * handling and Mark-of-the-Web tagging below are subtle enough that a
900
+ * second copy of them would drift. */
901
+ private stageAndOpenFile;
902
+ /** Open an http/https/mailto URL in the OS default browser/handler from the
903
+ * Node process. The UI used to rely on `window.open(url, "_blank")`, but
904
+ * inside msger's WebView2 that opens a local in-app window (or no-ops)
905
+ * rather than the system browser — so clicking a link in a message, or an
906
+ * unsubscribe "click here", "stayed local" (Bob 2026-06-13, "old bug
907
+ * back"). `mailxapi.openExternal` now routes here.
908
+ *
909
+ * Security: the URL comes from untrusted email. We (1) parse it and allow
910
+ * ONLY http/https/mailto — no file:, javascript:, data:, etc. — and (2)
911
+ * pass it as a single spawn ARG with no shell, and on Windows use rundll32
912
+ * (not `cmd /c start`, which re-parses `&`/`^` in query strings and is
913
+ * injection-prone). Same cross-platform spawn pattern as openAttachment. */
914
+ openExternal(url: string): Promise<{
915
+ ok: boolean;
916
+ reason?: string;
917
+ }>;
918
+ saveDraft(accountId: string, subject: string, bodyHtml: string, bodyText: string, to?: string, cc?: string, previousDraftUid?: number, draftId?: string, bcc?: string, from?: string, attachments?: {
919
+ filename: string;
920
+ mimeType: string;
921
+ dataBase64: string;
922
+ }[]): Promise<{
923
+ draftUid: number | null;
924
+ draftId: string;
925
+ }>;
926
+ deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void>;
927
+ /** Compose-open probe: is there a NEWER copy of this draft on the server
928
+ * (edited on another machine) than the row the user just opened? Best
929
+ * effort — network/auth failures return null and compose proceeds with
930
+ * the local copy (local-first: this never blocks the compose window
931
+ * from opening; the client fires it in the background). */
932
+ checkDraftNewer(accountId: string, draftId: string, sinceUid: number): Promise<{
933
+ uid: number;
934
+ folderId: number;
935
+ } | null>;
936
+ searchContacts(query: string): any[];
937
+ /** Q49: boolean hint for compose to auto-expand Cc when replying to this
938
+ * address. True when at least one past sent message to the same recipient
939
+ * had a non-empty Cc field. */
940
+ hasCcHistoryTo(email: string): boolean;
941
+ /** Q49: same shape, for Bcc. Sent folder is the only place Bcc appears,
942
+ * so the signal is local-only but still reflects the user's habit. */
943
+ hasBccHistoryTo(email: string): boolean;
944
+ syncGoogleContacts(): Promise<void>;
945
+ seedContacts(): Promise<number>;
946
+ /** Explicit add to address book — used by the right-click "Add to contacts"
947
+ * action on From/To/Cc addresses in the message viewer. Just calls the same
948
+ * validated upsert path as recordSentAddress. */
949
+ addContact(name: string, email: string): boolean;
950
+ /** Address-book listing — paginated, filterable. */
951
+ listContacts(query: string, page?: number, pageSize?: number): any;
952
+ /** Upsert a contact from the address book UI (edit name). Two-way cache:
953
+ * commits locally, queues a Google People push. */
954
+ upsertContact(name: string, email: string, fields?: Record<string, string>): {
955
+ ok: true;
956
+ };
957
+ /** Delete a contact from the address book. Also pushes the deletion to
958
+ * Google People if the contact had a resourceName (i.e. was synced). */
959
+ deleteContact(email: string): {
960
+ ok: true;
961
+ };
962
+ /** Open a configured local path in the OS file explorer. Whitelisted to
963
+ * avoid the UI poking at arbitrary paths. */
964
+ /** Open an absolute file path in the OS default *text* editor.
965
+ * Distinct from "open with the file's associated app" — for a .eml
966
+ * that would usually be Outlook / a mail client; the user wants a
967
+ * plain-text viewer like Notepad / TextEdit / xdg's text handler.
968
+ *
969
+ * Cross-platform strategy:
970
+ * - Windows: `notepad.exe <path>`. Always present since Windows 95.
971
+ * Notepad reads any file regardless of extension.
972
+ * - macOS: `open -t <path>` — the `-t` flag explicitly routes to the
973
+ * user's default text editor (TextEdit or whatever they've set).
974
+ * - Linux: try `$VISUAL` / `$EDITOR` first (user's stated preference),
975
+ * then fall back to `xdg-open` with a `.txt` symlink so xdg picks
976
+ * the text MIME handler rather than the .eml handler.
977
+ *
978
+ * Path whitelist: must live under the configured store path or
979
+ * ~/.rmfmail, so the IPC can't be coerced into opening arbitrary
980
+ * files (e.g., system passwords).
981
+ */
982
+ openInTextEditor(filePath: string): Promise<{
983
+ ok: boolean;
984
+ opener: string;
985
+ reason?: string;
986
+ }>;
987
+ openLocalPath(which: "config" | "log"): Promise<{
988
+ ok: true;
989
+ path: string;
990
+ }>;
991
+ /** Get all messages in a thread (across folders) for an account. */
992
+ getThreadMessages(accountId: string, threadId: string): any;
993
+ /** Read a JSONC config file from the shared cloud dir or local ~/.mailx.
994
+ * Names are whitelisted so the UI can't read arbitrary files.
995
+ * `config.jsonc` is the local per-machine config (not cloud-synced). */
996
+ readJsoncFile(name: string): Promise<string | null>;
997
+ formatJsonc(content: string): Promise<string>;
998
+ /** Return the help markdown for a named config file. Prefers a per-file
999
+ * doc (`<name>.md` minus the `.jsonc` suffix — e.g. `contacts.md` for
1000
+ * `contacts.jsonc`) shipped in the package's `docs/` dir; falls back to
1001
+ * the legacy `config-help.md`-with-sections format if the per-file
1002
+ * isn't available. */
1003
+ readConfigHelp(name: string): Promise<string>;
1004
+ /** Write a JSONC config file. Validates that the content parses as JSONC
1005
+ * (loosely — strips comments/trailing commas) before writing.
1006
+ * Saves the prior content to a dated backup file first — manual edits
1007
+ * occasionally have typos that survive validation (semantically wrong
1008
+ * but syntactically OK), and a one-key undo isn't enough; the user
1009
+ * asked to be able to recover yesterday's accounts.jsonc. Automatic
1010
+ * saveAccounts/saveAllowlist paths skip backups (they're driven by
1011
+ * trusted code, not the JSONC editor). */
1012
+ writeJsoncFile(name: string, content: string): Promise<void>;
1013
+ /** Read the current content of a config file (cloud or local) so it can
1014
+ * be saved as a backup before being overwritten. Returns null if the
1015
+ * file doesn't exist yet (first save — nothing to back up). */
1016
+ private readJsoncForBackup;
1017
+ /** Write the prior content to `<configDir>/backup/<name>.<ts>.bak` and
1018
+ * prune so at most 10 backups per file remain AND none are older than 7
1019
+ * days. Skipped when previous content is null (first write) or
1020
+ * identical to the new content (no-op save). */
1021
+ private backupJsoncIfChanged;
1022
+ getSettings(): any;
1023
+ saveSettings(settings: any): Promise<void>;
1024
+ getStorageInfo(): {
1025
+ provider: string;
1026
+ mode: string;
1027
+ cloudPath?: string;
1028
+ folderName?: string;
1029
+ folderId?: string;
1030
+ configDir?: string;
1031
+ cloudError?: string;
1032
+ };
1033
+ /** True when the writer DB handle came up read-only (the OS refused
1034
+ * read-write at open and SQLite silently downgraded it — see
1035
+ * MailxDB.openWritable). Nothing the user does will be saved, so the
1036
+ * client turns this into a sticky fatal banner with a Restart button
1037
+ * rather than letting every action fail one confusing error at a time. */
1038
+ isDbWriteDenied(): boolean;
1039
+ setupAccount(name: string, email: string, password?: string): Promise<{
1040
+ ok: boolean;
1041
+ error?: string;
1042
+ message?: string;
1043
+ }>;
1044
+ repairAccounts(): Promise<{
1045
+ ok: boolean;
1046
+ error?: string;
1047
+ message?: string;
1048
+ }>;
1049
+ getAutocompleteSettings(): AutocompleteSettings;
1050
+ saveAutocompleteSettings(settings: AutocompleteSettings): void;
1051
+ autocomplete(req: AutocompleteRequest): Promise<AutocompleteResponse>;
1052
+ /** Generic AI text transform — translate / proofread / summarize.
1053
+ * Shares the autocomplete provider config (provider, key, model). Each
1054
+ * feature has its own opt-in toggle (translateEnabled / proofreadEnabled),
1055
+ * default false. Returns empty text + reason when disabled or on error. */
1056
+ aiTransform(req: AiTransformRequest): Promise<AiTransformResponse>;
1057
+ private harperInit;
1058
+ private static readonly EMPTY_LINTS;
1059
+ grammarLint(blocks: string[]): Promise<{
1060
+ blocks: {
1061
+ start: number;
1062
+ end: number;
1063
+ kind: string;
1064
+ message: string;
1065
+ suggestions: string[];
1066
+ }[][];
1067
+ }>;
1068
+ /** Event extraction via the interface `gcal add -clip` uses — keep the
1069
+ * prompt and model IN SYNC with Y:/dev/utils/cardx/gcal/glib/aihelper.ts
1070
+ * (EVENT_EXTRACTION_PROMPT / extractEventsFromText). Key sources, in
1071
+ * order: accounts.jsonc `keys.anthropic`, ANTHROPIC_API_KEY env, the
1072
+ * gcards shared key file (%APPDATA%/gcards/keys.env — same file gcal
1073
+ * and gcards read). Returns null ONLY when no key is available, which
1074
+ * lets aiTransform fall through to the configured provider. */
1075
+ private extractEventGcalStyle;
1076
+ sendMessage(msg: any): Promise<void>;
1077
+ searchMessages(q: string, page?: number, pageSize?: number, scope?: string, accountId?: string, folderId?: number, includeTrashSpam?: boolean): Promise<any>;
1078
+ createCalendarEvent(ev: any): Promise<{
1079
+ uuid: string;
1080
+ }>;
1081
+ updateCalendarEvent(uuid: string, patch: any): Promise<{
1082
+ ok: true;
1083
+ }>;
1084
+ deleteCalendarEvent(uuid: string): Promise<{
1085
+ ok: true;
1086
+ }>;
1087
+ createTask(t: {
1088
+ title: string;
1089
+ notes?: string;
1090
+ dueMs?: number;
1091
+ }): Promise<{
1092
+ uuid: string;
1093
+ }>;
1094
+ updateTask(uuid: string, patch: any): Promise<{
1095
+ ok: true;
1096
+ }>;
1097
+ deleteTask(uuid: string): Promise<{
1098
+ ok: true;
1099
+ }>;
1100
+ saveSettingsData(settings: any): Promise<void>;
1101
+ }
1102
+ //# sourceMappingURL=index.d.ts.map