@bobfrankston/mailx-settings 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/cloud.d.ts +47 -0
  2. package/cloud.js +403 -0
  3. package/index.d.ts +86 -10
  4. package/index.js +671 -38
  5. package/package.json +8 -2
package/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * allowlist.jsonc — remote content allow-list
9
9
  *
10
10
  * Local overrides (~/.mailx/):
11
- * config.json — pointer to shared dir + local-only settings (storePath, historyDays)
11
+ * config.jsonc — pointer to shared dir + local-only settings (storePath, historyDays)
12
12
  * accounts.jsonc — cached copy, fallback when shared unavailable
13
13
  * preferences.jsonc — local overrides merged on top of shared
14
14
  * allowlist.jsonc — cached copy
@@ -18,38 +18,229 @@
18
18
  import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
20
  import { parse as parseJsonc } from "jsonc-parser";
21
+ import { getCloudProvider, gDriveFindOrCreateFolder } from "./cloud.js";
21
22
  // ── Paths ──
22
23
  const LOCAL_DIR = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".mailx");
23
- const LOCAL_CONFIG_PATH = path.join(LOCAL_DIR, "config.json");
24
+ const LOCAL_CONFIG_PATH = path.join(LOCAL_DIR, "config.jsonc");
25
+ const LEGACY_CONFIG_PATH = path.join(LOCAL_DIR, "config.json");
24
26
  const DEFAULT_STORE_PATH = path.join(LOCAL_DIR, "mailxstore");
27
+ /** Resolve a path from config — relative to ~/.mailx/, ~ expands to home */
28
+ function resolvePath(p) {
29
+ if (!p)
30
+ return p;
31
+ const home = process.env.USERPROFILE || process.env.HOME || ".";
32
+ // Expand ~ to home directory
33
+ if (p.startsWith("~/") || p.startsWith("~\\"))
34
+ return path.join(home, p.slice(2));
35
+ if (p === "~")
36
+ return home;
37
+ // Absolute path — use as-is
38
+ if (path.isAbsolute(p))
39
+ return p;
40
+ // Relative — resolve from config directory (~/.mailx/)
41
+ return path.resolve(LOCAL_DIR, p);
42
+ }
25
43
  function readLocalConfig() {
44
+ // Migrate config.json → config.jsonc
45
+ if (!fs.existsSync(LOCAL_CONFIG_PATH) && fs.existsSync(LEGACY_CONFIG_PATH)) {
46
+ fs.renameSync(LEGACY_CONFIG_PATH, LOCAL_CONFIG_PATH);
47
+ }
26
48
  if (!fs.existsSync(LOCAL_CONFIG_PATH))
27
49
  return {};
28
- try {
29
- return JSON.parse(fs.readFileSync(LOCAL_CONFIG_PATH, "utf-8"));
50
+ return readJsonc(LOCAL_CONFIG_PATH) || {};
51
+ }
52
+ /** Resolve provider config to a filesystem path (checks for local Google Drive mount) */
53
+ function resolveProvider(cfg) {
54
+ // Cloud providers use API only — no filesystem mount scanning
55
+ if (cfg.provider === "gdrive" || cfg.provider === "google")
56
+ return undefined;
57
+ if (cfg.provider === "local")
58
+ return resolvePath(cfg.path);
59
+ return undefined;
60
+ }
61
+ /** Pending cloud config for API fallback (set when mount not found) */
62
+ let pendingCloudConfig = null;
63
+ /** Last cloud API error (for UI display) */
64
+ let lastCloudError = null;
65
+ const cloudErrorListeners = [];
66
+ export function onCloudError(cb) {
67
+ cloudErrorListeners.push(cb);
68
+ return () => {
69
+ const i = cloudErrorListeners.indexOf(cb);
70
+ if (i >= 0)
71
+ cloudErrorListeners.splice(i, 1);
72
+ };
73
+ }
74
+ function setCloudError(error, context) {
75
+ lastCloudError = error;
76
+ for (const cb of cloudErrorListeners) {
77
+ try {
78
+ cb(error, context);
79
+ }
80
+ catch { /* listener faults shouldn't break writes */ }
30
81
  }
31
- catch {
32
- return {};
82
+ }
83
+ export function getLastCloudError() { return lastCloudError; }
84
+ function resolveSharedEntry(entry) {
85
+ if (typeof entry === "string") {
86
+ const p = resolvePath(entry);
87
+ return fs.existsSync(p) ? p : undefined;
33
88
  }
89
+ return resolveProvider(entry);
34
90
  }
35
91
  function getSharedDir() {
36
92
  const config = readLocalConfig();
37
- if (config.sharedDir)
38
- return config.sharedDir;
39
- // Legacy: derive from settingsPath
40
- if (config.settingsPath)
41
- return path.dirname(config.settingsPath);
93
+ if (config.sharedDir) {
94
+ const entries = Array.isArray(config.sharedDir) ? config.sharedDir : [config.sharedDir];
95
+ for (const entry of entries) {
96
+ const resolved = resolveSharedEntry(entry);
97
+ if (resolved)
98
+ return resolved;
99
+ }
100
+ // Set pending cloud config for API access
101
+ if (!pendingCloudConfig) {
102
+ const lastProvider = [...entries].reverse().find(e => typeof e !== "string");
103
+ if (lastProvider)
104
+ pendingCloudConfig = lastProvider;
105
+ }
106
+ }
107
+ // Legacy settingsPath no longer used for shared dir — use loadLegacySettings() for reading only.
42
108
  return LOCAL_DIR;
43
109
  }
110
+ /** Read a file via cloud API (when filesystem mount not available) */
111
+ export async function cloudRead(filename) {
112
+ if (!pendingCloudConfig)
113
+ return null;
114
+ // Ensure we have a folder ID
115
+ if (!pendingCloudConfig.folderId) {
116
+ pendingCloudConfig.folderId = await gDriveFindOrCreateFolder() || undefined;
117
+ if (pendingCloudConfig.folderId)
118
+ saveFolderIdToConfig(pendingCloudConfig.folderId);
119
+ if (!pendingCloudConfig.folderId) {
120
+ setCloudError(`Cannot read ${filename}: Google Drive folder unavailable (OAuth not granted?)`, { op: "read", filename });
121
+ return null;
122
+ }
123
+ }
124
+ const provider = getCloudProvider(pendingCloudConfig.provider, pendingCloudConfig.folderId);
125
+ if (!provider) {
126
+ setCloudError(`No cloud provider for ${pendingCloudConfig.provider}`, { op: "read", filename });
127
+ return null;
128
+ }
129
+ console.log(` [cloud] Reading ${filename} via ${pendingCloudConfig.provider} API...`);
130
+ const content = await provider.read(filename);
131
+ if (content) {
132
+ setCloudError(null);
133
+ // Cache locally
134
+ try {
135
+ fs.mkdirSync(LOCAL_DIR, { recursive: true });
136
+ fs.writeFileSync(path.join(LOCAL_DIR, filename), content);
137
+ }
138
+ catch { /* ignore cache write failure */ }
139
+ }
140
+ // Don't set error for missing files — they may not exist yet (e.g., clients.jsonc on first run)
141
+ return content;
142
+ }
143
+ /** Write a file via cloud API. Throws on failure with a descriptive error,
144
+ * and updates lastCloudError so UI banners pick it up via getStorageInfo()
145
+ * and the onCloudError listener. */
146
+ export async function cloudWrite(filename, content) {
147
+ if (!pendingCloudConfig) {
148
+ const err = `Cloud not initialized yet — cannot save ${filename} (pendingCloudConfig is null; loadSettings may not have run, or config.jsonc has no sharedDir)`;
149
+ console.error(` [cloud] cloudWrite: ${err}`);
150
+ setCloudError(err, { op: "write", filename });
151
+ throw new Error(err);
152
+ }
153
+ // Ensure we have a folder ID
154
+ if (!pendingCloudConfig.folderId) {
155
+ pendingCloudConfig.folderId = await gDriveFindOrCreateFolder() || undefined;
156
+ if (pendingCloudConfig.folderId)
157
+ saveFolderIdToConfig(pendingCloudConfig.folderId);
158
+ if (!pendingCloudConfig.folderId) {
159
+ const err = `Cannot save ${filename}: Google Drive folder unavailable (OAuth not granted or token expired)`;
160
+ setCloudError(err, { op: "write", filename });
161
+ throw new Error(err);
162
+ }
163
+ }
164
+ const provider = getCloudProvider(pendingCloudConfig.provider, pendingCloudConfig.folderId);
165
+ if (!provider) {
166
+ const err = `No cloud provider for ${pendingCloudConfig.provider}`;
167
+ setCloudError(err, { op: "write", filename });
168
+ throw new Error(err);
169
+ }
170
+ try {
171
+ await provider.write(filename, content);
172
+ setCloudError(null);
173
+ }
174
+ catch (e) {
175
+ setCloudError(e.message, { op: "write", filename });
176
+ throw e;
177
+ }
178
+ }
179
+ /** Persist the discovered folder ID back to config.jsonc so we don't search again */
180
+ function saveFolderIdToConfig(folderId) {
181
+ try {
182
+ const config = readLocalConfig();
183
+ if (config.sharedDir && typeof config.sharedDir === "object" && !Array.isArray(config.sharedDir)) {
184
+ config.sharedDir.folderId = folderId;
185
+ atomicWrite(LOCAL_CONFIG_PATH, config);
186
+ }
187
+ }
188
+ catch { /* non-critical */ }
189
+ }
190
+ /** Whether cloud API fallback is active */
191
+ export function isCloudMode() {
192
+ return pendingCloudConfig !== null;
193
+ }
194
+ /** Get storage provider info for display */
195
+ export function getStorageInfo() {
196
+ const config = readLocalConfig();
197
+ if (config.sharedDir) {
198
+ const entries = Array.isArray(config.sharedDir) ? config.sharedDir : [config.sharedDir];
199
+ for (const entry of entries) {
200
+ if (typeof entry === "string") {
201
+ const resolved = resolveSharedEntry(entry);
202
+ if (resolved && resolved !== LOCAL_DIR) {
203
+ return { provider: "local", mode: "local", cloudPath: resolved };
204
+ }
205
+ continue;
206
+ }
207
+ // Provider-based entry — check for API mode (folderId) first, then mount
208
+ const name = (entry.provider === "gdrive" || entry.provider === "google") ? "gdrive" : entry.provider;
209
+ if (entry.folderId) {
210
+ // Has folder ID → API mode (don't scan filesystem for mounts)
211
+ return { provider: name, mode: "api", cloudPath: entry.path, cloudError: lastCloudError || undefined };
212
+ }
213
+ const resolved = resolveSharedEntry(entry);
214
+ if (resolved && resolved !== LOCAL_DIR) {
215
+ return { provider: name, mode: "mount", cloudPath: entry.path };
216
+ }
217
+ }
218
+ // Not mounted and no folderId — check pendingCloudConfig from initCloudConfig()
219
+ if (pendingCloudConfig) {
220
+ const name = (pendingCloudConfig.provider === "gdrive" || pendingCloudConfig.provider === "google") ? "gdrive" : pendingCloudConfig.provider;
221
+ return { provider: name, mode: "api", cloudPath: pendingCloudConfig.path, cloudError: lastCloudError || undefined };
222
+ }
223
+ }
224
+ return { provider: "local", mode: "local" };
225
+ }
44
226
  // ── File helpers ──
227
+ /** Read JSON or JSONC file. If exact path not found, tries .json/.jsonc variant. */
45
228
  function readJsonc(filePath) {
46
- if (!fs.existsSync(filePath))
47
- return null;
229
+ let actual = filePath;
230
+ if (!fs.existsSync(actual)) {
231
+ // Try alternate extension
232
+ if (actual.endsWith(".jsonc"))
233
+ actual = actual.replace(/\.jsonc$/, ".json");
234
+ else if (actual.endsWith(".json"))
235
+ actual = actual.replace(/\.json$/, ".jsonc");
236
+ if (!fs.existsSync(actual))
237
+ return null;
238
+ }
48
239
  try {
49
- return parseJsonc(fs.readFileSync(filePath, "utf-8"));
240
+ return parseJsonc(fs.readFileSync(actual, "utf-8").replace(/\r/g, ""));
50
241
  }
51
242
  catch (e) {
52
- console.error(`Failed to read ${filePath}: ${e.message}`);
243
+ console.error(`Failed to read ${actual}: ${e.message}`);
53
244
  return null;
54
245
  }
55
246
  }
@@ -84,7 +275,12 @@ function loadFile(filename, defaults) {
84
275
  }
85
276
  return { ...defaults, ...data };
86
277
  }
87
- /** Save a config file to the shared directory */
278
+ /** Save a config file to the shared directory (and cloud API if active).
279
+ * Always writes the local copy first so the data is never lost.
280
+ * If a cloud provider is configured, also writes there — failures set
281
+ * lastCloudError and notify onCloudError listeners so the UI can show
282
+ * a banner. The cloud write is fire-and-forget at this layer; callers
283
+ * who need the result should use the typed save* helpers below. */
88
284
  function saveFile(filename, data) {
89
285
  const sharedDir = getSharedDir();
90
286
  atomicWrite(path.join(sharedDir, filename), data);
@@ -95,12 +291,122 @@ function saveFile(filename, data) {
95
291
  }
96
292
  catch { /* ignore */ }
97
293
  }
294
+ // Write to cloud API if filesystem mount unavailable (creates app-owned file for drive.file scope)
295
+ if (pendingCloudConfig) {
296
+ cloudWrite(filename, JSON.stringify(data, null, 2))
297
+ .then(() => console.log(` [cloud] Saved ${filename} via ${pendingCloudConfig.provider} API`))
298
+ .catch(e => console.error(` [cloud] Failed to save ${filename}: ${e.message}`));
299
+ // Note: we don't await — saveFile is sync. cloudWrite() already calls
300
+ // setCloudError(), which fires onCloudError listeners synchronously
301
+ // when the promise rejects, so the UI gets the failure even though
302
+ // we don't propagate it back through the call chain here.
303
+ }
304
+ }
305
+ const PROVIDERS = {
306
+ "gmail.com": {
307
+ label: "Gmail",
308
+ imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
309
+ smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
310
+ },
311
+ "googlemail.com": {
312
+ label: "Gmail",
313
+ imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
314
+ smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
315
+ },
316
+ "outlook.com": {
317
+ label: "Outlook",
318
+ imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
319
+ smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
320
+ },
321
+ "hotmail.com": {
322
+ label: "Hotmail",
323
+ imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
324
+ smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
325
+ },
326
+ "yahoo.com": {
327
+ label: "Yahoo",
328
+ imap: { host: "imap.mail.yahoo.com", port: 993, tls: true, auth: "password" },
329
+ smtp: { host: "smtp.mail.yahoo.com", port: 587, tls: true, auth: "password" },
330
+ },
331
+ "aol.com": {
332
+ label: "AOL",
333
+ imap: { host: "imap.aol.com", port: 993, tls: true, auth: "password" },
334
+ smtp: { host: "smtp.aol.com", port: 587, tls: true, auth: "password" },
335
+ },
336
+ "icloud.com": {
337
+ label: "iCloud",
338
+ imap: { host: "imap.mail.me.com", port: 993, tls: true, auth: "password" },
339
+ smtp: { host: "smtp.mail.me.com", port: 587, tls: true, auth: "password" },
340
+ },
341
+ };
342
+ /** Fill in provider defaults for an account based on email domain.
343
+ * Exported so mailx-service's leanAccountsJsonc helper can reuse the same
344
+ * canonicalization rules that loadAccounts uses. */
345
+ export function normalizeAccount(acct, globalName) {
346
+ const email = acct.email || "";
347
+ const localPart = email.split("@")[0]?.toLowerCase() || "";
348
+ const domain = email.split("@")[1]?.toLowerCase() || "";
349
+ const provider = PROVIDERS[domain];
350
+ const user = acct.imap?.user || acct.user || email;
351
+ // P14: auto-derive id and label so a known-provider account works with just
352
+ // { email, password? } in accounts.jsonc. id defaults to local-part (most
353
+ // accounts have a unique local-part); label defaults to provider name or id.
354
+ // Generic local-parts (info, admin, support, no-reply, ...) fall back to
355
+ // the domain stem to avoid collisions across accounts.
356
+ const GENERIC_LOCALS = new Set(["info", "admin", "support", "no-reply", "noreply", "contact", "hello", "mail", "office"]);
357
+ const domainStem = domain.split(".")[0] || "";
358
+ const autoId = (localPart && !GENERIC_LOCALS.has(localPart)) ? localPart : (domainStem || "account");
359
+ return {
360
+ id: acct.id || autoId,
361
+ name: acct.name || globalName || localPart,
362
+ label: acct.label || provider?.label || acct.id || autoId,
363
+ email,
364
+ imap: {
365
+ host: acct.imap?.host || provider?.imap.host || `imap.${domain}`,
366
+ port: acct.imap?.port || provider?.imap.port || 993,
367
+ tls: acct.imap?.tls ?? provider?.imap.tls ?? true,
368
+ auth: acct.imap?.auth || provider?.imap.auth || "password",
369
+ user: acct.imap?.user || user,
370
+ password: acct.imap?.password || acct.password,
371
+ },
372
+ smtp: {
373
+ host: acct.smtp?.host || provider?.smtp.host || `smtp.${domain}`,
374
+ port: acct.smtp?.port || provider?.smtp.port || 587,
375
+ tls: acct.smtp?.tls ?? provider?.smtp.tls ?? true,
376
+ auth: acct.smtp?.auth || provider?.smtp.auth || "password",
377
+ user: acct.smtp?.user || user,
378
+ password: acct.smtp?.password || acct.password,
379
+ },
380
+ enabled: acct.enabled ?? true,
381
+ primary: acct.primary,
382
+ primaryCalendar: acct.primaryCalendar,
383
+ primaryTasks: acct.primaryTasks,
384
+ primaryContacts: acct.primaryContacts,
385
+ defaultSend: acct.defaultSend,
386
+ syncContacts: acct.syncContacts ?? (provider?.imap.auth === "oauth2"),
387
+ relayDomains: acct.relayDomains,
388
+ deliveredToPrefix: acct.deliveredToPrefix,
389
+ identityDomains: acct.identityDomains,
390
+ // `spam` passthrough retired 2026-04-22 — markAsSpamMessages now finds
391
+ // the junk folder via `specialUse === "junk"` on the DB folder record
392
+ // (populated by mailx-imap from iflow's getSpecialFolders()). Authoritative
393
+ // per-server info beats per-domain guesses.
394
+ // `signature` is on AccountConfig in mailx-types but the workspace
395
+ // build order sometimes leaves a stale .d.ts for type-check; using
396
+ // `as any` is the minimum-blast-radius way to add the field without
397
+ // blocking the build. Once mailx-types has rebuilt once (post-field)
398
+ // this cast can be removed.
399
+ ...(acct.signature ? { signature: acct.signature } : {}),
400
+ ...(acct.sig && typeof acct.sig === "object" && typeof acct.sig.text === "string"
401
+ ? { sig: { text: acct.sig.text, html: !!acct.sig.html } } : {}),
402
+ };
98
403
  }
99
404
  // ── Defaults ──
100
405
  const DEFAULT_ACCOUNTS = [];
101
406
  const DEFAULT_PREFERENCES = {
102
407
  ui: {
103
- theme: "dark",
408
+ theme: "system",
409
+ editor: "quill",
104
410
  folderWidth: 220,
105
411
  listViewerSplit: 40,
106
412
  fontSize: 15,
@@ -108,35 +414,279 @@ const DEFAULT_PREFERENCES = {
108
414
  sync: {
109
415
  intervalMinutes: 5,
110
416
  historyDays: 30,
417
+ prefetch: true,
418
+ },
419
+ autocomplete: {
420
+ enabled: false,
421
+ provider: "ollama",
422
+ ollamaUrl: "http://localhost:11434",
423
+ ollamaModel: "qwen2.5-coder:1.5b",
424
+ cloudApiKey: "",
425
+ cloudModel: "claude-sonnet-4-20250514",
426
+ debounceMs: 600,
427
+ maxTokens: 60,
111
428
  },
112
429
  };
430
+ const DEFAULT_AUTOCOMPLETE = {
431
+ enabled: false,
432
+ provider: "ollama",
433
+ ollamaUrl: "http://localhost:11434",
434
+ ollamaModel: "qwen2.5-coder:1.5b",
435
+ cloudApiKey: "",
436
+ cloudModel: "claude-sonnet-4-20250514",
437
+ debounceMs: 600,
438
+ maxTokens: 60,
439
+ };
113
440
  const DEFAULT_ALLOWLIST = {
114
441
  senders: [],
115
442
  domains: [],
116
443
  recipients: [],
444
+ // Flagged senders/domains — surfaced as a red warning on the
445
+ // remote-content banner. Distinct from the positive lists above:
446
+ // these don't auto-allow anything, they make the banner louder when
447
+ // a known-suspicious correspondent's mail shows up. Future: a shared
448
+ // GitHub list users can opt in to (community-flagged phishing /
449
+ // tracker-heavy senders) — see TODO.md.
450
+ flaggedSenders: [],
451
+ flaggedDomains: [],
117
452
  };
118
453
  // ── Public API ──
119
454
  /** Load account configs */
120
455
  export function loadAccounts() {
121
- // Try new split file first
122
- const accounts = readJsonc(path.join(getSharedDir(), "accounts.jsonc"));
123
- if (accounts?.accounts)
124
- return accounts.accounts;
125
- if (Array.isArray(accounts))
126
- return accounts;
456
+ const sharedDir = getSharedDir();
457
+ const sharedPath = path.join(sharedDir, "accounts.jsonc");
458
+ const localPath = path.join(LOCAL_DIR, "accounts.jsonc");
459
+ // Try shared first, then local cache
460
+ let accounts = readJsonc(sharedPath);
461
+ if (!accounts)
462
+ accounts = readJsonc(localPath);
463
+ if (accounts?.accounts || Array.isArray(accounts)) {
464
+ // Cache shared to local for offline fallback — but ONLY if the
465
+ // content actually differs. Unconditionally writing on every load
466
+ // retriggers fs.watch on the local copy, which fires the config-
467
+ // changed banner and cloud-poll cycle even when nothing changed.
468
+ // Result: "accounts.jsonc changed" notification firing constantly.
469
+ if (sharedDir !== LOCAL_DIR && fs.existsSync(sharedPath)) {
470
+ try {
471
+ const sharedContent = fs.readFileSync(sharedPath, "utf-8");
472
+ let localContent = "";
473
+ try {
474
+ localContent = fs.readFileSync(localPath, "utf-8");
475
+ }
476
+ catch { /* missing */ }
477
+ // Normalize before comparing — GDrive-mounted copies often
478
+ // differ in BOM / line endings / trailing newline without any
479
+ // semantic change, and that triggered the spurious banner.
480
+ const norm = (s) => s.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/[ \t\r\n]+$/, "");
481
+ if (norm(sharedContent) !== norm(localContent)) {
482
+ fs.mkdirSync(LOCAL_DIR, { recursive: true });
483
+ fs.writeFileSync(localPath, sharedContent);
484
+ }
485
+ }
486
+ catch { /* ignore */ }
487
+ }
488
+ const raw = accounts.accounts || accounts;
489
+ const globalName = accounts.name || "";
490
+ const result = deduplicateAccounts(raw.map((a) => normalizeAccount(a, globalName)));
491
+ return applyAccountOverrides(result);
492
+ }
127
493
  // Legacy: read from settings.jsonc
128
494
  const legacy = loadLegacySettings();
129
495
  if (legacy?.accounts)
130
- return legacy.accounts;
496
+ return applyAccountOverrides(legacy.accounts.map((a) => normalizeAccount(a, legacy.name)));
131
497
  return DEFAULT_ACCOUNTS;
132
498
  }
499
+ /** Normalize email for dedup — Gmail ignores dots before @ */
500
+ function normalizeEmail(email) {
501
+ const [local, domain] = email.toLowerCase().split("@");
502
+ if (!domain)
503
+ return email.toLowerCase();
504
+ if (domain === "gmail.com" || domain === "googlemail.com") {
505
+ return local.replace(/\./g, "") + "@gmail.com";
506
+ }
507
+ return `${local}@${domain}`;
508
+ }
509
+ /** Remove duplicate accounts (same email after normalization) */
510
+ function deduplicateAccounts(accounts) {
511
+ const seen = new Set();
512
+ return accounts.filter(a => {
513
+ const key = normalizeEmail(a.email);
514
+ if (seen.has(key))
515
+ return false;
516
+ seen.add(key);
517
+ return true;
518
+ });
519
+ }
520
+ /** Apply local per-account overrides (enabled, etc.) */
521
+ function applyAccountOverrides(accounts) {
522
+ const localConfig = readLocalConfig();
523
+ const overrides = localConfig.accountOverrides;
524
+ if (!overrides)
525
+ return accounts;
526
+ for (const acct of accounts) {
527
+ const ov = overrides[acct.id];
528
+ if (!ov)
529
+ continue;
530
+ if (ov.enabled !== undefined)
531
+ acct.enabled = ov.enabled;
532
+ }
533
+ return accounts;
534
+ }
535
+ /** Load accounts with cloud API fallback (async — use when cloud settings may not be mounted) */
536
+ export async function loadAccountsAsync() {
537
+ // Try sync first (filesystem)
538
+ const accounts = loadAccounts();
539
+ if (accounts.length > 0)
540
+ return accounts;
541
+ // Try cloud API fallback
542
+ if (pendingCloudConfig) {
543
+ console.log(" [cloud] Trying cloud API for accounts...");
544
+ const content = await cloudRead("accounts.jsonc");
545
+ if (content) {
546
+ const data = parseJsonc(content);
547
+ if (data?.accounts || Array.isArray(data)) {
548
+ const raw = data.accounts || data;
549
+ const globalName = data.name || "";
550
+ return applyAccountOverrides(raw.map((a) => normalizeAccount(a, globalName)));
551
+ }
552
+ }
553
+ // Legacy settings.jsonc is no longer read — use accounts.jsonc only
554
+ }
555
+ return [];
556
+ }
557
+ /** Strip default-valued fields from a normalized AccountConfig so the
558
+ * serialized JSONC stays compact and human-editable. The previous version
559
+ * was round-tripping every defaulted field (port: 993, tls: true, auth:
560
+ * "password", enabled: true, etc.), which bloated a 10-line accounts.jsonc
561
+ * to 60+ lines and embedded unnecessary "knowledge" about defaults.
562
+ *
563
+ * Rules:
564
+ * - Drop fields that match the provider default (host/port/tls/auth derived
565
+ * from email domain via PROVIDERS).
566
+ * - Drop `enabled: true` (default).
567
+ * - Drop `name` if equal to the file-level `globalName`.
568
+ * - Drop `imap.user` / `smtp.user` if they equal the email.
569
+ * - Drop `sig.html: false` (default; only keep when user enabled HTML sigs).
570
+ * - Keep field order: id → label → email → primary* → imap → smtp → defaultSend
571
+ * → relayDomains → deliveredToPrefix → identityDomains → syncContacts → sig.
572
+ * Curated for readability, not alphabetic. */
573
+ export function denormalizeAccount(acct, globalName) {
574
+ const domain = (acct.email || "").split("@")[1]?.toLowerCase() || "";
575
+ const provider = PROVIDERS[domain];
576
+ const out = {};
577
+ out.id = acct.id;
578
+ if (acct.label && acct.label !== provider?.label && acct.label !== acct.id)
579
+ out.label = acct.label;
580
+ out.email = acct.email;
581
+ if (acct.name && acct.name !== globalName)
582
+ out.name = acct.name;
583
+ if (acct.primary)
584
+ out.primary = true;
585
+ if (acct.primaryCalendar !== undefined)
586
+ out.primaryCalendar = acct.primaryCalendar;
587
+ if (acct.primaryTasks !== undefined)
588
+ out.primaryTasks = acct.primaryTasks;
589
+ if (acct.primaryContacts !== undefined)
590
+ out.primaryContacts = acct.primaryContacts;
591
+ // imap — keep only fields that differ from provider defaults.
592
+ const imapOut = {};
593
+ if (acct.imap?.host && acct.imap.host !== provider?.imap.host)
594
+ imapOut.host = acct.imap.host;
595
+ if (acct.imap?.user && acct.imap.user !== acct.email)
596
+ imapOut.user = acct.imap.user;
597
+ if (acct.imap?.password)
598
+ imapOut.password = acct.imap.password;
599
+ if (acct.imap?.port && acct.imap.port !== (provider?.imap.port ?? 993))
600
+ imapOut.port = acct.imap.port;
601
+ if (acct.imap?.tls !== undefined && acct.imap.tls !== (provider?.imap.tls ?? true))
602
+ imapOut.tls = acct.imap.tls;
603
+ if (acct.imap?.auth && acct.imap.auth !== (provider?.imap.auth ?? "password"))
604
+ imapOut.auth = acct.imap.auth;
605
+ if (Object.keys(imapOut).length > 0)
606
+ out.imap = imapOut;
607
+ // smtp — same treatment.
608
+ const smtpOut = {};
609
+ if (acct.smtp?.host && acct.smtp.host !== provider?.smtp.host)
610
+ smtpOut.host = acct.smtp.host;
611
+ if (acct.smtp?.user && acct.smtp.user !== acct.email && acct.smtp.user !== acct.imap?.user)
612
+ smtpOut.user = acct.smtp.user;
613
+ if (acct.smtp?.password)
614
+ smtpOut.password = acct.smtp.password;
615
+ if (acct.smtp?.port && acct.smtp.port !== (provider?.smtp.port ?? 587))
616
+ smtpOut.port = acct.smtp.port;
617
+ if (acct.smtp?.tls !== undefined && acct.smtp.tls !== (provider?.smtp.tls ?? true))
618
+ smtpOut.tls = acct.smtp.tls;
619
+ if (acct.smtp?.auth && acct.smtp.auth !== (provider?.smtp.auth ?? "password"))
620
+ smtpOut.auth = acct.smtp.auth;
621
+ if (Object.keys(smtpOut).length > 0)
622
+ out.smtp = smtpOut;
623
+ if (acct.defaultSend)
624
+ out.defaultSend = true;
625
+ if (acct.enabled === false)
626
+ out.enabled = false; // default true → omit
627
+ if (acct.relayDomains && acct.relayDomains.length > 0)
628
+ out.relayDomains = acct.relayDomains;
629
+ if (acct.deliveredToPrefix && acct.deliveredToPrefix.length > 0)
630
+ out.deliveredToPrefix = acct.deliveredToPrefix;
631
+ if (acct.identityDomains && acct.identityDomains.length > 0)
632
+ out.identityDomains = acct.identityDomains;
633
+ // syncContacts default: true for OAuth, false otherwise. Only emit when
634
+ // the user overrode the default.
635
+ const syncContactsDefault = provider?.imap.auth === "oauth2";
636
+ if (acct.syncContacts !== undefined && acct.syncContacts !== syncContactsDefault) {
637
+ out.syncContacts = acct.syncContacts;
638
+ }
639
+ if (acct.signature)
640
+ out.signature = acct.signature;
641
+ if (acct.sig?.text) {
642
+ // html: false is default; only keep the html flag when explicitly true.
643
+ out.sig = acct.sig.html ? { text: acct.sig.text, html: true } : { text: acct.sig.text };
644
+ }
645
+ return out;
646
+ }
133
647
  /** Save account configs */
134
- export function saveAccounts(accounts) {
135
- saveFile("accounts.jsonc", { accounts });
648
+ /** Save accounts — merges with cloud copy by email (multi-client safe).
649
+ * Writes the lean form via denormalizeAccount so accounts.jsonc stays
650
+ * compact and human-editable. */
651
+ export async function saveAccounts(accounts) {
652
+ // Merge with cloud: keep all accounts, deduplicate by normalized email
653
+ try {
654
+ const cloudContent = await cloudRead("accounts.jsonc");
655
+ if (cloudContent) {
656
+ const cloud = parseJsonc(cloudContent);
657
+ const cloudAccts = cloud?.accounts || (Array.isArray(cloud) ? cloud : []);
658
+ if (cloudAccts.length > 0) {
659
+ const seen = new Set(accounts.map(a => normalizeEmail(a.email)));
660
+ for (const ca of cloudAccts) {
661
+ if (ca.email && !seen.has(normalizeEmail(ca.email))) {
662
+ // Cloud entries are already lean — feed through
663
+ // normalizeAccount to coerce to AccountConfig shape.
664
+ accounts.push(normalizeAccount(ca));
665
+ seen.add(normalizeEmail(ca.email));
666
+ }
667
+ }
668
+ }
669
+ }
670
+ }
671
+ catch { /* cloud read failed — save local version */ }
672
+ // Promote a shared "name" to file level when every account has the same
673
+ // name — keeps the JSONC tidy ({ "name": "Bob Frankston", "accounts": [...] }
674
+ // instead of repeating "name" on each entry).
675
+ const names = new Set(accounts.map(a => a.name).filter(Boolean));
676
+ const globalName = names.size === 1 ? [...names][0] : undefined;
677
+ const lean = accounts.map(a => denormalizeAccount(a, globalName));
678
+ const payload = globalName ? { name: globalName, accounts: lean } : { accounts: lean };
679
+ saveFile("accounts.jsonc", payload);
136
680
  }
137
- /** Load preferences (shared + local overrides) */
681
+ /** Load preferences (shared + local overrides, with legacy fallback) */
138
682
  export function loadPreferences() {
139
- const shared = loadFile("preferences.jsonc", DEFAULT_PREFERENCES);
683
+ let shared = loadFile("preferences.jsonc", DEFAULT_PREFERENCES);
684
+ // Legacy fallback: read ui/sync from settings.jsonc if preferences.jsonc had only defaults
685
+ const legacy = loadLegacySettings();
686
+ if (legacy?.ui)
687
+ shared = { ...shared, ui: { ...shared.ui, ...legacy.ui } };
688
+ if (legacy?.sync)
689
+ shared = { ...shared, sync: { ...shared.sync, ...legacy.sync } };
140
690
  const localConfig = readLocalConfig();
141
691
  // Local overrides
142
692
  if (localConfig.historyDays !== undefined) {
@@ -145,25 +695,62 @@ export function loadPreferences() {
145
695
  return {
146
696
  ui: { ...DEFAULT_PREFERENCES.ui, ...shared.ui },
147
697
  sync: { ...DEFAULT_PREFERENCES.sync, ...shared.sync },
698
+ autocomplete: { ...DEFAULT_AUTOCOMPLETE, ...shared.autocomplete },
148
699
  };
149
700
  }
150
701
  /** Save preferences */
151
702
  export function savePreferences(prefs) {
152
703
  saveFile("preferences.jsonc", prefs);
153
704
  }
705
+ /** Load autocomplete settings */
706
+ export function loadAutocomplete() {
707
+ const prefs = loadPreferences();
708
+ return prefs.autocomplete;
709
+ }
710
+ /** Save autocomplete settings */
711
+ export function saveAutocomplete(settings) {
712
+ const prefs = loadPreferences();
713
+ prefs.autocomplete = settings;
714
+ savePreferences(prefs);
715
+ }
154
716
  /** Load remote content allow-list */
155
717
  export function loadAllowlist() {
156
718
  return loadFile("allowlist.jsonc", DEFAULT_ALLOWLIST);
157
719
  }
158
- /** Save allow-list */
159
- export function saveAllowlist(list) {
160
- saveFile("allowlist.jsonc", list);
720
+ /** Save allow-list — merges with existing cloud copy (multi-client safe) */
721
+ export async function saveAllowlist(list) {
722
+ // Read current cloud version and merge (other clients may have added entries)
723
+ let merged = { ...list };
724
+ try {
725
+ const cloudContent = await cloudRead("allowlist.jsonc");
726
+ if (cloudContent) {
727
+ const cloud = parseJsonc(cloudContent);
728
+ if (cloud) {
729
+ const mergeArrays = (local, remote) => [...new Set([...local, ...remote])];
730
+ merged = {
731
+ senders: mergeArrays(list.senders || [], cloud.senders || []),
732
+ domains: mergeArrays(list.domains || [], cloud.domains || []),
733
+ recipients: mergeArrays(list.recipients || [], cloud.recipients || []),
734
+ flaggedSenders: mergeArrays(list.flaggedSenders || [], cloud.flaggedSenders || []),
735
+ flaggedDomains: mergeArrays(list.flaggedDomains || [], cloud.flaggedDomains || []),
736
+ };
737
+ }
738
+ }
739
+ }
740
+ catch { /* cloud read failed — save local version */ }
741
+ saveFile("allowlist.jsonc", merged);
161
742
  }
162
743
  // ── Legacy compatibility ──
163
744
  function loadLegacySettings() {
164
745
  const config = readLocalConfig();
165
- const settingsPath = config.settingsPath || path.join(LOCAL_DIR, "settings.jsonc");
166
- return readJsonc(settingsPath);
746
+ if (config.settingsPath)
747
+ return readJsonc(resolvePath(config.settingsPath));
748
+ // Try shared dir first, then local
749
+ const sharedDir = getSharedDir();
750
+ const shared = readJsonc(path.join(sharedDir, "settings.jsonc"));
751
+ if (shared)
752
+ return shared;
753
+ return readJsonc(path.join(LOCAL_DIR, "settings.jsonc"));
167
754
  }
168
755
  /** Load settings — unified view combining all files (backward compatible) */
169
756
  export function loadSettings() {
@@ -174,6 +761,7 @@ export function loadSettings() {
174
761
  accounts,
175
762
  ui: prefs.ui,
176
763
  sync: prefs.sync,
764
+ autocomplete: prefs.autocomplete,
177
765
  store: {
178
766
  basePath: localConfig.storePath || DEFAULT_STORE_PATH,
179
767
  compressionBoundaryDays: 365,
@@ -181,14 +769,14 @@ export function loadSettings() {
181
769
  };
182
770
  }
183
771
  /** Save settings — writes to split files */
184
- export function saveSettings(settings) {
185
- saveAccounts(settings.accounts);
772
+ export async function saveSettings(settings) {
773
+ await saveAccounts(settings.accounts);
186
774
  savePreferences({ ui: settings.ui, sync: settings.sync });
187
775
  }
188
776
  /** Get the local store base path */
189
777
  export function getStorePath() {
190
778
  const config = readLocalConfig();
191
- return config.storePath || DEFAULT_STORE_PATH;
779
+ return config.storePath ? resolvePath(config.storePath) : DEFAULT_STORE_PATH;
192
780
  }
193
781
  /** Get the local data directory (DB, store, etc.) */
194
782
  export function getConfigDir() {
@@ -196,23 +784,68 @@ export function getConfigDir() {
196
784
  }
197
785
  /** Get the shared settings directory */
198
786
  export { getSharedDir };
787
+ // detectSharedDir() removed — cloud storage is configured via API (gdrive/onedrive),
788
+ // not auto-detected from filesystem mounts. Setup form triggers initCloudConfig().
199
789
  /** Initialize local config if it doesn't exist */
200
790
  export function initLocalConfig(sharedDir, storePath) {
201
791
  if (fs.existsSync(LOCAL_CONFIG_PATH) && !sharedDir && !storePath)
202
792
  return;
203
793
  const existing = readLocalConfig();
794
+ // Use explicit sharedDir or preserve existing — no auto-detection.
795
+ // Cloud storage is configured when user adds an account (initCloudConfig).
796
+ const resolvedSharedDir = sharedDir || existing.sharedDir;
204
797
  const config = {
205
798
  ...existing,
206
- sharedDir: sharedDir || existing.sharedDir || existing.settingsPath ? path.dirname(existing.settingsPath) : undefined,
799
+ sharedDir: resolvedSharedDir,
207
800
  storePath: storePath || existing.storePath || DEFAULT_STORE_PATH,
208
801
  };
802
+ fs.mkdirSync(LOCAL_DIR, { recursive: true });
803
+ atomicWrite(LOCAL_CONFIG_PATH, config);
804
+ }
805
+ /** Initialize config with Google Drive cloud storage.
806
+ * Finds or creates the app-owned "mailx" folder via Drive API and stores its ID.
807
+ * No mount scanning — API only. Existing settings at other paths (e.g., home/.mailx
808
+ * from Desktop sync) must be migrated manually or via config.jsonc importPath. */
809
+ export async function initCloudConfig(provider = "gdrive") {
810
+ const existing = readLocalConfig();
811
+ if (existing.sharedDir)
812
+ return; // Already configured
813
+ // Find or create the settings folder via Drive API — tries "home/.mailx"
814
+ // first (family convention), then "mailx" (default). The found path gets
815
+ // saved so future lookups don't re-scan.
816
+ const folderId = await gDriveFindOrCreateFolder();
817
+ // Detect which path was actually found by reading back from the API
818
+ // (gDriveFindOrCreateFolder logs it). For now use "mailx" as default
819
+ // label — the folderId is what matters for subsequent reads/writes.
820
+ const sharedDir = { provider, path: "home/.mailx", folderId: folderId || undefined };
821
+ const config = { ...existing, sharedDir, storePath: existing.storePath || DEFAULT_STORE_PATH };
822
+ fs.mkdirSync(LOCAL_DIR, { recursive: true });
209
823
  atomicWrite(LOCAL_CONFIG_PATH, config);
824
+ pendingCloudConfig = sharedDir;
825
+ console.log(` Initialized cloud config: ${provider} (folder ID: ${folderId || "pending"})`);
210
826
  }
211
827
  const DEFAULT_SETTINGS = {
212
828
  accounts: [],
213
829
  ui: DEFAULT_PREFERENCES.ui,
214
830
  sync: DEFAULT_PREFERENCES.sync,
831
+ autocomplete: DEFAULT_AUTOCOMPLETE,
215
832
  store: { basePath: DEFAULT_STORE_PATH, compressionBoundaryDays: 365 },
216
833
  };
217
- export { DEFAULT_SETTINGS, DEFAULT_ALLOWLIST, DEFAULT_PREFERENCES, LOCAL_DIR };
834
+ /** Get historyDays for an account: per-account override > system override > shared default */
835
+ export function getHistoryDays(accountId) {
836
+ const localConfig = readLocalConfig();
837
+ if (accountId && localConfig.accountOverrides?.[accountId]?.historyDays !== undefined) {
838
+ return localConfig.accountOverrides[accountId].historyDays;
839
+ }
840
+ if (localConfig.historyDays !== undefined)
841
+ return localConfig.historyDays;
842
+ const prefs = loadPreferences();
843
+ return prefs.sync.historyDays || 0;
844
+ }
845
+ /** Get prefetch setting: download bodies during sync (default true) */
846
+ export function getPrefetch() {
847
+ const prefs = loadPreferences();
848
+ return prefs.sync.prefetch !== false;
849
+ }
850
+ export { DEFAULT_SETTINGS, DEFAULT_ALLOWLIST, DEFAULT_PREFERENCES, DEFAULT_AUTOCOMPLETE, LOCAL_DIR };
218
851
  //# sourceMappingURL=index.js.map