@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 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) && !["--no-browser"].includes(a))) {
35
- const { spawn } = await import("node:child_process");
36
- const child = spawn(process.execPath, [...process.argv.slice(1), "--daemon"], {
37
- detached: true,
38
- stdio: "ignore",
39
- windowsHide: true,
40
- });
41
- child.unref();
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
- if (!target && isFirstLoad) {
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
@@ -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.206",
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.267",
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",
@@ -64,6 +64,7 @@ export declare function getMessage(params: {
64
64
  size: number;
65
65
  hasAttachments: boolean;
66
66
  preview: string;
67
+ bodyPath?: string;
67
68
  }>;
68
69
  export declare function updateFlags(params: {
69
70
  accountId: string;
@@ -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
- return null;
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
- const msg = await provider.fetchOne(folder.path, uid, { source: true });
192
- if (!msg?.source)
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
- const OAUTH_SCOPES = "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/drive.file";
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
- const deviceId = `android-${getDeviceId().substring(0, 8)}`;
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 → decoded string
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
- try {
141
- source = atob(base64);
142
- }
143
- catch {
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 = JSON.parse(cached);
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 { /* parse error — fall through to GDrive */ }
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 = JSON.parse(content);
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 { /* parse error */ }
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 = JSON.parse(content);
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 { /* parse error */ }
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 = JSON.parse(cached);
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 = JSON.parse(content);
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 JSON.parse(cached);
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 JSON.parse(content);
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 JSON.parse(cached);
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 JSON.parse(content);
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 {