@bobfrankston/mailx-settings 0.1.4 → 0.1.7

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/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,379 @@
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, gDriveValidateCachedFolder } from "./cloud.js";
22
+ const __dirname = import.meta.dirname;
21
23
  // ── Paths ──
22
- const LOCAL_DIR = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".mailx");
23
- const LOCAL_CONFIG_PATH = path.join(LOCAL_DIR, "config.json");
24
+ // REMOVE 2026-06-15 one-shot migration of legacy ~/.mailx → ~/.rmfmail.
25
+ // Five cases handled (idempotent — running twice is safe):
26
+ // 1. only ~/.mailx: rename to ~/.rmfmail (atomic single move).
27
+ // 2. ~/.mailx contains ONLY a `mailxstore/` subdir while ~/.rmfmail is
28
+ // already the active config — move the body store across, rewrite
29
+ // body_path rows in the DB, then remove the empty ~/.mailx. This is
30
+ // the hybrid-state user (their config jumped to .rmfmail but the
31
+ // stored body files lived under .mailx because storePath was an
32
+ // absolute path).
33
+ // 3. both exist with other content in ~/.mailx: rename to ~/.mailx-old
34
+ // so it's visibly cruft.
35
+ // 4. only ~/.rmfmail (or neither): do nothing.
36
+ // 5 covers config.jsonc with absolute storePath pointing at ~/.mailx —
37
+ // rewritten to a relative "mailxstore" so the path follows the config dir.
38
+ {
39
+ const home = process.env.USERPROFILE || process.env.HOME || ".";
40
+ const oldDir = path.join(home, ".mailx");
41
+ const newDir = path.join(home, ".rmfmail");
42
+ const asideDir = path.join(home, ".mailx-old");
43
+ if (!fs.existsSync(newDir) && fs.existsSync(oldDir)) {
44
+ fs.renameSync(oldDir, newDir);
45
+ console.log("[migrate] ~/.mailx → ~/.rmfmail");
46
+ }
47
+ else if (fs.existsSync(newDir) && fs.existsSync(oldDir)) {
48
+ // Hybrid: figure out whether `.mailx/` is just a leftover store or
49
+ // has meaningful content.
50
+ let entries = [];
51
+ try {
52
+ entries = fs.readdirSync(oldDir);
53
+ }
54
+ catch { /* */ }
55
+ const onlyStore = entries.length === 1 && entries[0] === "mailxstore";
56
+ if (onlyStore) {
57
+ const oldStore = path.join(oldDir, "mailxstore");
58
+ const newStore = path.join(newDir, "mailxstore");
59
+ try {
60
+ fs.mkdirSync(newStore, { recursive: true });
61
+ // Recursive move (rename per top-level entry; rename across
62
+ // dirs on the same volume is atomic, fall back to copy+unlink
63
+ // if it fails — e.g. when the volumes differ).
64
+ const moveRecursive = (src, dst) => {
65
+ const stat = fs.statSync(src);
66
+ if (stat.isDirectory()) {
67
+ fs.mkdirSync(dst, { recursive: true });
68
+ for (const child of fs.readdirSync(src))
69
+ moveRecursive(path.join(src, child), path.join(dst, child));
70
+ try {
71
+ fs.rmdirSync(src);
72
+ }
73
+ catch { /* */ }
74
+ }
75
+ else {
76
+ try {
77
+ fs.renameSync(src, dst);
78
+ }
79
+ catch {
80
+ fs.copyFileSync(src, dst);
81
+ fs.unlinkSync(src);
82
+ }
83
+ }
84
+ };
85
+ for (const child of fs.readdirSync(oldStore)) {
86
+ moveRecursive(path.join(oldStore, child), path.join(newStore, child));
87
+ }
88
+ try {
89
+ fs.rmdirSync(oldStore);
90
+ }
91
+ catch { /* */ }
92
+ try {
93
+ fs.rmdirSync(oldDir);
94
+ }
95
+ catch { /* may still have files; let next case handle */ }
96
+ console.log("[migrate] moved ~/.mailx/mailxstore/* → ~/.rmfmail/mailxstore/");
97
+ }
98
+ catch (e) {
99
+ console.warn(`[migrate] mailxstore move failed: ${e.message}`);
100
+ }
101
+ }
102
+ else if (!fs.existsSync(asideDir)) {
103
+ fs.renameSync(oldDir, asideDir);
104
+ console.log("[migrate] ~/.mailx → ~/.mailx-old (safe to delete)");
105
+ }
106
+ }
107
+ // Body-path rewrite: any DB rows still pointing at the old absolute
108
+ // store path get translated to the new one. Fire-and-forget — the DB
109
+ // module exposes a method called from the daemon's startup; this
110
+ // module-level migration just touches config.jsonc.
111
+ const localCfg = path.join(newDir, "config.jsonc");
112
+ if (fs.existsSync(localCfg)) {
113
+ try {
114
+ const raw = fs.readFileSync(localCfg, "utf-8");
115
+ const stripped = raw.replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1");
116
+ const cfg = JSON.parse(stripped);
117
+ // Drop storePath entirely when it points at the legacy dir or
118
+ // is the default — config.jsonc shouldn't pin paths that follow
119
+ // from where LOCAL_DIR is. Users who actually need a custom
120
+ // store location keep that override; everyone else gets the
121
+ // default (~/.rmfmail/mailxstore) without ceremony.
122
+ if (typeof cfg.storePath === "string") {
123
+ const p = cfg.storePath;
124
+ const isLegacyAbs = /[\\/]\.mailx[\\/]mailxstore$/i.test(p);
125
+ const isDefaultRel = p === "mailxstore";
126
+ const isDefaultAbs = /[\\/]\.rmfmail[\\/]mailxstore$/i.test(p);
127
+ if (isLegacyAbs || isDefaultRel || isDefaultAbs) {
128
+ delete cfg.storePath;
129
+ fs.writeFileSync(localCfg, JSON.stringify(cfg, null, 2));
130
+ console.log("[migrate] config.jsonc storePath dropped (using default ~/.rmfmail/mailxstore)");
131
+ }
132
+ }
133
+ // Same for sharedDir.path label (cosmetic — folderId is what matters).
134
+ if (cfg.sharedDir && typeof cfg.sharedDir === "object" && cfg.sharedDir.path === "home/.mailx") {
135
+ cfg.sharedDir.path = "home/.rmfmail";
136
+ fs.writeFileSync(localCfg, JSON.stringify(cfg, null, 2));
137
+ console.log("[migrate] config.jsonc sharedDir.path → home/.rmfmail");
138
+ }
139
+ }
140
+ catch (e) {
141
+ console.warn(`[migrate] config.jsonc rewrite failed: ${e.message}`);
142
+ }
143
+ }
144
+ }
145
+ const LOCAL_DIR = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".rmfmail");
146
+ const LOCAL_CONFIG_PATH = path.join(LOCAL_DIR, "config.jsonc");
147
+ const LEGACY_CONFIG_PATH = path.join(LOCAL_DIR, "config.json");
24
148
  const DEFAULT_STORE_PATH = path.join(LOCAL_DIR, "mailxstore");
149
+ /** Resolve a path from config — relative to ~/.mailx/, ~ expands to home */
150
+ function resolvePath(p) {
151
+ if (!p)
152
+ return p;
153
+ const home = process.env.USERPROFILE || process.env.HOME || ".";
154
+ // Expand ~ to home directory
155
+ if (p.startsWith("~/") || p.startsWith("~\\"))
156
+ return path.join(home, p.slice(2));
157
+ if (p === "~")
158
+ return home;
159
+ // Absolute path — use as-is
160
+ if (path.isAbsolute(p))
161
+ return p;
162
+ // Relative — resolve from config directory (~/.mailx/)
163
+ return path.resolve(LOCAL_DIR, p);
164
+ }
25
165
  function readLocalConfig() {
166
+ // Migrate config.json → config.jsonc
167
+ if (!fs.existsSync(LOCAL_CONFIG_PATH) && fs.existsSync(LEGACY_CONFIG_PATH)) {
168
+ fs.renameSync(LEGACY_CONFIG_PATH, LOCAL_CONFIG_PATH);
169
+ }
26
170
  if (!fs.existsSync(LOCAL_CONFIG_PATH))
27
171
  return {};
28
- try {
29
- return JSON.parse(fs.readFileSync(LOCAL_CONFIG_PATH, "utf-8"));
172
+ return readJsonc(LOCAL_CONFIG_PATH) || {};
173
+ }
174
+ /** Resolve provider config to a filesystem path (checks for local Google Drive mount) */
175
+ function resolveProvider(cfg) {
176
+ // Cloud providers use API only — no filesystem mount scanning
177
+ if (cfg.provider === "gdrive" || cfg.provider === "google")
178
+ return undefined;
179
+ if (cfg.provider === "local")
180
+ return resolvePath(cfg.path);
181
+ return undefined;
182
+ }
183
+ /** Pending cloud config for API fallback (set when mount not found) */
184
+ let pendingCloudConfig = null;
185
+ /** Last cloud API error (for UI display) */
186
+ let lastCloudError = null;
187
+ const cloudErrorListeners = [];
188
+ export function onCloudError(cb) {
189
+ cloudErrorListeners.push(cb);
190
+ return () => {
191
+ const i = cloudErrorListeners.indexOf(cb);
192
+ if (i >= 0)
193
+ cloudErrorListeners.splice(i, 1);
194
+ };
195
+ }
196
+ function setCloudError(error, context) {
197
+ lastCloudError = error;
198
+ for (const cb of cloudErrorListeners) {
199
+ try {
200
+ cb(error, context);
201
+ }
202
+ catch { /* listener faults shouldn't break writes */ }
30
203
  }
31
- catch {
32
- return {};
204
+ }
205
+ export function getLastCloudError() { return lastCloudError; }
206
+ function resolveSharedEntry(entry) {
207
+ if (typeof entry === "string") {
208
+ const p = resolvePath(entry);
209
+ return fs.existsSync(p) ? p : undefined;
33
210
  }
211
+ return resolveProvider(entry);
34
212
  }
35
213
  function getSharedDir() {
36
214
  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);
215
+ if (config.sharedDir) {
216
+ const entries = Array.isArray(config.sharedDir) ? config.sharedDir : [config.sharedDir];
217
+ for (const entry of entries) {
218
+ const resolved = resolveSharedEntry(entry);
219
+ if (resolved)
220
+ return resolved;
221
+ }
222
+ // Set pending cloud config for API access
223
+ if (!pendingCloudConfig) {
224
+ const lastProvider = [...entries].reverse().find(e => typeof e !== "string");
225
+ if (lastProvider)
226
+ pendingCloudConfig = lastProvider;
227
+ }
228
+ }
229
+ // Legacy settingsPath no longer used for shared dir — use loadLegacySettings() for reading only.
42
230
  return LOCAL_DIR;
43
231
  }
232
+ /** Once-per-process validation flag — verify the cached folderId points at
233
+ * an `.rmfmail` folder before trusting it. Catches the cross-version-cache
234
+ * corruption case where 1.0.504 cached a wrong ID and never re-discovered. */
235
+ let folderIdValidated = false;
236
+ /** Read a file via cloud API (when filesystem mount not available) */
237
+ export async function cloudRead(filename) {
238
+ if (!pendingCloudConfig)
239
+ return null;
240
+ // Validate cached folderId once per process. If it points somewhere
241
+ // other than an `.rmfmail` folder (e.g. an orphan from a buggy version),
242
+ // drop it and re-discover via gDriveFindOrCreateFolder.
243
+ if (pendingCloudConfig.folderId && !folderIdValidated) {
244
+ folderIdValidated = true;
245
+ const ok = await gDriveValidateCachedFolder(pendingCloudConfig.folderId);
246
+ if (!ok) {
247
+ pendingCloudConfig.folderId = undefined;
248
+ clearFolderIdInConfig();
249
+ }
250
+ }
251
+ // Ensure we have a folder ID
252
+ if (!pendingCloudConfig.folderId) {
253
+ pendingCloudConfig.folderId = await gDriveFindOrCreateFolder() || undefined;
254
+ if (pendingCloudConfig.folderId)
255
+ saveFolderIdToConfig(pendingCloudConfig.folderId);
256
+ if (!pendingCloudConfig.folderId) {
257
+ setCloudError(`Cannot read ${filename}: Google Drive folder unavailable (OAuth not granted?)`, { op: "read", filename });
258
+ return null;
259
+ }
260
+ }
261
+ const provider = getCloudProvider(pendingCloudConfig.provider, pendingCloudConfig.folderId);
262
+ if (!provider) {
263
+ setCloudError(`No cloud provider for ${pendingCloudConfig.provider}`, { op: "read", filename });
264
+ return null;
265
+ }
266
+ console.log(` [cloud] Reading ${filename} via ${pendingCloudConfig.provider} API...`);
267
+ const content = await provider.read(filename);
268
+ if (content) {
269
+ setCloudError(null);
270
+ // Cache locally
271
+ try {
272
+ fs.mkdirSync(LOCAL_DIR, { recursive: true });
273
+ fs.writeFileSync(path.join(LOCAL_DIR, filename), content);
274
+ }
275
+ catch { /* ignore cache write failure */ }
276
+ }
277
+ // Don't set error for missing files — they may not exist yet (e.g., clients.jsonc on first run)
278
+ return content;
279
+ }
280
+ /** Write a file via cloud API. Throws on failure with a descriptive error,
281
+ * and updates lastCloudError so UI banners pick it up via getStorageInfo()
282
+ * and the onCloudError listener. */
283
+ export async function cloudWrite(filename, content) {
284
+ if (!pendingCloudConfig) {
285
+ const err = `Cloud not initialized yet — cannot save ${filename} (pendingCloudConfig is null; loadSettings may not have run, or config.jsonc has no sharedDir)`;
286
+ console.error(` [cloud] cloudWrite: ${err}`);
287
+ setCloudError(err, { op: "write", filename });
288
+ throw new Error(err);
289
+ }
290
+ // Ensure we have a folder ID
291
+ if (!pendingCloudConfig.folderId) {
292
+ pendingCloudConfig.folderId = await gDriveFindOrCreateFolder() || undefined;
293
+ if (pendingCloudConfig.folderId)
294
+ saveFolderIdToConfig(pendingCloudConfig.folderId);
295
+ if (!pendingCloudConfig.folderId) {
296
+ const err = `Cannot save ${filename}: Google Drive folder unavailable (OAuth not granted or token expired)`;
297
+ setCloudError(err, { op: "write", filename });
298
+ throw new Error(err);
299
+ }
300
+ }
301
+ const provider = getCloudProvider(pendingCloudConfig.provider, pendingCloudConfig.folderId);
302
+ if (!provider) {
303
+ const err = `No cloud provider for ${pendingCloudConfig.provider}`;
304
+ setCloudError(err, { op: "write", filename });
305
+ throw new Error(err);
306
+ }
307
+ try {
308
+ await provider.write(filename, content);
309
+ setCloudError(null);
310
+ }
311
+ catch (e) {
312
+ setCloudError(e.message, { op: "write", filename });
313
+ throw e;
314
+ }
315
+ }
316
+ /** Persist the discovered folder ID back to config.jsonc so we don't search again */
317
+ function saveFolderIdToConfig(folderId) {
318
+ try {
319
+ const config = readLocalConfig();
320
+ if (config.sharedDir && typeof config.sharedDir === "object" && !Array.isArray(config.sharedDir)) {
321
+ config.sharedDir.folderId = folderId;
322
+ atomicWrite(LOCAL_CONFIG_PATH, config);
323
+ }
324
+ }
325
+ catch { /* non-critical */ }
326
+ }
327
+ /** Drop a stale folder ID from config.jsonc so the next cloudRead/cloudWrite
328
+ * re-discovers via gDriveFindOrCreateFolder. */
329
+ function clearFolderIdInConfig() {
330
+ try {
331
+ const config = readLocalConfig();
332
+ if (config.sharedDir && typeof config.sharedDir === "object" && !Array.isArray(config.sharedDir)) {
333
+ delete config.sharedDir.folderId;
334
+ atomicWrite(LOCAL_CONFIG_PATH, config);
335
+ }
336
+ }
337
+ catch { /* non-critical */ }
338
+ }
339
+ /** Whether cloud API fallback is active */
340
+ export function isCloudMode() {
341
+ return pendingCloudConfig !== null;
342
+ }
343
+ /** Get storage provider info for display */
344
+ export function getStorageInfo() {
345
+ const configDir = LOCAL_DIR;
346
+ const config = readLocalConfig();
347
+ if (config.sharedDir) {
348
+ const entries = Array.isArray(config.sharedDir) ? config.sharedDir : [config.sharedDir];
349
+ for (const entry of entries) {
350
+ if (typeof entry === "string") {
351
+ const resolved = resolveSharedEntry(entry);
352
+ if (resolved && resolved !== LOCAL_DIR) {
353
+ return { provider: "local", mode: "local", cloudPath: resolved, configDir };
354
+ }
355
+ continue;
356
+ }
357
+ // Provider-based entry — check for API mode (folderId) first, then mount
358
+ const name = (entry.provider === "gdrive" || entry.provider === "google") ? "gdrive" : entry.provider;
359
+ if (entry.folderId) {
360
+ // Has folder ID → API mode (don't scan filesystem for mounts)
361
+ return { provider: name, mode: "api", cloudPath: entry.path, folderName: entry.path, folderId: entry.folderId, configDir, cloudError: lastCloudError || undefined };
362
+ }
363
+ const resolved = resolveSharedEntry(entry);
364
+ if (resolved && resolved !== LOCAL_DIR) {
365
+ return { provider: name, mode: "mount", cloudPath: entry.path, folderName: entry.path, configDir };
366
+ }
367
+ }
368
+ // Not mounted and no folderId — check pendingCloudConfig from initCloudConfig()
369
+ if (pendingCloudConfig) {
370
+ const name = (pendingCloudConfig.provider === "gdrive" || pendingCloudConfig.provider === "google") ? "gdrive" : pendingCloudConfig.provider;
371
+ return { provider: name, mode: "api", cloudPath: pendingCloudConfig.path, folderName: pendingCloudConfig.path, configDir, cloudError: lastCloudError || undefined };
372
+ }
373
+ }
374
+ return { provider: "local", mode: "local", configDir };
375
+ }
44
376
  // ── File helpers ──
377
+ /** Read JSON or JSONC file. If exact path not found, tries .json/.jsonc variant. */
45
378
  function readJsonc(filePath) {
46
- if (!fs.existsSync(filePath))
47
- return null;
379
+ let actual = filePath;
380
+ if (!fs.existsSync(actual)) {
381
+ // Try alternate extension
382
+ if (actual.endsWith(".jsonc"))
383
+ actual = actual.replace(/\.jsonc$/, ".json");
384
+ else if (actual.endsWith(".json"))
385
+ actual = actual.replace(/\.json$/, ".jsonc");
386
+ if (!fs.existsSync(actual))
387
+ return null;
388
+ }
48
389
  try {
49
- return parseJsonc(fs.readFileSync(filePath, "utf-8"));
390
+ return parseJsonc(fs.readFileSync(actual, "utf-8").replace(/\r/g, ""));
50
391
  }
51
392
  catch (e) {
52
- console.error(`Failed to read ${filePath}: ${e.message}`);
393
+ console.error(`Failed to read ${actual}: ${e.message}`);
53
394
  return null;
54
395
  }
55
396
  }
@@ -84,7 +425,12 @@ function loadFile(filename, defaults) {
84
425
  }
85
426
  return { ...defaults, ...data };
86
427
  }
87
- /** Save a config file to the shared directory */
428
+ /** Save a config file to the shared directory (and cloud API if active).
429
+ * Always writes the local copy first so the data is never lost.
430
+ * If a cloud provider is configured, also writes there — failures set
431
+ * lastCloudError and notify onCloudError listeners so the UI can show
432
+ * a banner. The cloud write is fire-and-forget at this layer; callers
433
+ * who need the result should use the typed save* helpers below. */
88
434
  function saveFile(filename, data) {
89
435
  const sharedDir = getSharedDir();
90
436
  atomicWrite(path.join(sharedDir, filename), data);
@@ -95,12 +441,122 @@ function saveFile(filename, data) {
95
441
  }
96
442
  catch { /* ignore */ }
97
443
  }
444
+ // Write to cloud API if filesystem mount unavailable (creates app-owned file for drive.file scope)
445
+ if (pendingCloudConfig) {
446
+ cloudWrite(filename, JSON.stringify(data, null, 2))
447
+ .then(() => console.log(` [cloud] Saved ${filename} via ${pendingCloudConfig.provider} API`))
448
+ .catch(e => console.error(` [cloud] Failed to save ${filename}: ${e.message}`));
449
+ // Note: we don't await — saveFile is sync. cloudWrite() already calls
450
+ // setCloudError(), which fires onCloudError listeners synchronously
451
+ // when the promise rejects, so the UI gets the failure even though
452
+ // we don't propagate it back through the call chain here.
453
+ }
454
+ }
455
+ const PROVIDERS = {
456
+ "gmail.com": {
457
+ label: "Gmail",
458
+ imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
459
+ smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
460
+ },
461
+ "googlemail.com": {
462
+ label: "Gmail",
463
+ imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
464
+ smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
465
+ },
466
+ "outlook.com": {
467
+ label: "Outlook",
468
+ imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
469
+ smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
470
+ },
471
+ "hotmail.com": {
472
+ label: "Hotmail",
473
+ imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
474
+ smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
475
+ },
476
+ "yahoo.com": {
477
+ label: "Yahoo",
478
+ imap: { host: "imap.mail.yahoo.com", port: 993, tls: true, auth: "password" },
479
+ smtp: { host: "smtp.mail.yahoo.com", port: 587, tls: true, auth: "password" },
480
+ },
481
+ "aol.com": {
482
+ label: "AOL",
483
+ imap: { host: "imap.aol.com", port: 993, tls: true, auth: "password" },
484
+ smtp: { host: "smtp.aol.com", port: 587, tls: true, auth: "password" },
485
+ },
486
+ "icloud.com": {
487
+ label: "iCloud",
488
+ imap: { host: "imap.mail.me.com", port: 993, tls: true, auth: "password" },
489
+ smtp: { host: "smtp.mail.me.com", port: 587, tls: true, auth: "password" },
490
+ },
491
+ };
492
+ /** Fill in provider defaults for an account based on email domain.
493
+ * Exported so mailx-service's leanAccountsJsonc helper can reuse the same
494
+ * canonicalization rules that loadAccounts uses. */
495
+ export function normalizeAccount(acct, globalName) {
496
+ const email = acct.email || "";
497
+ const localPart = email.split("@")[0]?.toLowerCase() || "";
498
+ const domain = email.split("@")[1]?.toLowerCase() || "";
499
+ const provider = PROVIDERS[domain];
500
+ const user = acct.imap?.user || acct.user || email;
501
+ // P14: auto-derive id and label so a known-provider account works with just
502
+ // { email, password? } in accounts.jsonc. id defaults to local-part (most
503
+ // accounts have a unique local-part); label defaults to provider name or id.
504
+ // Generic local-parts (info, admin, support, no-reply, ...) fall back to
505
+ // the domain stem to avoid collisions across accounts.
506
+ const GENERIC_LOCALS = new Set(["info", "admin", "support", "no-reply", "noreply", "contact", "hello", "mail", "office"]);
507
+ const domainStem = domain.split(".")[0] || "";
508
+ const autoId = (localPart && !GENERIC_LOCALS.has(localPart)) ? localPart : (domainStem || "account");
509
+ return {
510
+ id: acct.id || autoId,
511
+ name: acct.name || globalName || localPart,
512
+ label: acct.label || provider?.label || acct.id || autoId,
513
+ email,
514
+ imap: {
515
+ host: acct.imap?.host || provider?.imap.host || `imap.${domain}`,
516
+ port: acct.imap?.port || provider?.imap.port || 993,
517
+ tls: acct.imap?.tls ?? provider?.imap.tls ?? true,
518
+ auth: acct.imap?.auth || provider?.imap.auth || "password",
519
+ user: acct.imap?.user || user,
520
+ password: acct.imap?.password || acct.password,
521
+ },
522
+ smtp: {
523
+ host: acct.smtp?.host || provider?.smtp.host || `smtp.${domain}`,
524
+ port: acct.smtp?.port || provider?.smtp.port || 587,
525
+ tls: acct.smtp?.tls ?? provider?.smtp.tls ?? true,
526
+ auth: acct.smtp?.auth || provider?.smtp.auth || "password",
527
+ user: acct.smtp?.user || user,
528
+ password: acct.smtp?.password || acct.password,
529
+ },
530
+ enabled: acct.enabled ?? true,
531
+ primary: acct.primary,
532
+ primaryCalendar: acct.primaryCalendar,
533
+ primaryTasks: acct.primaryTasks,
534
+ primaryContacts: acct.primaryContacts,
535
+ defaultSend: acct.defaultSend,
536
+ syncContacts: acct.syncContacts ?? (provider?.imap.auth === "oauth2"),
537
+ relayDomains: acct.relayDomains,
538
+ deliveredToPrefix: acct.deliveredToPrefix,
539
+ identityDomains: acct.identityDomains,
540
+ // `spam` passthrough retired 2026-04-22 — markAsSpamMessages now finds
541
+ // the junk folder via `specialUse === "junk"` on the DB folder record
542
+ // (populated by mailx-imap from iflow's getSpecialFolders()). Authoritative
543
+ // per-server info beats per-domain guesses.
544
+ // `signature` is on AccountConfig in mailx-types but the workspace
545
+ // build order sometimes leaves a stale .d.ts for type-check; using
546
+ // `as any` is the minimum-blast-radius way to add the field without
547
+ // blocking the build. Once mailx-types has rebuilt once (post-field)
548
+ // this cast can be removed.
549
+ ...(acct.signature ? { signature: acct.signature } : {}),
550
+ ...(acct.sig && typeof acct.sig === "object" && typeof acct.sig.text === "string"
551
+ ? { sig: { text: acct.sig.text, html: !!acct.sig.html } } : {}),
552
+ };
98
553
  }
99
554
  // ── Defaults ──
100
555
  const DEFAULT_ACCOUNTS = [];
101
556
  const DEFAULT_PREFERENCES = {
102
557
  ui: {
103
- theme: "dark",
558
+ theme: "system",
559
+ editor: "quill",
104
560
  folderWidth: 220,
105
561
  listViewerSplit: 40,
106
562
  fontSize: 15,
@@ -108,35 +564,282 @@ const DEFAULT_PREFERENCES = {
108
564
  sync: {
109
565
  intervalMinutes: 5,
110
566
  historyDays: 30,
567
+ prefetch: true,
568
+ },
569
+ autocomplete: {
570
+ enabled: false,
571
+ provider: "ollama",
572
+ ollamaUrl: "http://localhost:11434",
573
+ ollamaModel: "qwen2.5-coder:1.5b",
574
+ cloudApiKey: "",
575
+ cloudModel: "claude-sonnet-4-20250514",
576
+ debounceMs: 600,
577
+ maxTokens: 60,
111
578
  },
112
579
  };
580
+ const DEFAULT_AUTOCOMPLETE = {
581
+ enabled: false,
582
+ provider: "ollama",
583
+ ollamaUrl: "http://localhost:11434",
584
+ ollamaModel: "qwen2.5-coder:1.5b",
585
+ cloudApiKey: "",
586
+ cloudModel: "claude-sonnet-4-20250514",
587
+ debounceMs: 600,
588
+ maxTokens: 60,
589
+ };
113
590
  const DEFAULT_ALLOWLIST = {
114
591
  senders: [],
115
592
  domains: [],
116
593
  recipients: [],
594
+ // Flagged senders/domains — surfaced as a red warning on the
595
+ // remote-content banner. Distinct from the positive lists above:
596
+ // these don't auto-allow anything, they make the banner louder when
597
+ // a known-suspicious correspondent's mail shows up. Future: a shared
598
+ // GitHub list users can opt in to (community-flagged phishing /
599
+ // tracker-heavy senders) — see TODO.md.
600
+ flaggedSenders: [],
601
+ flaggedDomains: [],
117
602
  };
118
603
  // ── Public API ──
119
604
  /** Load account configs */
120
605
  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;
606
+ const sharedDir = getSharedDir();
607
+ const sharedPath = path.join(sharedDir, "accounts.jsonc");
608
+ const localPath = path.join(LOCAL_DIR, "accounts.jsonc");
609
+ // Try shared first, then local cache
610
+ let accounts = readJsonc(sharedPath);
611
+ if (!accounts)
612
+ accounts = readJsonc(localPath);
613
+ if (accounts?.accounts || Array.isArray(accounts)) {
614
+ // Cache shared to local for offline fallback — but ONLY if the
615
+ // content actually differs. Unconditionally writing on every load
616
+ // retriggers fs.watch on the local copy, which fires the config-
617
+ // changed banner and cloud-poll cycle even when nothing changed.
618
+ // Result: "accounts.jsonc changed" notification firing constantly.
619
+ if (sharedDir !== LOCAL_DIR && fs.existsSync(sharedPath)) {
620
+ try {
621
+ const sharedContent = fs.readFileSync(sharedPath, "utf-8");
622
+ let localContent = "";
623
+ try {
624
+ localContent = fs.readFileSync(localPath, "utf-8");
625
+ }
626
+ catch { /* missing */ }
627
+ // Normalize before comparing — GDrive-mounted copies often
628
+ // differ in BOM / line endings / trailing newline without any
629
+ // semantic change, and that triggered the spurious banner.
630
+ const norm = (s) => s.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/[ \t\r\n]+$/, "");
631
+ if (norm(sharedContent) !== norm(localContent)) {
632
+ fs.mkdirSync(LOCAL_DIR, { recursive: true });
633
+ fs.writeFileSync(localPath, sharedContent);
634
+ }
635
+ }
636
+ catch { /* ignore */ }
637
+ }
638
+ const raw = accounts.accounts || accounts;
639
+ const globalName = accounts.name || "";
640
+ const result = deduplicateAccounts(raw.map((a) => normalizeAccount(a, globalName)));
641
+ return applyAccountOverrides(result);
642
+ }
127
643
  // Legacy: read from settings.jsonc
128
644
  const legacy = loadLegacySettings();
129
645
  if (legacy?.accounts)
130
- return legacy.accounts;
646
+ return applyAccountOverrides(legacy.accounts.map((a) => normalizeAccount(a, legacy.name)));
131
647
  return DEFAULT_ACCOUNTS;
132
648
  }
649
+ /** Normalize email for dedup — Gmail ignores dots before @ */
650
+ function normalizeEmail(email) {
651
+ const [local, domain] = email.toLowerCase().split("@");
652
+ if (!domain)
653
+ return email.toLowerCase();
654
+ if (domain === "gmail.com" || domain === "googlemail.com") {
655
+ return local.replace(/\./g, "") + "@gmail.com";
656
+ }
657
+ return `${local}@${domain}`;
658
+ }
659
+ /** Remove duplicate accounts (same email after normalization) */
660
+ function deduplicateAccounts(accounts) {
661
+ const seen = new Set();
662
+ return accounts.filter(a => {
663
+ const key = normalizeEmail(a.email);
664
+ if (seen.has(key))
665
+ return false;
666
+ seen.add(key);
667
+ return true;
668
+ });
669
+ }
670
+ /** Apply local per-account overrides (enabled, etc.) */
671
+ function applyAccountOverrides(accounts) {
672
+ const localConfig = readLocalConfig();
673
+ const overrides = localConfig.accountOverrides;
674
+ if (!overrides)
675
+ return accounts;
676
+ for (const acct of accounts) {
677
+ const ov = overrides[acct.id];
678
+ if (!ov)
679
+ continue;
680
+ if (ov.enabled !== undefined)
681
+ acct.enabled = ov.enabled;
682
+ }
683
+ return accounts;
684
+ }
685
+ /** Load accounts, preferring the cloud copy when cloud is configured.
686
+ * The local file in `~/.rmfmail/` is an offline cache, NOT the source of
687
+ * truth — earlier versions returned the local copy whenever it was non-empty,
688
+ * which left the desktop reading a stale single-account file forever after
689
+ * the GDrive folder lookup got fixed. */
690
+ export async function loadAccountsAsync() {
691
+ // Make sure pendingCloudConfig is initialized (calling getSharedDir as a
692
+ // side effect — it sets pendingCloudConfig from the local config.jsonc).
693
+ getSharedDir();
694
+ // Cloud configured → cloud is canonical.
695
+ if (pendingCloudConfig) {
696
+ const content = await cloudRead("accounts.jsonc");
697
+ if (content) {
698
+ const data = parseJsonc(content);
699
+ if (data?.accounts || Array.isArray(data)) {
700
+ const raw = data.accounts || data;
701
+ const globalName = data.name || "";
702
+ // cloudRead has already cached content to LOCAL_DIR.
703
+ return applyAccountOverrides(raw.map((a) => normalizeAccount(a, globalName)));
704
+ }
705
+ }
706
+ // Cloud unreachable / unparseable — fall through to local cache.
707
+ }
708
+ return loadAccounts();
709
+ }
710
+ /** Strip default-valued fields from a normalized AccountConfig so the
711
+ * serialized JSONC stays compact and human-editable. The previous version
712
+ * was round-tripping every defaulted field (port: 993, tls: true, auth:
713
+ * "password", enabled: true, etc.), which bloated a 10-line accounts.jsonc
714
+ * to 60+ lines and embedded unnecessary "knowledge" about defaults.
715
+ *
716
+ * Rules:
717
+ * - Drop fields that match the provider default (host/port/tls/auth derived
718
+ * from email domain via PROVIDERS).
719
+ * - Drop `enabled: true` (default).
720
+ * - Drop `name` if equal to the file-level `globalName`.
721
+ * - Drop `imap.user` / `smtp.user` if they equal the email.
722
+ * - Drop `sig.html: false` (default; only keep when user enabled HTML sigs).
723
+ * - Keep field order: id → label → email → primary* → imap → smtp → defaultSend
724
+ * → relayDomains → deliveredToPrefix → identityDomains → syncContacts → sig.
725
+ * Curated for readability, not alphabetic. */
726
+ export function denormalizeAccount(acct, globalName) {
727
+ const domain = (acct.email || "").split("@")[1]?.toLowerCase() || "";
728
+ const provider = PROVIDERS[domain];
729
+ const out = {};
730
+ out.id = acct.id;
731
+ if (acct.label && acct.label !== provider?.label && acct.label !== acct.id)
732
+ out.label = acct.label;
733
+ out.email = acct.email;
734
+ if (acct.name && acct.name !== globalName)
735
+ out.name = acct.name;
736
+ if (acct.primary)
737
+ out.primary = true;
738
+ if (acct.primaryCalendar !== undefined)
739
+ out.primaryCalendar = acct.primaryCalendar;
740
+ if (acct.primaryTasks !== undefined)
741
+ out.primaryTasks = acct.primaryTasks;
742
+ if (acct.primaryContacts !== undefined)
743
+ out.primaryContacts = acct.primaryContacts;
744
+ // imap — keep only fields that differ from provider defaults.
745
+ const imapOut = {};
746
+ if (acct.imap?.host && acct.imap.host !== provider?.imap.host)
747
+ imapOut.host = acct.imap.host;
748
+ if (acct.imap?.user && acct.imap.user !== acct.email)
749
+ imapOut.user = acct.imap.user;
750
+ if (acct.imap?.password)
751
+ imapOut.password = acct.imap.password;
752
+ if (acct.imap?.port && acct.imap.port !== (provider?.imap.port ?? 993))
753
+ imapOut.port = acct.imap.port;
754
+ if (acct.imap?.tls !== undefined && acct.imap.tls !== (provider?.imap.tls ?? true))
755
+ imapOut.tls = acct.imap.tls;
756
+ if (acct.imap?.auth && acct.imap.auth !== (provider?.imap.auth ?? "password"))
757
+ imapOut.auth = acct.imap.auth;
758
+ if (Object.keys(imapOut).length > 0)
759
+ out.imap = imapOut;
760
+ // smtp — same treatment.
761
+ const smtpOut = {};
762
+ if (acct.smtp?.host && acct.smtp.host !== provider?.smtp.host)
763
+ smtpOut.host = acct.smtp.host;
764
+ if (acct.smtp?.user && acct.smtp.user !== acct.email && acct.smtp.user !== acct.imap?.user)
765
+ smtpOut.user = acct.smtp.user;
766
+ if (acct.smtp?.password)
767
+ smtpOut.password = acct.smtp.password;
768
+ if (acct.smtp?.port && acct.smtp.port !== (provider?.smtp.port ?? 587))
769
+ smtpOut.port = acct.smtp.port;
770
+ if (acct.smtp?.tls !== undefined && acct.smtp.tls !== (provider?.smtp.tls ?? true))
771
+ smtpOut.tls = acct.smtp.tls;
772
+ if (acct.smtp?.auth && acct.smtp.auth !== (provider?.smtp.auth ?? "password"))
773
+ smtpOut.auth = acct.smtp.auth;
774
+ if (Object.keys(smtpOut).length > 0)
775
+ out.smtp = smtpOut;
776
+ if (acct.defaultSend)
777
+ out.defaultSend = true;
778
+ if (acct.enabled === false)
779
+ out.enabled = false; // default true → omit
780
+ if (acct.relayDomains && acct.relayDomains.length > 0)
781
+ out.relayDomains = acct.relayDomains;
782
+ if (acct.deliveredToPrefix && acct.deliveredToPrefix.length > 0)
783
+ out.deliveredToPrefix = acct.deliveredToPrefix;
784
+ if (acct.identityDomains && acct.identityDomains.length > 0)
785
+ out.identityDomains = acct.identityDomains;
786
+ // syncContacts default: true for OAuth, false otherwise. Only emit when
787
+ // the user overrode the default.
788
+ const syncContactsDefault = provider?.imap.auth === "oauth2";
789
+ if (acct.syncContacts !== undefined && acct.syncContacts !== syncContactsDefault) {
790
+ out.syncContacts = acct.syncContacts;
791
+ }
792
+ if (acct.signature)
793
+ out.signature = acct.signature;
794
+ if (acct.sig?.text) {
795
+ // html: false is default; only keep the html flag when explicitly true.
796
+ out.sig = acct.sig.html ? { text: acct.sig.text, html: true } : { text: acct.sig.text };
797
+ }
798
+ return out;
799
+ }
133
800
  /** Save account configs */
134
- export function saveAccounts(accounts) {
135
- saveFile("accounts.jsonc", { accounts });
801
+ /** Save accounts — merges with cloud copy by email (multi-client safe).
802
+ * Writes the lean form via denormalizeAccount so accounts.jsonc stays
803
+ * compact and human-editable. */
804
+ export async function saveAccounts(accounts) {
805
+ // Merge with cloud: keep all accounts, deduplicate by normalized email
806
+ try {
807
+ const cloudContent = await cloudRead("accounts.jsonc");
808
+ if (cloudContent) {
809
+ const cloud = parseJsonc(cloudContent);
810
+ const cloudAccts = cloud?.accounts || (Array.isArray(cloud) ? cloud : []);
811
+ if (cloudAccts.length > 0) {
812
+ const seen = new Set(accounts.map(a => normalizeEmail(a.email)));
813
+ for (const ca of cloudAccts) {
814
+ if (ca.email && !seen.has(normalizeEmail(ca.email))) {
815
+ // Cloud entries are already lean — feed through
816
+ // normalizeAccount to coerce to AccountConfig shape.
817
+ accounts.push(normalizeAccount(ca));
818
+ seen.add(normalizeEmail(ca.email));
819
+ }
820
+ }
821
+ }
822
+ }
823
+ }
824
+ catch { /* cloud read failed — save local version */ }
825
+ // Promote a shared "name" to file level when every account has the same
826
+ // name — keeps the JSONC tidy ({ "name": "Bob Frankston", "accounts": [...] }
827
+ // instead of repeating "name" on each entry).
828
+ const names = new Set(accounts.map(a => a.name).filter(Boolean));
829
+ const globalName = names.size === 1 ? [...names][0] : undefined;
830
+ const lean = accounts.map(a => denormalizeAccount(a, globalName));
831
+ const payload = globalName ? { name: globalName, accounts: lean } : { accounts: lean };
832
+ saveFile("accounts.jsonc", payload);
136
833
  }
137
- /** Load preferences (shared + local overrides) */
834
+ /** Load preferences (shared + local overrides, with legacy fallback) */
138
835
  export function loadPreferences() {
139
- const shared = loadFile("preferences.jsonc", DEFAULT_PREFERENCES);
836
+ let shared = loadFile("preferences.jsonc", DEFAULT_PREFERENCES);
837
+ // Legacy fallback: read ui/sync from settings.jsonc if preferences.jsonc had only defaults
838
+ const legacy = loadLegacySettings();
839
+ if (legacy?.ui)
840
+ shared = { ...shared, ui: { ...shared.ui, ...legacy.ui } };
841
+ if (legacy?.sync)
842
+ shared = { ...shared, sync: { ...shared.sync, ...legacy.sync } };
140
843
  const localConfig = readLocalConfig();
141
844
  // Local overrides
142
845
  if (localConfig.historyDays !== undefined) {
@@ -145,25 +848,62 @@ export function loadPreferences() {
145
848
  return {
146
849
  ui: { ...DEFAULT_PREFERENCES.ui, ...shared.ui },
147
850
  sync: { ...DEFAULT_PREFERENCES.sync, ...shared.sync },
851
+ autocomplete: { ...DEFAULT_AUTOCOMPLETE, ...shared.autocomplete },
148
852
  };
149
853
  }
150
854
  /** Save preferences */
151
855
  export function savePreferences(prefs) {
152
856
  saveFile("preferences.jsonc", prefs);
153
857
  }
858
+ /** Load autocomplete settings */
859
+ export function loadAutocomplete() {
860
+ const prefs = loadPreferences();
861
+ return prefs.autocomplete;
862
+ }
863
+ /** Save autocomplete settings */
864
+ export function saveAutocomplete(settings) {
865
+ const prefs = loadPreferences();
866
+ prefs.autocomplete = settings;
867
+ savePreferences(prefs);
868
+ }
154
869
  /** Load remote content allow-list */
155
870
  export function loadAllowlist() {
156
871
  return loadFile("allowlist.jsonc", DEFAULT_ALLOWLIST);
157
872
  }
158
- /** Save allow-list */
159
- export function saveAllowlist(list) {
160
- saveFile("allowlist.jsonc", list);
873
+ /** Save allow-list — merges with existing cloud copy (multi-client safe) */
874
+ export async function saveAllowlist(list) {
875
+ // Read current cloud version and merge (other clients may have added entries)
876
+ let merged = { ...list };
877
+ try {
878
+ const cloudContent = await cloudRead("allowlist.jsonc");
879
+ if (cloudContent) {
880
+ const cloud = parseJsonc(cloudContent);
881
+ if (cloud) {
882
+ const mergeArrays = (local, remote) => [...new Set([...local, ...remote])];
883
+ merged = {
884
+ senders: mergeArrays(list.senders || [], cloud.senders || []),
885
+ domains: mergeArrays(list.domains || [], cloud.domains || []),
886
+ recipients: mergeArrays(list.recipients || [], cloud.recipients || []),
887
+ flaggedSenders: mergeArrays(list.flaggedSenders || [], cloud.flaggedSenders || []),
888
+ flaggedDomains: mergeArrays(list.flaggedDomains || [], cloud.flaggedDomains || []),
889
+ };
890
+ }
891
+ }
892
+ }
893
+ catch { /* cloud read failed — save local version */ }
894
+ saveFile("allowlist.jsonc", merged);
161
895
  }
162
896
  // ── Legacy compatibility ──
163
897
  function loadLegacySettings() {
164
898
  const config = readLocalConfig();
165
- const settingsPath = config.settingsPath || path.join(LOCAL_DIR, "settings.jsonc");
166
- return readJsonc(settingsPath);
899
+ if (config.settingsPath)
900
+ return readJsonc(resolvePath(config.settingsPath));
901
+ // Try shared dir first, then local
902
+ const sharedDir = getSharedDir();
903
+ const shared = readJsonc(path.join(sharedDir, "settings.jsonc"));
904
+ if (shared)
905
+ return shared;
906
+ return readJsonc(path.join(LOCAL_DIR, "settings.jsonc"));
167
907
  }
168
908
  /** Load settings — unified view combining all files (backward compatible) */
169
909
  export function loadSettings() {
@@ -174,6 +914,7 @@ export function loadSettings() {
174
914
  accounts,
175
915
  ui: prefs.ui,
176
916
  sync: prefs.sync,
917
+ autocomplete: prefs.autocomplete,
177
918
  store: {
178
919
  basePath: localConfig.storePath || DEFAULT_STORE_PATH,
179
920
  compressionBoundaryDays: 365,
@@ -181,14 +922,14 @@ export function loadSettings() {
181
922
  };
182
923
  }
183
924
  /** Save settings — writes to split files */
184
- export function saveSettings(settings) {
185
- saveAccounts(settings.accounts);
925
+ export async function saveSettings(settings) {
926
+ await saveAccounts(settings.accounts);
186
927
  savePreferences({ ui: settings.ui, sync: settings.sync });
187
928
  }
188
929
  /** Get the local store base path */
189
930
  export function getStorePath() {
190
931
  const config = readLocalConfig();
191
- return config.storePath || DEFAULT_STORE_PATH;
932
+ return config.storePath ? resolvePath(config.storePath) : DEFAULT_STORE_PATH;
192
933
  }
193
934
  /** Get the local data directory (DB, store, etc.) */
194
935
  export function getConfigDir() {
@@ -196,23 +937,123 @@ export function getConfigDir() {
196
937
  }
197
938
  /** Get the shared settings directory */
198
939
  export { getSharedDir };
940
+ // detectSharedDir() removed — cloud storage is configured via API (gdrive/onedrive),
941
+ // not auto-detected from filesystem mounts. Setup form triggers initCloudConfig().
199
942
  /** Initialize local config if it doesn't exist */
200
943
  export function initLocalConfig(sharedDir, storePath) {
201
944
  if (fs.existsSync(LOCAL_CONFIG_PATH) && !sharedDir && !storePath)
202
945
  return;
203
946
  const existing = readLocalConfig();
947
+ // Use explicit sharedDir or preserve existing — no auto-detection.
948
+ // Cloud storage is configured when user adds an account (initCloudConfig).
949
+ const resolvedSharedDir = sharedDir || existing.sharedDir;
204
950
  const config = {
205
951
  ...existing,
206
- sharedDir: sharedDir || existing.sharedDir || existing.settingsPath ? path.dirname(existing.settingsPath) : undefined,
207
- storePath: storePath || existing.storePath || DEFAULT_STORE_PATH,
952
+ sharedDir: resolvedSharedDir,
953
+ // storePath is now optional — omit when default. Resolves to
954
+ // ~/.rmfmail/mailxstore via getStorePath() if not specified.
955
+ ...(storePath || existing.storePath ? { storePath: storePath || existing.storePath } : {}),
208
956
  };
957
+ fs.mkdirSync(LOCAL_DIR, { recursive: true });
209
958
  atomicWrite(LOCAL_CONFIG_PATH, config);
210
959
  }
960
+ /** Initialize config with Google Drive cloud storage.
961
+ * Finds or creates the app-owned ".rmfmail" folder via Drive API and stores its ID.
962
+ * No mount scanning — API only. Existing settings at other paths (e.g., home/.rmfmail
963
+ * from Desktop sync) must be migrated manually or via config.jsonc importPath. */
964
+ export async function initCloudConfig(provider = "gdrive") {
965
+ const existing = readLocalConfig();
966
+ if (existing.sharedDir)
967
+ return; // Already configured
968
+ // Find or create the settings folder via Drive API. Stored path is the
969
+ // canonical post-rebrand location; the folderId is what actually drives
970
+ // subsequent reads/writes, so even legacy users with the old folder name
971
+ // work fine since gDriveFindOrCreateFolder resolves whichever exists.
972
+ const folderId = await gDriveFindOrCreateFolder();
973
+ const sharedDir = { provider, path: "home/.rmfmail", folderId: folderId || undefined };
974
+ const config = { ...existing, sharedDir, storePath: existing.storePath || DEFAULT_STORE_PATH };
975
+ fs.mkdirSync(LOCAL_DIR, { recursive: true });
976
+ atomicWrite(LOCAL_CONFIG_PATH, config);
977
+ pendingCloudConfig = sharedDir;
978
+ console.log(` Initialized cloud config: ${provider} (folder ID: ${folderId || "pending"})`);
979
+ }
211
980
  const DEFAULT_SETTINGS = {
212
981
  accounts: [],
213
982
  ui: DEFAULT_PREFERENCES.ui,
214
983
  sync: DEFAULT_PREFERENCES.sync,
984
+ autocomplete: DEFAULT_AUTOCOMPLETE,
215
985
  store: { basePath: DEFAULT_STORE_PATH, compressionBoundaryDays: 365 },
216
986
  };
217
- export { DEFAULT_SETTINGS, DEFAULT_ALLOWLIST, DEFAULT_PREFERENCES, LOCAL_DIR };
987
+ /** Get historyDays for an account: per-account override > system override > shared default */
988
+ export function getHistoryDays(accountId) {
989
+ const localConfig = readLocalConfig();
990
+ if (accountId && localConfig.accountOverrides?.[accountId]?.historyDays !== undefined) {
991
+ return localConfig.accountOverrides[accountId].historyDays;
992
+ }
993
+ if (localConfig.historyDays !== undefined)
994
+ return localConfig.historyDays;
995
+ const prefs = loadPreferences();
996
+ return prefs.sync.historyDays || 0;
997
+ }
998
+ /** Get prefetch setting: download bodies during sync (default true) */
999
+ export function getPrefetch() {
1000
+ const prefs = loadPreferences();
1001
+ return prefs.sync.prefetch !== false;
1002
+ }
1003
+ export { DEFAULT_SETTINGS, DEFAULT_ALLOWLIST, DEFAULT_PREFERENCES, DEFAULT_AUTOCOMPLETE, LOCAL_DIR };
1004
+ /** Deploy the app-owned `.md` reference docs to the shared cloud folder so
1005
+ * users can read them next to the `.jsonc` files they document. The .md
1006
+ * files live in the app bundle (`<workspace>/app/docs/` in dev, or
1007
+ * `<package>/docs/` in published builds) and are overwritten on every
1008
+ * release — the file header documents that fact, so users know not to
1009
+ * edit them.
1010
+ *
1011
+ * Skip-path: a manifest file `.docs-version` on the cloud records the
1012
+ * app version that last deployed. We re-deploy only when the running
1013
+ * app's version differs, so startup doesn't pound Drive on every launch.
1014
+ *
1015
+ * This function is fire-and-forget at the call site; failures are logged
1016
+ * but never throw. The `.md` files are reference material, not load-
1017
+ * bearing — the app works without them.
1018
+ */
1019
+ export async function deployDocs(appVersion) {
1020
+ if (!pendingCloudConfig?.folderId)
1021
+ return;
1022
+ const provider = getCloudProvider(pendingCloudConfig.provider, pendingCloudConfig.folderId);
1023
+ if (!provider)
1024
+ return;
1025
+ // Resolve the source docs dir. In dev the workspace has `app/docs/`;
1026
+ // in published packages we look for `docs/` next to this file.
1027
+ const candidates = [
1028
+ path.join(__dirname, "..", "..", "docs"), // workspace dev
1029
+ path.join(__dirname, "docs"), // sibling in published package
1030
+ path.join(__dirname, "..", "docs"), // one level up in some layouts
1031
+ ];
1032
+ let docsDir = "";
1033
+ for (const c of candidates) {
1034
+ if (fs.existsSync(c) && fs.readdirSync(c).some(f => f.endsWith(".md"))) {
1035
+ docsDir = c;
1036
+ break;
1037
+ }
1038
+ }
1039
+ if (!docsDir) {
1040
+ console.log(" [docs] no docs/ dir found in package — skipping deploy");
1041
+ return;
1042
+ }
1043
+ try {
1044
+ const deployedVersion = (await provider.read(".docs-version") || "").trim();
1045
+ if (deployedVersion === appVersion)
1046
+ return; // already up to date
1047
+ const mdFiles = fs.readdirSync(docsDir).filter(f => f.endsWith(".md"));
1048
+ for (const f of mdFiles) {
1049
+ const content = fs.readFileSync(path.join(docsDir, f), "utf-8");
1050
+ await provider.write(f, content);
1051
+ }
1052
+ await provider.write(".docs-version", appVersion);
1053
+ console.log(` [docs] deployed ${mdFiles.length} .md file(s) for app v${appVersion}`);
1054
+ }
1055
+ catch (e) {
1056
+ console.warn(` [docs] deploy failed: ${e.message}`);
1057
+ }
1058
+ }
218
1059
  //# sourceMappingURL=index.js.map