@bobfrankston/mailx 1.0.203 → 1.0.205

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.
@@ -1 +1 @@
1
- {"height":1344,"width":2151,"x":375,"y":51}
1
+ {"height":1344,"width":2151,"x":476,"y":58}
package/client/app.js CHANGED
@@ -341,6 +341,7 @@ async function openCompose(mode) {
341
341
  else if (msg && mode === "forward") {
342
342
  init.subject = `Fwd: ${cleanSubject}`;
343
343
  init.bodyHtml = forwardBody(msg);
344
+ init.fromAddress = detectReplyFrom();
344
345
  }
345
346
  // Store init data for compose window to pick up
346
347
  sessionStorage.setItem("composeInit", JSON.stringify(init));
@@ -897,6 +898,28 @@ document.addEventListener("keydown", (e) => {
897
898
  row.classList.toggle("unread", !newFlags.includes("\\Seen"));
898
899
  }).catch(() => { });
899
900
  }
901
+ // Arrow keys — navigate message list
902
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
903
+ const active = document.activeElement;
904
+ if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.tagName === "SELECT"))
905
+ return;
906
+ const body = document.getElementById("ml-body");
907
+ if (!body)
908
+ return;
909
+ const selected = body.querySelector(".ml-row.selected");
910
+ if (!selected) {
911
+ const first = body.querySelector(".ml-row");
912
+ if (first)
913
+ first.click();
914
+ return;
915
+ }
916
+ const next = (e.key === "ArrowDown" ? selected.nextElementSibling : selected.previousElementSibling);
917
+ if (next?.classList.contains("ml-row")) {
918
+ e.preventDefault();
919
+ next.click();
920
+ next.scrollIntoView({ block: "nearest" });
921
+ }
922
+ }
900
923
  });
901
924
  // ── View menu ──
902
925
  const viewBtn = document.getElementById("btn-view");
@@ -290,10 +290,11 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
290
290
  else {
291
291
  bodyEl.innerHTML = `<div class="mv-empty">No content</div>`;
292
292
  }
293
- // Attachments
293
+ // Attachments — always clear first to avoid stale chips from previous message
294
+ attEl.innerHTML = "";
295
+ attEl.hidden = true;
294
296
  if (msg.attachments?.length) {
295
297
  attEl.hidden = false;
296
- attEl.innerHTML = "";
297
298
  for (let i = 0; i < msg.attachments.length; i++) {
298
299
  const att = msg.attachments[i];
299
300
  const chip = document.createElement("a");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.203",
3
+ "version": "1.0.205",
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.255",
27
+ "@bobfrankston/msger": "^0.1.266",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -144,7 +144,7 @@ export class GmailApiProvider {
144
144
  if (options.source && msg.raw) {
145
145
  // Gmail returns URL-safe base64 — convert to standard base64 then decode
146
146
  const base64 = msg.raw.replace(/-/g, "+").replace(/_/g, "/");
147
- source = Buffer.from(base64, "base64").toString("utf-8");
147
+ source = new TextDecoder().decode(Uint8Array.from(atob(base64), c => c.charCodeAt(0)));
148
148
  }
149
149
  const fromRaw = getHeader(headers, "From");
150
150
  const toRaw = getHeader(headers, "To");
@@ -321,6 +321,7 @@ function normalizeAccount(acct, globalName) {
321
321
  syncContacts: acct.syncContacts ?? (provider?.imap.auth === "oauth2"),
322
322
  relayDomains: acct.relayDomains,
323
323
  deliveredToPrefix: acct.deliveredToPrefix,
324
+ identityDomains: acct.identityDomains,
324
325
  };
325
326
  }
326
327
  // ── Defaults ──
@@ -14,7 +14,7 @@
14
14
  import { WebMailxDB } from "./db.js";
15
15
  import { WebMessageStore } from "./web-message-store.js";
16
16
  import { WebMailxService } from "./web-service.js";
17
- import { loadAccounts, saveAccounts, clearSettings } from "./web-settings.js";
17
+ import { loadAccounts, loadAccountsFromCloud, saveAccounts, clearSettings, setGDriveTokenProvider, setGDriveFolderId } from "./web-settings.js";
18
18
  import { GmailApiWebProvider } from "./gmail-api-web.js";
19
19
  // ── State ──
20
20
  let db;
@@ -363,6 +363,19 @@ function createNativeTokenProvider(email) {
363
363
  return token.access_token;
364
364
  };
365
365
  }
366
+ // ── GDrive folder lookup ──
367
+ async function findGDriveMailxFolder(tokenProvider) {
368
+ const token = await tokenProvider();
369
+ const q = encodeURIComponent("name='mailx' and mimeType='application/vnd.google-apps.folder' and trashed=false");
370
+ const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id,name)&spaces=drive`, { headers: { "Authorization": `Bearer ${token}` } });
371
+ if (!res.ok) {
372
+ console.warn(`[gdrive] Folder search failed: ${res.status}`);
373
+ return null;
374
+ }
375
+ const data = await res.json();
376
+ const folder = data.files?.[0];
377
+ return folder?.id || null;
378
+ }
366
379
  // ── Initialization ──
367
380
  async function waitForNativeBridge(timeoutMs = 5000) {
368
381
  if (window._nativeBridge)
@@ -392,18 +405,52 @@ export async function initAndroid() {
392
405
  bodyStore = new WebMessageStore();
393
406
  syncManager = new AndroidSyncManager(db, bodyStore);
394
407
  service = new WebMailxService(db, bodyStore, syncManager);
395
- const accounts = await loadAccounts();
408
+ let accounts = await loadAccounts();
396
409
  console.log(`[android] ${accounts.length} account(s) found`);
410
+ // Find a Gmail account to use as the GDrive token provider
411
+ let gmailTokenProvider = null;
397
412
  for (const account of accounts) {
398
413
  if (!account.enabled)
399
414
  continue;
400
- // Set up OAuth token provider for Gmail accounts via Android AccountManager
401
415
  const domain = account.email?.split("@")[1]?.toLowerCase() || "";
402
416
  if (domain === "gmail.com" || domain === "googlemail.com") {
403
- syncManager.setTokenProvider(account.id, createNativeTokenProvider(account.email));
417
+ const tp = createNativeTokenProvider(account.email);
418
+ syncManager.setTokenProvider(account.id, tp);
419
+ if (!gmailTokenProvider)
420
+ gmailTokenProvider = tp;
404
421
  }
405
422
  await syncManager.addAccount(account);
406
423
  }
424
+ // Connect to GDrive using the Gmail token — read shared accounts.jsonc
425
+ if (gmailTokenProvider) {
426
+ setGDriveTokenProvider(gmailTokenProvider);
427
+ try {
428
+ const folderId = await findGDriveMailxFolder(gmailTokenProvider);
429
+ if (folderId) {
430
+ setGDriveFolderId(folderId);
431
+ console.log(`[android] GDrive mailx folder: ${folderId}`);
432
+ // Re-read accounts directly from GDrive (bypass cache to pick up bob.ma etc.)
433
+ const gdriveAccounts = await loadAccountsFromCloud();
434
+ if (gdriveAccounts.length > accounts.length) {
435
+ console.log(`[android] GDrive has ${gdriveAccounts.length} accounts (was ${accounts.length})`);
436
+ accounts = gdriveAccounts;
437
+ // Register any new accounts
438
+ for (const account of accounts) {
439
+ if (!account.enabled)
440
+ continue;
441
+ const domain = account.email?.split("@")[1]?.toLowerCase() || "";
442
+ if (domain === "gmail.com" || domain === "googlemail.com") {
443
+ syncManager.setTokenProvider(account.id, createNativeTokenProvider(account.email));
444
+ }
445
+ await syncManager.addAccount(account);
446
+ }
447
+ }
448
+ }
449
+ }
450
+ catch (e) {
451
+ console.warn(`[android] GDrive access failed: ${e.message}`);
452
+ }
453
+ }
407
454
  installBridge();
408
455
  setTimeout(() => {
409
456
  syncManager.syncAll().catch(e => console.error(`[android] Sync error: ${e.message}`));
@@ -42,6 +42,8 @@ declare const DEFAULT_ALLOWLIST: {
42
42
  };
43
43
  /** Load accounts — first from IndexedDB cache, then GDrive */
44
44
  export declare function loadAccounts(): Promise<AccountConfig[]>;
45
+ /** Load accounts directly from GDrive, bypassing local cache */
46
+ export declare function loadAccountsFromCloud(): Promise<AccountConfig[]>;
45
47
  /** Save accounts to IndexedDB and GDrive */
46
48
  export declare function saveAccounts(accounts: AccountConfig[]): Promise<void>;
47
49
  /** Load preferences */
@@ -260,6 +260,20 @@ export async function loadAccounts() {
260
260
  }
261
261
  return [];
262
262
  }
263
+ /** Load accounts directly from GDrive, bypassing local cache */
264
+ export async function loadAccountsFromCloud() {
265
+ const content = await gDriveRead("accounts.jsonc");
266
+ if (content) {
267
+ await idbWrite("accounts.jsonc", content);
268
+ try {
269
+ const data = JSON.parse(content);
270
+ const raw = data.accounts || (Array.isArray(data) ? data : []);
271
+ return raw.map((a) => normalizeAccount(a, data.name));
272
+ }
273
+ catch { /* parse error */ }
274
+ }
275
+ return [];
276
+ }
263
277
  /** Save accounts to IndexedDB and GDrive */
264
278
  export async function saveAccounts(accounts) {
265
279
  const content = JSON.stringify({ accounts }, null, 2);
@@ -33,6 +33,7 @@ export interface AccountConfig {
33
33
  syncContacts?: boolean; /** Sync contacts even when account is disabled (contacts-only Gmail) */
34
34
  relayDomains?: string[]; /** Domains to skip in Delivered-To chain (e.g., ["m.connectivity.xyz"]) */
35
35
  deliveredToPrefix?: string[]; /** Prefixes to strip from Delivered-To to get clean alias (e.g., ["bobf-ma-", "bobf-"]) — order matters, longest first */
36
+ identityDomains?: string[]; /** Domains where Delivered-To address should become the reply From (e.g., ["bob.ma", "bobf.frankston.com"]) */
36
37
  }
37
38
  /** Standard IMAP special-use folder types */
38
39
  export type SpecialUse = "inbox" | "sent" | "drafts" | "trash" | "junk" | "archive" | "all";