@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.
- package/bin/mailx.js.map +1 -0
- package/bin/mailx.ts +1498 -0
- package/bin/postinstall.js.map +1 -0
- package/bin/postinstall.ts +41 -0
- package/bin/tsconfig.json +10 -0
- package/client/.gitattributes +10 -0
- package/client/app.js +51 -2
- package/client/app.js.map +1 -0
- package/client/app.ts +3112 -0
- package/client/components/address-book.js.map +1 -0
- package/client/components/address-book.ts +204 -0
- package/client/components/alarms.js.map +1 -0
- package/client/components/alarms.ts +276 -0
- package/client/components/calendar-sidebar.js.map +1 -0
- package/client/components/calendar-sidebar.ts +474 -0
- package/client/components/calendar.js.map +1 -0
- package/client/components/calendar.ts +211 -0
- package/client/components/context-menu.js.map +1 -0
- package/client/components/context-menu.ts +95 -0
- package/client/components/folder-picker.js.map +1 -0
- package/client/components/folder-picker.ts +127 -0
- package/client/components/folder-tree.js.map +1 -0
- package/client/components/folder-tree.ts +1069 -0
- package/client/components/message-list.js.map +1 -0
- package/client/components/message-list.ts +1129 -0
- package/client/components/message-viewer.js.map +1 -0
- package/client/components/message-viewer.ts +1257 -0
- package/client/components/outbox-view.js.map +1 -0
- package/client/components/outbox-view.ts +102 -0
- package/client/components/tasks.js.map +1 -0
- package/client/components/tasks.ts +234 -0
- package/client/compose/compose.js.map +1 -0
- package/client/compose/compose.ts +1231 -0
- package/client/compose/editor.js.map +1 -0
- package/client/compose/editor.ts +599 -0
- package/client/compose/ghost-text.js.map +1 -0
- package/client/compose/ghost-text.ts +140 -0
- package/client/index.html +1 -0
- package/client/lib/android-bootstrap.js.map +1 -0
- package/client/lib/android-bootstrap.ts +9 -0
- package/client/lib/api-client.js.map +1 -0
- package/client/lib/api-client.ts +439 -0
- package/client/lib/local-service.js.map +1 -0
- package/client/lib/local-service.ts +646 -0
- package/client/lib/local-store.js.map +1 -0
- package/client/lib/local-store.ts +283 -0
- package/client/lib/message-state.js.map +1 -0
- package/client/lib/message-state.ts +140 -0
- package/client/tsconfig.json +19 -0
- package/package.json +15 -15
- package/packages/mailx-api/.gitattributes +10 -0
- package/packages/mailx-api/index.d.ts.map +1 -0
- package/packages/mailx-api/index.js.map +1 -0
- package/packages/mailx-api/index.ts +283 -0
- package/packages/mailx-api/tsconfig.json +9 -0
- package/packages/mailx-compose/.gitattributes +10 -0
- package/packages/mailx-compose/index.d.ts.map +1 -0
- package/packages/mailx-compose/index.js.map +1 -0
- package/packages/mailx-compose/index.ts +85 -0
- package/packages/mailx-compose/tsconfig.json +9 -0
- package/packages/mailx-core/index.d.ts.map +1 -0
- package/packages/mailx-core/index.js.map +1 -0
- package/packages/mailx-core/index.ts +424 -0
- package/packages/mailx-core/ipc.d.ts.map +1 -0
- package/packages/mailx-core/ipc.js.map +1 -0
- package/packages/mailx-core/ipc.ts +62 -0
- package/packages/mailx-core/tsconfig.json +9 -0
- package/packages/mailx-host/.gitattributes +10 -0
- package/packages/mailx-host/index.d.ts.map +1 -0
- package/packages/mailx-host/index.js.map +1 -0
- package/packages/mailx-host/index.ts +38 -0
- package/packages/mailx-host/package.json +10 -2
- package/packages/mailx-host/tsconfig.json +9 -0
- package/packages/mailx-send/.gitattributes +10 -0
- package/packages/mailx-send/cli-queue.d.ts.map +1 -0
- package/packages/mailx-send/cli-queue.js.map +1 -0
- package/packages/mailx-send/cli-queue.ts +62 -0
- package/packages/mailx-send/cli-send.d.ts.map +1 -0
- package/packages/mailx-send/cli-send.js.map +1 -0
- package/packages/mailx-send/cli-send.ts +83 -0
- package/packages/mailx-send/cli.d.ts.map +1 -0
- package/packages/mailx-send/cli.js.map +1 -0
- package/packages/mailx-send/cli.ts +126 -0
- package/packages/mailx-send/index.d.ts.map +1 -0
- package/packages/mailx-send/index.js.map +1 -0
- package/packages/mailx-send/index.ts +333 -0
- package/packages/mailx-send/mailsend/cli.d.ts.map +1 -0
- package/packages/mailx-send/mailsend/cli.js.map +1 -0
- package/packages/mailx-send/mailsend/cli.ts +81 -0
- package/packages/mailx-send/mailsend/index.d.ts.map +1 -0
- package/packages/mailx-send/mailsend/index.js.map +1 -0
- package/packages/mailx-send/mailsend/index.ts +333 -0
- package/packages/mailx-send/mailsend/package-lock.json +65 -0
- package/packages/mailx-send/mailsend/tsconfig.json +21 -0
- package/packages/mailx-send/package-lock.json +65 -0
- package/packages/mailx-send/package.json +1 -1
- package/packages/mailx-send/tsconfig.json +21 -0
- package/packages/mailx-server/.gitattributes +10 -0
- package/packages/mailx-server/index.d.ts.map +1 -0
- package/packages/mailx-server/index.js.map +1 -0
- package/packages/mailx-server/index.ts +429 -0
- package/packages/mailx-server/tsconfig.json +9 -0
- package/packages/mailx-service/google-sync.d.ts.map +1 -0
- package/packages/mailx-service/google-sync.js.map +1 -0
- package/packages/mailx-service/google-sync.ts +238 -0
- package/packages/mailx-service/index.d.ts.map +1 -0
- package/packages/mailx-service/index.js.map +1 -0
- package/packages/mailx-service/index.ts +2461 -0
- package/packages/mailx-service/jsonrpc.d.ts.map +1 -0
- package/packages/mailx-service/jsonrpc.js.map +1 -0
- package/packages/mailx-service/jsonrpc.ts +268 -0
- package/packages/mailx-service/tsconfig.json +9 -0
- package/packages/mailx-settings/.gitattributes +10 -0
- package/packages/mailx-settings/cloud.d.ts.map +1 -0
- package/packages/mailx-settings/cloud.js.map +1 -0
- package/packages/mailx-settings/cloud.ts +388 -0
- package/packages/mailx-settings/index.d.ts.map +1 -0
- package/packages/mailx-settings/index.js.map +1 -0
- package/packages/mailx-settings/index.ts +892 -0
- package/packages/mailx-settings/tsconfig.json +9 -0
- package/packages/mailx-store/.gitattributes +10 -0
- package/packages/mailx-store/db.d.ts.map +1 -0
- package/packages/mailx-store/db.js.map +1 -0
- package/packages/mailx-store/db.ts +2007 -0
- package/packages/mailx-store/file-store.d.ts.map +1 -0
- package/packages/mailx-store/file-store.js.map +1 -0
- package/packages/mailx-store/file-store.ts +82 -0
- package/packages/mailx-store/index.d.ts.map +1 -0
- package/packages/mailx-store/index.js.map +1 -0
- package/packages/mailx-store/index.ts +7 -0
- package/packages/mailx-store/tsconfig.json +9 -0
- package/packages/mailx-store-web/android-bootstrap.d.ts.map +1 -0
- package/packages/mailx-store-web/android-bootstrap.js.map +1 -0
- package/packages/mailx-store-web/android-bootstrap.ts +1262 -0
- package/packages/mailx-store-web/db.d.ts.map +1 -0
- package/packages/mailx-store-web/db.js.map +1 -0
- package/packages/mailx-store-web/db.ts +756 -0
- package/packages/mailx-store-web/gmail-api-web.d.ts.map +1 -0
- package/packages/mailx-store-web/gmail-api-web.js.map +1 -0
- package/packages/mailx-store-web/gmail-api-web.ts +11 -0
- package/packages/mailx-store-web/imap-web-provider.d.ts.map +1 -0
- package/packages/mailx-store-web/imap-web-provider.js.map +1 -0
- package/packages/mailx-store-web/imap-web-provider.ts +156 -0
- package/packages/mailx-store-web/index.d.ts.map +1 -0
- package/packages/mailx-store-web/index.js.map +1 -0
- package/packages/mailx-store-web/index.ts +10 -0
- package/packages/mailx-store-web/main-thread-host.d.ts.map +1 -0
- package/packages/mailx-store-web/main-thread-host.js.map +1 -0
- package/packages/mailx-store-web/main-thread-host.ts +322 -0
- package/packages/mailx-store-web/package.json +4 -4
- package/packages/mailx-store-web/provider-types.d.ts.map +1 -0
- package/packages/mailx-store-web/provider-types.js.map +1 -0
- package/packages/mailx-store-web/provider-types.ts +7 -0
- package/packages/mailx-store-web/sync-manager.d.ts.map +1 -0
- package/packages/mailx-store-web/sync-manager.js.map +1 -0
- package/packages/mailx-store-web/sync-manager.ts +508 -0
- package/packages/mailx-store-web/tsconfig.json +10 -0
- package/packages/mailx-store-web/web-jsonrpc.d.ts.map +1 -0
- package/packages/mailx-store-web/web-jsonrpc.js.map +1 -0
- package/packages/mailx-store-web/web-jsonrpc.ts +116 -0
- package/packages/mailx-store-web/web-message-store.d.ts.map +1 -0
- package/packages/mailx-store-web/web-message-store.js.map +1 -0
- package/packages/mailx-store-web/web-message-store.ts +97 -0
- package/packages/mailx-store-web/web-service.d.ts.map +1 -0
- package/packages/mailx-store-web/web-service.js.map +1 -0
- package/packages/mailx-store-web/web-service.ts +616 -0
- package/packages/mailx-store-web/web-settings.d.ts.map +1 -0
- package/packages/mailx-store-web/web-settings.js.map +1 -0
- package/packages/mailx-store-web/web-settings.ts +522 -0
- package/packages/mailx-store-web/worker-entry.d.ts.map +1 -0
- package/packages/mailx-store-web/worker-entry.js.map +1 -0
- package/packages/mailx-store-web/worker-entry.ts +215 -0
- package/packages/mailx-store-web/worker-tcp-transport.d.ts.map +1 -0
- package/packages/mailx-store-web/worker-tcp-transport.js.map +1 -0
- package/packages/mailx-store-web/worker-tcp-transport.ts +101 -0
- package/packages/mailx-types/.gitattributes +10 -0
- package/packages/mailx-types/index.d.ts.map +1 -0
- package/packages/mailx-types/index.js.map +1 -0
- package/packages/mailx-types/index.ts +498 -0
- package/packages/mailx-types/tsconfig.json +9 -0
- package/tsconfig.base.json +2 -1
- package/tsconfig.json +9 -0
- package/build-apk.cmd +0 -3
- package/npmg.bat +0 -6
- package/packages/mailx-imap/index.d.ts +0 -442
- package/packages/mailx-imap/index.js +0 -3684
- package/packages/mailx-imap/package.json +0 -25
- package/packages/mailx-imap/providers/gmail-api.d.ts +0 -8
- package/packages/mailx-imap/providers/gmail-api.js +0 -8
- package/packages/mailx-imap/providers/types.d.ts +0 -9
- package/packages/mailx-imap/providers/types.js +0 -9
- package/packages/mailx-imap/tsconfig.tsbuildinfo +0 -1
- package/rebuild.cmd +0 -23
- package/tdview.cmd +0 -2
- package/temp.ps1 +0 -10
- package/test-smtp-direct.mjs +0 -4
- package/unbash.cmd +0 -55
- 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
|
+
}
|