@bobfrankston/rmfmail 1.2.170 → 1.2.172

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.
@@ -61,6 +61,17 @@ async function idbDelete(key: string): Promise<void> {
61
61
  });
62
62
  }
63
63
 
64
+ /** All keys with the given prefix (prev-version pruning). */
65
+ async function idbListKeys(prefix: string): Promise<string[]> {
66
+ const db = await openSettingsDb();
67
+ return new Promise((resolve, reject) => {
68
+ const tx = db.transaction(STORE_NAME, "readonly");
69
+ const req = tx.objectStore(STORE_NAME).getAllKeys(IDBKeyRange.bound(prefix, prefix + "￿"));
70
+ req.onsuccess = () => resolve((req.result as IDBValidKey[]).map(String));
71
+ req.onerror = () => reject(req.error);
72
+ });
73
+ }
74
+
64
75
  // ── GDrive API ──
65
76
 
66
77
  /** GDrive folder ID for the ".rmfmail" app folder */
@@ -112,19 +123,36 @@ export async function cloudStat(filename: string): Promise<{ id: string; modifie
112
123
  }
113
124
  }
114
125
 
126
+
127
+ /** Find a config file by name in the .rmfmail folder, DETERMINISTICALLY.
128
+ * Drive allows duplicate names; a second device signed into a different
129
+ * Google account created its own allowlist.jsonc in the shared folder and
130
+ * files?.[0] then made every device's pick a coin flip — stale bases and
131
+ * cross-account clobbers (2026-07-23 wipe). Preference order: a file THIS
132
+ * account owns, then newest modifiedTime. Duplicates are logged loudly. */
133
+ async function gDriveFindFile(filename: string, token: string): Promise<{ id: string; modifiedTime: string; ownedByMe: boolean; capabilitiesCanEdit: boolean } | null> {
134
+ const q = encodeURIComponent(`name='${filename}' and '${gDriveFolderId}' in parents and trashed=false`);
135
+ const res = await globalThis.fetch(
136
+ `https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id,modifiedTime,ownedByMe,capabilities(canEdit))`,
137
+ { headers: { "Authorization": `Bearer ${token}` } }
138
+ );
139
+ if (!res.ok) return null;
140
+ const files: any[] = ((await res.json()) as any).files || [];
141
+ if (files.length === 0) return null;
142
+ if (files.length > 1) {
143
+ console.warn(`[settings] ${filename}: ${files.length} same-named files in the shared folder — using own/newest. Clean the duplicates (owners differ?).`);
144
+ files.sort((a, b) => (b.ownedByMe === true ? 1 : 0) - (a.ownedByMe === true ? 1 : 0) || String(b.modifiedTime).localeCompare(String(a.modifiedTime)));
145
+ }
146
+ const f = files[0];
147
+ return { id: f.id, modifiedTime: f.modifiedTime || '', ownedByMe: f.ownedByMe === true, capabilitiesCanEdit: f.capabilities?.canEdit !== false };
148
+ }
149
+
115
150
  async function gDriveRead(filename: string): Promise<string | null> {
116
151
  if (!tokenProvider || !gDriveFolderId) return null;
117
152
  try {
118
153
  const token = await tokenProvider();
119
- // Find file by name in folder
120
- const q = encodeURIComponent(`name='${filename}' and '${gDriveFolderId}' in parents and trashed=false`);
121
- const listRes = await globalThis.fetch(
122
- `https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id)`,
123
- { headers: { "Authorization": `Bearer ${token}` } }
124
- );
125
- if (!listRes.ok) return null;
126
- const listData = await listRes.json() as any;
127
- const fileId = listData.files?.[0]?.id;
154
+ const found = await gDriveFindFile(filename, token);
155
+ const fileId = found?.id;
128
156
  if (!fileId) return null;
129
157
  // Download content
130
158
  const res = await globalThis.fetch(
@@ -143,17 +171,18 @@ async function gDriveWrite(filename: string, content: string): Promise<boolean>
143
171
  if (!tokenProvider || !gDriveFolderId) return false;
144
172
  try {
145
173
  const token = await tokenProvider();
146
- // Check if file exists
147
- const q = encodeURIComponent(`name='${filename}' and '${gDriveFolderId}' in parents and trashed=false`);
148
- const listRes = await globalThis.fetch(
149
- `https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id)`,
150
- { headers: { "Authorization": `Bearer ${token}` } }
151
- );
152
- if (!listRes.ok) return false;
153
- const listData = await listRes.json() as any;
154
- const fileId = listData.files?.[0]?.id;
174
+ const found = await gDriveFindFile(filename, token);
175
+ const fileId = found?.id;
155
176
 
156
177
  if (fileId) {
178
+ // A file EXISTS but this account can't edit it (someone else's
179
+ // copy in a shared folder). Creating a same-named sibling is how
180
+ // the duplicate-file mess started — never fork; fail loudly and
181
+ // keep the change local-only.
182
+ if (!found!.capabilitiesCanEdit) {
183
+ console.error(`[settings] ${filename}: existing shared file is not editable by this account — NOT creating a duplicate. Change kept locally.`);
184
+ return false;
185
+ }
157
186
  // Update existing
158
187
  const res = await globalThis.fetch(
159
188
  `https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=media`,
@@ -166,6 +195,7 @@ async function gDriveWrite(filename: string, content: string): Promise<boolean>
166
195
  body: content,
167
196
  }
168
197
  );
198
+ if (!res.ok) console.error(`[settings] ${filename}: cloud update failed (${res.status})`);
169
199
  return res.ok;
170
200
  } else {
171
201
  // Create new
@@ -365,6 +395,24 @@ function parseJsonc(text: string): any {
365
395
  // the NEXT read sees the shared copy. Caches are caches, never the source
366
396
  // of truth. (Per-device files like devices/<id>/state.json are exempt —
367
397
  // only this device writes them.)
398
+ // prev-versioning (Bob's prev/ convention, IndexedDB flavor): before a
399
+ // shared config file's cache changes, stash the outgoing version under a
400
+ // `prev:` key so a clobber leaves a local trail (the 2026-07-23 allowlist
401
+ // wipe had NO local history anywhere — Drive revisions only reach ~30 days).
402
+ const PREV_KEEP = 5;
403
+ async function stashPrevIdb(filename: string, aboutToBecome: string): Promise<void> {
404
+ try {
405
+ const old = await idbRead(filename);
406
+ if (!old || old === aboutToBecome) return;
407
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
408
+ await idbWrite(`prev:${filename}:${stamp}`, old);
409
+ const keys = await idbListKeys(`prev:${filename}:`);
410
+ for (const k of keys.sort().slice(0, Math.max(0, keys.length - PREV_KEEP))) {
411
+ await idbDelete(k);
412
+ }
413
+ } catch { /* archival never blocks a save */ }
414
+ }
415
+
368
416
  const SHARED_REFRESH_MS = 5 * 60_000;
369
417
  const __lastSharedRefresh = new Map<string, number>();
370
418
  function refreshSharedFile(filename: string, cached: string): void {
@@ -462,6 +510,7 @@ export async function loadPreferences(): Promise<typeof DEFAULT_PREFERENCES> {
462
510
  /** Save preferences */
463
511
  export async function savePreferences(prefs: any): Promise<void> {
464
512
  const content = JSON.stringify(prefs, null, 2);
513
+ await stashPrevIdb("preferences.jsonc", content);
465
514
  await idbWrite("preferences.jsonc", content);
466
515
  await gDriveWrite("preferences.jsonc", content);
467
516
  }
@@ -503,10 +552,52 @@ export async function loadAllowlist(): Promise<typeof DEFAULT_ALLOWLIST> {
503
552
  return { ...DEFAULT_ALLOWLIST };
504
553
  }
505
554
 
506
- /** Save allowlist */
555
+ const ALLOWLIST_KEYS = ["senders", "domains", "recipients", "flaggedSenders", "flaggedDomains"] as const;
556
+ function allowlistEntryCount(l: any): number {
557
+ return ALLOWLIST_KEYS.reduce((n, k) => n + (Array.isArray(l?.[k]) ? l[k].length : 0), 0);
558
+ }
559
+
560
+ /** Read-modify-write against the FRESHEST copy. The load→mutate→save shape
561
+ * clobbered the shared file twice (2026-07-22/23): a phone whose cached
562
+ * base was 12 days stale wrote it back minus everything added since, and a
563
+ * fresh install whose base was DEFAULT_ALLOWLIST wiped the file to ~nothing
564
+ * (restored from Drive revisions). Mutations now apply to the live cloud
565
+ * copy when reachable; the cache is only the offline fallback. */
566
+ export async function updateAllowlist(mutate: (list: typeof DEFAULT_ALLOWLIST) => typeof DEFAULT_ALLOWLIST | void): Promise<typeof DEFAULT_ALLOWLIST> {
567
+ let base: typeof DEFAULT_ALLOWLIST | null = null;
568
+ try {
569
+ const fresh = await gDriveRead("allowlist.jsonc");
570
+ if (fresh) {
571
+ await idbWrite("allowlist.jsonc", fresh);
572
+ try { base = parseJsonc(fresh); } catch { /* corrupt cloud copy — fall back */ }
573
+ }
574
+ } catch { /* offline / auth pending */ }
575
+ if (!base) base = await loadAllowlist();
576
+ const out = mutate(base) || base;
577
+ await saveAllowlist(out);
578
+ return out;
579
+ }
580
+
581
+ /** Save allowlist. Backstop shrink guard: a write that would discard more
582
+ * than half of a substantial cloud copy is the wipe signature (stale or
583
+ * default base) — keep it out of the shared file. The local cache still
584
+ * updates so the device's own view is consistent; the next
585
+ * stale-while-revalidate pull re-syncs it with the cloud. */
507
586
  export async function saveAllowlist(list: typeof DEFAULT_ALLOWLIST): Promise<void> {
508
587
  const content = JSON.stringify(list, null, 2);
588
+ await stashPrevIdb("allowlist.jsonc", content);
509
589
  await idbWrite("allowlist.jsonc", content);
590
+ try {
591
+ const cloudRaw = await gDriveRead("allowlist.jsonc");
592
+ if (cloudRaw) {
593
+ const cloudCount = allowlistEntryCount(parseJsonc(cloudRaw));
594
+ const newCount = allowlistEntryCount(list);
595
+ if (cloudCount > 10 && newCount < cloudCount / 2) {
596
+ console.error(`[settings] REFUSING allowlist cloud write: ${cloudCount} → ${newCount} entries (stale/default base — see 2026-07-23 wipe). Local cache updated only.`);
597
+ return;
598
+ }
599
+ }
600
+ } catch { /* cloud unreadable — proceed; the guard only fires when it can verify */ }
510
601
  await gDriveWrite("allowlist.jsonc", content);
511
602
  }
512
603