@bobfrankston/mailx 1.0.206 → 1.0.208
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/mailx.js +17 -9
- package/client/components/folder-tree.js +7 -2
- package/client/styles/layout.css +6 -2
- package/package.json +2 -2
- package/packages/mailx-core/index.d.ts +1 -0
- package/packages/mailx-store-web/android-bootstrap.js +65 -10
- package/packages/mailx-store-web/db.js +2 -1
- package/packages/mailx-store-web/gmail-api-web.d.ts +2 -0
- package/packages/mailx-store-web/gmail-api-web.js +25 -9
- package/packages/mailx-store-web/web-settings.js +68 -12
- package/packages/mailx-types/index.d.ts +1 -0
package/bin/mailx.js
CHANGED
|
@@ -31,14 +31,22 @@ const isDaemon = hasFlag("daemon"); // internal: re-spawned detached process
|
|
|
31
31
|
// Auto-detach: re-spawn as background process so terminal returns immediately
|
|
32
32
|
// Skip for: --verbose (want console), --daemon (already detached),
|
|
33
33
|
// and any command flags (setup, kill, test, etc.)
|
|
34
|
-
if (!verbose && !isDaemon && !process.argv.slice(2).some(a => /^-/.test(a)
|
|
35
|
-
const { spawn } = await import("node:child_process");
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
34
|
+
if (!verbose && !isDaemon && !process.argv.slice(2).some(a => /^-/.test(a))) {
|
|
35
|
+
const { execSync, spawn } = await import("node:child_process");
|
|
36
|
+
if (process.platform === "win32") {
|
|
37
|
+
// Use wscript to launch without any visible console window
|
|
38
|
+
const args = [...process.argv.slice(1), "--daemon"].map(a => `"${a}"`).join(" ");
|
|
39
|
+
const vbs = `CreateObject("Wscript.Shell").Run """${process.execPath}"" ${args}", 0, False`;
|
|
40
|
+
const tmpVbs = path.join(os.tmpdir(), "mailx-launch.vbs");
|
|
41
|
+
fs.writeFileSync(tmpVbs, vbs);
|
|
42
|
+
execSync(`wscript "${tmpVbs}"`, { stdio: "ignore" });
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
const child = spawn(process.execPath, [...process.argv.slice(1), "--daemon"], {
|
|
46
|
+
detached: true, stdio: "ignore",
|
|
47
|
+
});
|
|
48
|
+
child.unref();
|
|
49
|
+
}
|
|
42
50
|
process.exit(0);
|
|
43
51
|
}
|
|
44
52
|
const setupMode = hasFlag("setup");
|
|
@@ -607,7 +615,7 @@ async function registerClient(settings) {
|
|
|
607
615
|
}
|
|
608
616
|
}
|
|
609
617
|
catch { /* ignore */ }
|
|
610
|
-
// Read existing clients.jsonc from cloud
|
|
618
|
+
// Read existing clients.jsonc from cloud (may not exist yet — that's fine)
|
|
611
619
|
let clients = {};
|
|
612
620
|
try {
|
|
613
621
|
const content = await cloudRead("clients.jsonc");
|
|
@@ -10,6 +10,7 @@ let selectedElement;
|
|
|
10
10
|
let selectedAccountId = null;
|
|
11
11
|
let selectedFolderId = null;
|
|
12
12
|
let isFirstLoad = true; // only auto-select on first load
|
|
13
|
+
let hasAutoSelected = false; // track whether we've ever managed to auto-select
|
|
13
14
|
// Debounce timer for refreshFolderTree
|
|
14
15
|
let refreshDebounceTimer = null;
|
|
15
16
|
// Persist expand/collapse state in localStorage
|
|
@@ -670,7 +671,9 @@ async function loadFolderTree(container) {
|
|
|
670
671
|
}
|
|
671
672
|
}
|
|
672
673
|
}
|
|
673
|
-
|
|
674
|
+
// Auto-select on first load OR until we successfully auto-selected at least once
|
|
675
|
+
// (handles Android where folders don't exist on first load — they arrive after sync)
|
|
676
|
+
if (!target && (isFirstLoad || !hasAutoSelected)) {
|
|
674
677
|
// Auto-select only on first load — not on refresh (prevents jumping)
|
|
675
678
|
const unified = container.querySelector('.ft-unified');
|
|
676
679
|
if (unified) {
|
|
@@ -694,8 +697,10 @@ async function loadFolderTree(container) {
|
|
|
694
697
|
}
|
|
695
698
|
if (!target && allFolderEls.length > 0)
|
|
696
699
|
target = allFolderEls[0];
|
|
697
|
-
if (target)
|
|
700
|
+
if (target) {
|
|
698
701
|
target.click();
|
|
702
|
+
hasAutoSelected = true;
|
|
703
|
+
}
|
|
699
704
|
}
|
|
700
705
|
isFirstLoad = false;
|
|
701
706
|
// Dismiss startup overlay once tree is loaded
|
package/client/styles/layout.css
CHANGED
|
@@ -87,8 +87,12 @@ body {
|
|
|
87
87
|
#btn-menu { display: inline-flex !important; }
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
/* Responsive: narrow viewport — single panel navigation */
|
|
91
|
-
@media (max-width: 768px) {
|
|
90
|
+
/* Responsive: narrow OR short viewport — single panel navigation */
|
|
91
|
+
@media (max-width: 768px), (max-height: 600px) {
|
|
92
|
+
/* Hide preview snippet under message subject — save space */
|
|
93
|
+
.ml-preview { display: none; }
|
|
94
|
+
}
|
|
95
|
+
@media (max-width: 768px), (max-height: 600px) {
|
|
92
96
|
body {
|
|
93
97
|
grid-template-columns: 1fr;
|
|
94
98
|
grid-template-rows: var(--toolbar-height) auto 1fr var(--statusbar-height);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.208",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.269",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -159,6 +159,9 @@ class AndroidSyncManager {
|
|
|
159
159
|
flags.push("\\Answered");
|
|
160
160
|
if (msg.draft)
|
|
161
161
|
flags.push("\\Draft");
|
|
162
|
+
// Store the Gmail providerId in bodyPath as "gmail:<id>" so we can
|
|
163
|
+
// fetch the body directly without re-listing 1000 messages from the folder
|
|
164
|
+
const bodyPath = msg.providerId ? `gmail:${msg.providerId}` : "";
|
|
162
165
|
this.db.upsertMessage({
|
|
163
166
|
accountId, folderId, uid: msg.uid,
|
|
164
167
|
messageId: msg.messageId || "", inReplyTo: "", references: [],
|
|
@@ -167,7 +170,7 @@ class AndroidSyncManager {
|
|
|
167
170
|
from: toEmailAddress(msg.from?.[0]),
|
|
168
171
|
to: msg.to.map(a => toEmailAddress(a)),
|
|
169
172
|
cc: msg.cc.map(a => toEmailAddress(a)),
|
|
170
|
-
flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath
|
|
173
|
+
flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath,
|
|
171
174
|
});
|
|
172
175
|
}
|
|
173
176
|
this.db.commitTransaction();
|
|
@@ -182,15 +185,31 @@ class AndroidSyncManager {
|
|
|
182
185
|
return this.bodyStore.getMessage(accountId, folderId, uid);
|
|
183
186
|
}
|
|
184
187
|
const provider = this.getProvider(accountId);
|
|
185
|
-
if (!provider)
|
|
186
|
-
|
|
187
|
-
const folders = this.db.getFolders(accountId);
|
|
188
|
-
const folder = folders.find(f => f.id === folderId);
|
|
189
|
-
if (!folder)
|
|
188
|
+
if (!provider) {
|
|
189
|
+
console.warn(`[fetchBody] No provider for ${accountId}`);
|
|
190
190
|
return null;
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
}
|
|
192
|
+
// Look up the Gmail providerId stored in body_path during sync
|
|
193
|
+
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
194
|
+
const bp = envelope?.bodyPath || "";
|
|
195
|
+
let msg = null;
|
|
196
|
+
if (bp.startsWith("gmail:") && provider.fetchById) {
|
|
197
|
+
const providerId = bp.substring(6);
|
|
198
|
+
msg = await provider.fetchById(providerId, { source: true });
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
// Fallback: list-and-find by UID (slow, fragile)
|
|
202
|
+
const folders = this.db.getFolders(accountId);
|
|
203
|
+
const folder = folders.find(f => f.id === folderId);
|
|
204
|
+
if (!folder)
|
|
205
|
+
return null;
|
|
206
|
+
msg = await provider.fetchOne(folder.path, uid, { source: true });
|
|
207
|
+
}
|
|
208
|
+
if (!msg?.source) {
|
|
209
|
+
console.warn(`[fetchBody] No source returned for ${accountId}/${folderId}/${uid} (bp=${bp})`);
|
|
193
210
|
return null;
|
|
211
|
+
}
|
|
212
|
+
// Encode the UTF-8 string back to bytes for storage
|
|
194
213
|
const raw = new TextEncoder().encode(msg.source);
|
|
195
214
|
await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
196
215
|
this.db.updateBodyPath(accountId, uid, `idb:${accountId}/${folderId}/${uid}`);
|
|
@@ -247,7 +266,10 @@ const OAUTH_CLIENT = {
|
|
|
247
266
|
// Reverse client ID scheme — auto-allowed for Google "installed" apps
|
|
248
267
|
redirectUri: "com.googleusercontent.apps.884213380682-hcso64dcqmk4p98vsc7br2e6gvn7iv2u:/oauth2callback",
|
|
249
268
|
};
|
|
250
|
-
|
|
269
|
+
// Use full drive scope so we can read/write the desktop's accounts.jsonc + clients.jsonc.
|
|
270
|
+
// drive.file is per-consent-grant: files created by desktop's grant aren't visible to Android's grant
|
|
271
|
+
// even with the same client_id. drive (full) lets us see all files the user has access to.
|
|
272
|
+
const OAUTH_SCOPES = "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/drive";
|
|
251
273
|
// ── Token cache (IndexedDB) ──
|
|
252
274
|
async function getCachedToken(email) {
|
|
253
275
|
const key = `oauth-token-${email.replace(/[@.]/g, "_")}`;
|
|
@@ -367,7 +389,18 @@ function createNativeTokenProvider(email) {
|
|
|
367
389
|
async function registerDeviceInGDrive(tokenProvider, folderId, accountIds) {
|
|
368
390
|
try {
|
|
369
391
|
const token = await tokenProvider();
|
|
370
|
-
|
|
392
|
+
// Use persistent Android device ID (survives factory reset & app data clear)
|
|
393
|
+
const bridge = window._nativeBridge;
|
|
394
|
+
let deviceId = "android-unknown";
|
|
395
|
+
if (bridge?.app?.getAndroidId) {
|
|
396
|
+
try {
|
|
397
|
+
const androidId = await bridge.app.getAndroidId();
|
|
398
|
+
deviceId = `android-${androidId.substring(0, 12)}`;
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
deviceId = `android-${getDeviceId().substring(0, 8)}`;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
371
404
|
// Read existing clients.jsonc
|
|
372
405
|
const q = encodeURIComponent(`name='clients.jsonc' and '${folderId}' in parents and trashed=false`);
|
|
373
406
|
const listRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id)`, { headers: { "Authorization": `Bearer ${token}` } });
|
|
@@ -387,6 +420,12 @@ async function registerDeviceInGDrive(tokenProvider, folderId, accountIds) {
|
|
|
387
420
|
catch { /* */ }
|
|
388
421
|
}
|
|
389
422
|
}
|
|
423
|
+
// Remove stale android-* entries (from old random-UUID approach) — keep only this device
|
|
424
|
+
for (const key of Object.keys(clients)) {
|
|
425
|
+
if (key.startsWith("android-") && key !== deviceId) {
|
|
426
|
+
delete clients[key];
|
|
427
|
+
}
|
|
428
|
+
}
|
|
390
429
|
clients[deviceId] = {
|
|
391
430
|
hostname: deviceId,
|
|
392
431
|
platform: "android",
|
|
@@ -483,6 +522,22 @@ export async function initAndroid() {
|
|
|
483
522
|
else {
|
|
484
523
|
setGDriveFolderId(folderId);
|
|
485
524
|
console.log(`[android] GDrive mailx folder: ${folderId}`);
|
|
525
|
+
// DEBUG: list all files in the folder
|
|
526
|
+
try {
|
|
527
|
+
const tk = await gmailTokenProvider();
|
|
528
|
+
const lr = await fetch(`https://www.googleapis.com/drive/v3/files?q='${folderId}'+in+parents+and+trashed%3Dfalse&fields=files(id,name,mimeType,owners(emailAddress))`, { headers: { "Authorization": `Bearer ${tk}` } });
|
|
529
|
+
if (lr.ok) {
|
|
530
|
+
const ld = await lr.json();
|
|
531
|
+
const names = (ld.files || []).map((f) => `${f.name}(${f.owners?.[0]?.emailAddress || "?"})`).join(",");
|
|
532
|
+
console.log(`[android] Folder contents: ${ld.files?.length || 0} files [${names}]`);
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
console.warn(`[android] List folder failed: ${lr.status}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
catch (e) {
|
|
539
|
+
console.warn(`[android] List debug: ${e.message}`);
|
|
540
|
+
}
|
|
486
541
|
// Read accounts directly from GDrive (bypass IndexedDB cache)
|
|
487
542
|
console.log("[android] Reading accounts.jsonc from GDrive...");
|
|
488
543
|
const gdriveAccounts = await loadAccountsFromCloud();
|
|
@@ -501,7 +501,8 @@ export class WebMailxDB {
|
|
|
501
501
|
from: { name: r.from_name, address: r.from_address },
|
|
502
502
|
to: JSON.parse(r.to_json), cc: JSON.parse(r.cc_json),
|
|
503
503
|
flags: JSON.parse(r.flags_json), size: r.size,
|
|
504
|
-
hasAttachments: !!r.has_attachments, preview: r.preview
|
|
504
|
+
hasAttachments: !!r.has_attachments, preview: r.preview,
|
|
505
|
+
bodyPath: r.body_path || ""
|
|
505
506
|
};
|
|
506
507
|
}
|
|
507
508
|
}
|
|
@@ -19,6 +19,8 @@ export declare class GmailApiWebProvider implements MailProvider {
|
|
|
19
19
|
fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
20
20
|
fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
|
|
21
21
|
fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
22
|
+
/** Fetch a message directly by Gmail provider ID — fast path, no listing needed */
|
|
23
|
+
fetchById(providerId: string, options?: FetchOptions): Promise<ProviderMessage | null>;
|
|
22
24
|
fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
|
|
23
25
|
getUids(folder: string): Promise<number[]>;
|
|
24
26
|
close(): Promise<void>;
|
|
@@ -135,16 +135,14 @@ export class GmailApiWebProvider {
|
|
|
135
135
|
const headers = msg.payload?.headers || [];
|
|
136
136
|
let source = "";
|
|
137
137
|
if (options.source && msg.raw) {
|
|
138
|
-
// URL-safe base64 → standard base64 →
|
|
138
|
+
// URL-safe base64 → standard base64 → UTF-8 string
|
|
139
|
+
// atob() returns a binary string (1 byte per char). Must decode bytes as UTF-8
|
|
140
|
+
// to handle multi-byte characters correctly (e.g. smart quotes).
|
|
139
141
|
const base64 = msg.raw.replace(/-/g, "+").replace(/_/g, "/");
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// Handle padding issues
|
|
145
|
-
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
|
146
|
-
source = atob(padded);
|
|
147
|
-
}
|
|
142
|
+
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
|
143
|
+
const binary = atob(padded);
|
|
144
|
+
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
|
|
145
|
+
source = new TextDecoder("utf-8").decode(bytes);
|
|
148
146
|
}
|
|
149
147
|
const fromRaw = getHeader(headers, "From");
|
|
150
148
|
const toRaw = getHeader(headers, "To");
|
|
@@ -189,6 +187,24 @@ export class GmailApiWebProvider {
|
|
|
189
187
|
const matchingIds = ids.filter(id => uidSet.has(idToUid(id)));
|
|
190
188
|
return this.batchFetch(matchingIds, options);
|
|
191
189
|
}
|
|
190
|
+
/** Fetch a message directly by Gmail provider ID — fast path, no listing needed */
|
|
191
|
+
async fetchById(providerId, options = {}) {
|
|
192
|
+
const format = options.source ? "raw" : "metadata";
|
|
193
|
+
const params = new URLSearchParams({ format });
|
|
194
|
+
if (format === "metadata") {
|
|
195
|
+
for (const h of ["From", "To", "Cc", "Subject", "Message-ID", "Date"]) {
|
|
196
|
+
params.append("metadataHeaders", h);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const msg = await this.apiFetch(`/messages/${providerId}?${params}`);
|
|
201
|
+
return this.parseMessage(msg, options);
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
console.warn(`[gmail] fetchById ${providerId} failed: ${e.message}`);
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
192
208
|
async fetchOne(folder, uid, options = {}) {
|
|
193
209
|
const query = `in:${this.folderToLabel(folder)}`;
|
|
194
210
|
const ids = await this.listMessageIds(query, 1000);
|
|
@@ -232,6 +232,56 @@ const DEFAULT_ALLOWLIST = {
|
|
|
232
232
|
domains: [],
|
|
233
233
|
recipients: [],
|
|
234
234
|
};
|
|
235
|
+
// ── JSONC parser (strips comments and trailing commas) ──
|
|
236
|
+
function parseJsonc(text) {
|
|
237
|
+
// Strip /* block comments */ and // line comments, but preserve content inside strings
|
|
238
|
+
let stripped = "";
|
|
239
|
+
let i = 0;
|
|
240
|
+
let inString = false;
|
|
241
|
+
let stringChar = "";
|
|
242
|
+
while (i < text.length) {
|
|
243
|
+
const c = text[i];
|
|
244
|
+
const next = text[i + 1];
|
|
245
|
+
if (inString) {
|
|
246
|
+
stripped += c;
|
|
247
|
+
if (c === "\\" && i + 1 < text.length) {
|
|
248
|
+
stripped += text[i + 1];
|
|
249
|
+
i += 2;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (c === stringChar)
|
|
253
|
+
inString = false;
|
|
254
|
+
i++;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (c === '"' || c === "'") {
|
|
258
|
+
inString = true;
|
|
259
|
+
stringChar = c;
|
|
260
|
+
stripped += c;
|
|
261
|
+
i++;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (c === "/" && next === "/") {
|
|
265
|
+
// Line comment — skip to end of line
|
|
266
|
+
while (i < text.length && text[i] !== "\n")
|
|
267
|
+
i++;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (c === "/" && next === "*") {
|
|
271
|
+
// Block comment — skip to */
|
|
272
|
+
i += 2;
|
|
273
|
+
while (i < text.length - 1 && !(text[i] === "*" && text[i + 1] === "/"))
|
|
274
|
+
i++;
|
|
275
|
+
i += 2;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
stripped += c;
|
|
279
|
+
i++;
|
|
280
|
+
}
|
|
281
|
+
// Strip trailing commas before } or ]
|
|
282
|
+
stripped = stripped.replace(/,(\s*[}\]])/g, "$1");
|
|
283
|
+
return JSON.parse(stripped);
|
|
284
|
+
}
|
|
235
285
|
// ── Public API ──
|
|
236
286
|
/** Load accounts — first from IndexedDB cache, then GDrive */
|
|
237
287
|
export async function loadAccounts() {
|
|
@@ -239,24 +289,28 @@ export async function loadAccounts() {
|
|
|
239
289
|
const cached = await idbRead("accounts.jsonc");
|
|
240
290
|
if (cached) {
|
|
241
291
|
try {
|
|
242
|
-
const data =
|
|
292
|
+
const data = parseJsonc(cached);
|
|
243
293
|
const raw = data.accounts || (Array.isArray(data) ? data : []);
|
|
244
294
|
if (raw.length > 0) {
|
|
245
295
|
return raw.map((a) => normalizeAccount(a, data.name));
|
|
246
296
|
}
|
|
247
297
|
}
|
|
248
|
-
catch {
|
|
298
|
+
catch (e) {
|
|
299
|
+
console.warn(`[settings] Cached accounts.jsonc parse failed: ${e.message}`);
|
|
300
|
+
}
|
|
249
301
|
}
|
|
250
302
|
// Try GDrive
|
|
251
303
|
const content = await gDriveRead("accounts.jsonc");
|
|
252
304
|
if (content) {
|
|
253
305
|
await idbWrite("accounts.jsonc", content);
|
|
254
306
|
try {
|
|
255
|
-
const data =
|
|
307
|
+
const data = parseJsonc(content);
|
|
256
308
|
const raw = data.accounts || (Array.isArray(data) ? data : []);
|
|
257
309
|
return raw.map((a) => normalizeAccount(a, data.name));
|
|
258
310
|
}
|
|
259
|
-
catch {
|
|
311
|
+
catch (e) {
|
|
312
|
+
console.warn(`[settings] GDrive accounts.jsonc parse failed: ${e.message}`);
|
|
313
|
+
}
|
|
260
314
|
}
|
|
261
315
|
return [];
|
|
262
316
|
}
|
|
@@ -266,11 +320,13 @@ export async function loadAccountsFromCloud() {
|
|
|
266
320
|
if (content) {
|
|
267
321
|
await idbWrite("accounts.jsonc", content);
|
|
268
322
|
try {
|
|
269
|
-
const data =
|
|
323
|
+
const data = parseJsonc(content);
|
|
270
324
|
const raw = data.accounts || (Array.isArray(data) ? data : []);
|
|
271
325
|
return raw.map((a) => normalizeAccount(a, data.name));
|
|
272
326
|
}
|
|
273
|
-
catch {
|
|
327
|
+
catch (e) {
|
|
328
|
+
console.warn(`[settings] loadAccountsFromCloud parse failed: ${e.message}`);
|
|
329
|
+
}
|
|
274
330
|
}
|
|
275
331
|
return [];
|
|
276
332
|
}
|
|
@@ -285,7 +341,7 @@ export async function loadPreferences() {
|
|
|
285
341
|
const cached = await idbRead("preferences.jsonc");
|
|
286
342
|
if (cached) {
|
|
287
343
|
try {
|
|
288
|
-
const data =
|
|
344
|
+
const data = parseJsonc(cached);
|
|
289
345
|
return {
|
|
290
346
|
ui: { ...DEFAULT_PREFERENCES.ui, ...data.ui },
|
|
291
347
|
sync: { ...DEFAULT_PREFERENCES.sync, ...data.sync },
|
|
@@ -299,7 +355,7 @@ export async function loadPreferences() {
|
|
|
299
355
|
if (content) {
|
|
300
356
|
await idbWrite("preferences.jsonc", content);
|
|
301
357
|
try {
|
|
302
|
-
const data =
|
|
358
|
+
const data = parseJsonc(content);
|
|
303
359
|
return {
|
|
304
360
|
ui: { ...DEFAULT_PREFERENCES.ui, ...data.ui },
|
|
305
361
|
sync: { ...DEFAULT_PREFERENCES.sync, ...data.sync },
|
|
@@ -341,7 +397,7 @@ export async function loadAllowlist() {
|
|
|
341
397
|
const cached = await idbRead("allowlist.jsonc");
|
|
342
398
|
if (cached) {
|
|
343
399
|
try {
|
|
344
|
-
return
|
|
400
|
+
return parseJsonc(cached);
|
|
345
401
|
}
|
|
346
402
|
catch { /* */ }
|
|
347
403
|
}
|
|
@@ -349,7 +405,7 @@ export async function loadAllowlist() {
|
|
|
349
405
|
if (content) {
|
|
350
406
|
await idbWrite("allowlist.jsonc", content);
|
|
351
407
|
try {
|
|
352
|
-
return
|
|
408
|
+
return parseJsonc(content);
|
|
353
409
|
}
|
|
354
410
|
catch { /* */ }
|
|
355
411
|
}
|
|
@@ -418,7 +474,7 @@ export async function loadDeviceState() {
|
|
|
418
474
|
const cached = await idbRead(filename);
|
|
419
475
|
if (cached) {
|
|
420
476
|
try {
|
|
421
|
-
return
|
|
477
|
+
return parseJsonc(cached);
|
|
422
478
|
}
|
|
423
479
|
catch { /* */ }
|
|
424
480
|
}
|
|
@@ -426,7 +482,7 @@ export async function loadDeviceState() {
|
|
|
426
482
|
if (content) {
|
|
427
483
|
await idbWrite(filename, content);
|
|
428
484
|
try {
|
|
429
|
-
return
|
|
485
|
+
return parseJsonc(content);
|
|
430
486
|
}
|
|
431
487
|
catch { /* */ }
|
|
432
488
|
}
|
|
@@ -72,6 +72,7 @@ export interface MessageEnvelope {
|
|
|
72
72
|
size: number;
|
|
73
73
|
hasAttachments: boolean;
|
|
74
74
|
preview: string; /** First ~200 chars of body text */
|
|
75
|
+
bodyPath?: string; /** Local body location: "idb:..." or "gmail:<id>" */
|
|
75
76
|
}
|
|
76
77
|
/** Full message with body content */
|
|
77
78
|
export interface Message extends MessageEnvelope {
|