@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.
- package/client/android-bootstrap.bundle.js +117 -40
- package/client/android-bootstrap.bundle.js.map +2 -2
- package/package.json +3 -3
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-settings/index.d.ts.map +1 -1
- package/packages/mailx-settings/index.js +35 -1
- package/packages/mailx-settings/index.js.map +1 -1
- package/packages/mailx-settings/index.ts +32 -1
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-store-web/web-service.d.ts.map +1 -1
- package/packages/mailx-store-web/web-service.js +33 -28
- package/packages/mailx-store-web/web-service.js.map +1 -1
- package/packages/mailx-store-web/web-service.ts +23 -23
- package/packages/mailx-store-web/web-settings.d.ts +12 -1
- package/packages/mailx-store-web/web-settings.d.ts.map +1 -1
- package/packages/mailx-store-web/web-settings.js +112 -15
- package/packages/mailx-store-web/web-settings.js.map +1 -1
- package/packages/mailx-store-web/web-settings.ts +110 -19
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-41184 → node_modules.npmglobalize-stash-74140}/.package-lock.json +0 -0
|
@@ -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
|
-
|
|
120
|
-
const
|
|
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
|
-
|
|
147
|
-
const
|
|
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
|
-
|
|
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
|
|