@bobfrankston/mailx 1.0.444 → 1.0.446

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/README.md CHANGED
@@ -259,9 +259,6 @@ mailx -rebuild Wipe local cache and re-download everything from I
259
259
  mailx -setup Interactive first-time setup (CLI)
260
260
  mailx -test Test IMAP/SMTP connectivity for all accounts
261
261
  mailx -reauth Clear cached OAuth tokens; next start re-consents
262
- mailx -lean-accounts Strip defaulted fields from accounts.jsonc on
263
- Google Drive (port, tls, auth, enabled, ...).
264
- Add --dry-run to preview without writing.
265
262
  mailx -v Show version
266
263
  ```
267
264
 
package/bin/mailx.js CHANGED
@@ -17,9 +17,6 @@
17
17
  * mailx -repair Re-sync metadata (fix corrupt subjects) keeping .eml files
18
18
  * mailx -reauth Clear cached OAuth tokens; next start re-consents
19
19
  * (use when new Google scopes have been added)
20
- * mailx -lean-accounts Strip defaulted fields from the GDrive copy of
21
- * accounts.jsonc (port, tls, auth, enabled, ...).
22
- * Add --dry-run to preview without writing.
23
20
  */
24
21
  import fs from "node:fs";
25
22
  import path from "node:path";
@@ -92,7 +89,7 @@ function pidAlive(pid) {
92
89
  // on an old UI with no indication that the install has been upgraded.
93
90
  // Skip this logic for command-only flags (kill, rebuild, setup, ...) and for
94
91
  // the internal --daemon respawn.
95
- const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "import", "log", "reauth", "lean-accounts"];
92
+ const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "import", "log", "reauth"];
96
93
  const __isCommandInvocation = process.argv.slice(2).some(a => __commandFlags.includes(a.replace(/^--?/, "")));
97
94
  if (!isDaemon && !__isCommandInvocation) {
98
95
  const inst = readInstanceFile();
@@ -150,9 +147,8 @@ const testMode = hasFlag("test");
150
147
  const rebuildMode = hasFlag("rebuild");
151
148
  const repairMode = hasFlag("repair");
152
149
  const importMode = hasFlag("import");
153
- const leanAccountsMode = hasFlag("lean-accounts");
154
150
  // Validate arguments
155
- const knownFlags = ["verbose", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "log", "import", "lean-accounts", "email", "mail", "daemon"];
151
+ const knownFlags = ["verbose", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "log", "import", "email", "mail", "daemon"];
156
152
  for (const arg of args) {
157
153
  const flag = arg.replace(/^--?/, "");
158
154
  if (arg.startsWith("-") && !knownFlags.includes(flag)) {
@@ -367,52 +363,6 @@ if (repairMode) {
367
363
  console.log(" Run 'mailx' to re-sync from IMAP with correct encoding.");
368
364
  process.exit(0);
369
365
  }
370
- // Strip default-valued fields from the GDrive copy of accounts.jsonc.
371
- // Operates on the cloud version directly — the local ~/.mailx/accounts.jsonc
372
- // is just a cache and gets updated on the next mailx run anyway.
373
- if (leanAccountsMode) {
374
- const { parse: parseJsonc } = await import("jsonc-parser");
375
- const { initCloudConfig, normalizeAccount, denormalizeAccount } = await import("@bobfrankston/mailx-settings");
376
- const { cloudRead, cloudWrite } = await import("@bobfrankston/mailx-settings");
377
- await initCloudConfig("gdrive");
378
- const raw = await cloudRead("accounts.jsonc");
379
- if (!raw) {
380
- console.error("No accounts.jsonc found in GDrive.");
381
- process.exit(1);
382
- }
383
- const errors = [];
384
- const cfg = parseJsonc(raw, errors, { allowTrailingComma: true });
385
- if (errors.length) {
386
- console.error(`JSONC parse error: ${errors.map((e) => JSON.stringify(e)).join(", ")}`);
387
- process.exit(1);
388
- }
389
- const accountsRaw = cfg?.accounts || (Array.isArray(cfg) ? cfg : []);
390
- if (accountsRaw.length === 0) {
391
- console.error("No accounts found in GDrive accounts.jsonc.");
392
- process.exit(1);
393
- }
394
- const globalName = cfg?.name;
395
- const normalized = accountsRaw.map(a => normalizeAccount(a, globalName));
396
- const names = new Set(normalized.map((a) => a.name).filter(Boolean));
397
- const sharedName = names.size === 1 ? [...names][0] : globalName;
398
- const lean = normalized.map((a) => denormalizeAccount(a, sharedName));
399
- const payload = sharedName ? { name: sharedName, accounts: lean } : { accounts: lean };
400
- const output = JSON.stringify(payload, null, 2);
401
- const before = raw.length;
402
- const after = output.length;
403
- console.log(`Read ${accountsRaw.length} account(s) from GDrive.`);
404
- console.log(`Before: ${before} bytes`);
405
- console.log(`After: ${after} bytes (${Math.round(100 * (1 - after / before))}% smaller)`);
406
- if (process.argv.includes("--dry-run") || process.argv.includes("-n")) {
407
- console.log("--- Lean output (dry run, not written) ---");
408
- console.log(output);
409
- }
410
- else {
411
- await cloudWrite("accounts.jsonc", output);
412
- console.log("Wrote lean accounts.jsonc back to GDrive.");
413
- }
414
- process.exit(0);
415
- }
416
366
  // Import accounts from a local file into GDrive
417
367
  if (importMode) {
418
368
  const importPath = args.find(a => !a.startsWith("-"));
package/client/app.js CHANGED
@@ -2242,7 +2242,7 @@ document.getElementById("btn-open-log")?.addEventListener("click", async () => {
2242
2242
  }
2243
2243
  });
2244
2244
  async function openJsoncEditor(initialFile) {
2245
- const { readJsoncFile, writeJsoncFile, readConfigHelp, formatJsonc, leanAccountsJsonc } = await import("./lib/api-client.js");
2245
+ const { readJsoncFile, writeJsoncFile, readConfigHelp, formatJsonc } = await import("./lib/api-client.js");
2246
2246
  const backdrop = document.createElement("div");
2247
2247
  backdrop.className = "mailx-modal-backdrop";
2248
2248
  const panel = document.createElement("div");
@@ -2278,7 +2278,6 @@ async function openJsoncEditor(initialFile) {
2278
2278
  <div class="mailx-modal-error" id="jsonc-error" hidden></div>
2279
2279
  <div class="mailx-modal-buttons">
2280
2280
  <button type="button" class="mailx-modal-btn" data-action="format" title="Reformat indentation while preserving comments and trailing commas">Format</button>
2281
- <button type="button" class="mailx-modal-btn" data-action="lean" title="accounts.jsonc only — strip default-valued fields (port, tls, auth, etc.) so the file stays compact. Comments are dropped (the lean output is regenerated from values, not the original text)">Lean</button>
2282
2281
  <span class="mailx-modal-spacer"></span>
2283
2282
  <button type="button" class="mailx-modal-btn" data-action="cancel">Cancel</button>
2284
2283
  <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="save">Save</button>
@@ -2445,38 +2444,6 @@ async function openJsoncEditor(initialFile) {
2445
2444
  }
2446
2445
  return;
2447
2446
  }
2448
- if (action === "lean") {
2449
- // accounts.jsonc-only: strip defaulted fields and re-emit
2450
- // a compact form. User reviews it in the editor, then Save
2451
- // commits. Lean drops comments because the output is
2452
- // regenerated from canonical values, not the original text.
2453
- if (fileSelect.value !== "accounts.jsonc") {
2454
- errorEl.textContent = "Lean is only available for accounts.jsonc.";
2455
- errorEl.hidden = false;
2456
- return;
2457
- }
2458
- btn.disabled = true;
2459
- const orig = btn.textContent;
2460
- btn.textContent = "Leaning…";
2461
- try {
2462
- const r = await leanAccountsJsonc(textarea.value);
2463
- if (r?.content !== undefined) {
2464
- textarea.value = r.content;
2465
- renderGutter();
2466
- scheduleValidate();
2467
- errorEl.hidden = true;
2468
- }
2469
- }
2470
- catch (e) {
2471
- errorEl.textContent = `Lean failed: ${e.message}`;
2472
- errorEl.hidden = false;
2473
- }
2474
- finally {
2475
- btn.disabled = false;
2476
- btn.textContent = orig || "Lean";
2477
- }
2478
- return;
2479
- }
2480
2447
  if (action === "save") {
2481
2448
  // Final sync-check; refuse to save if it doesn't parse
2482
2449
  const err = validateJsonc(textarea.value);
@@ -140,10 +140,25 @@ if (!window.__mailxMultiSelectWired) {
140
140
  if (!body?.classList.contains("multi-select-on"))
141
141
  return;
142
142
  const target = e.target;
143
- // A tap on a row is handled by the row's own click listener; only
144
- // exit when the tap is on neutral ground (outside the list entirely).
145
- if (!target.closest(".ml-row"))
146
- exitMultiSelect();
143
+ // A tap on a row is handled by the row's own click listener.
144
+ // The toolbar must also be exempt: its trash / spam / etc.
145
+ // buttons operate ON the current multi-selection, so a tap on
146
+ // them should NOT clear selection before the button's click
147
+ // handler runs (otherwise getSelectedMessages returns empty
148
+ // and the action no-ops — Android-reported 2026-04-30: "press
149
+ // multiple circles, press trashcan, checks vanish, nothing
150
+ // deleted"). Same logic for the folder-tree (drop targets,
151
+ // future: bulk move). Exit only on a tap to genuine neutral
152
+ // ground.
153
+ // Exempt: rows (handled by their own listener), toolbar buttons
154
+ // (delete/spam/etc. operate ON the selection — clearing it here
155
+ // empties the selection before the click runs), folder-tree
156
+ // (drop targets / future bulk move), and the context menu
157
+ // (right-click → "mark read" / "move to" / etc. all need the
158
+ // selection intact when the menu item runs).
159
+ if (target.closest(".ml-row, .toolbar, .folder-tree, .ctx-menu, #btn-tb-delete, #btn-tb-spam"))
160
+ return;
161
+ exitMultiSelect();
147
162
  }, true);
148
163
  }
149
164
  function selectRange(from, to) {
@@ -549,6 +564,15 @@ function renderMessages(body, accountId, items) {
549
564
  body.replaceChildren(fragment);
550
565
  }
551
566
  function selectFirst(body) {
567
+ // Narrow viewports (Android, phone-sized): don't auto-select. The
568
+ // click handler in app.ts switches the layout to "narrow-active" on
569
+ // any list-row click, which on a phone means the message viewer takes
570
+ // over the screen and hides the list. Auto-selecting at startup
571
+ // therefore lands the user in the LAST letter they read instead of
572
+ // the inbox summary they wanted. Desktop unchanged — auto-select
573
+ // remains useful when the list and viewer are side-by-side.
574
+ if (window.innerWidth <= 768)
575
+ return;
552
576
  const firstRow = body.querySelector(".ml-row");
553
577
  if (firstRow)
554
578
  firstRow.click();
@@ -351,9 +351,6 @@ export function readConfigHelp(name) {
351
351
  export function unsubscribeOneClick(url) {
352
352
  return ipc().unsubscribeOneClick?.(url);
353
353
  }
354
- export function leanAccountsJsonc(content) {
355
- return ipc().leanAccountsJsonc?.(content) ?? Promise.resolve({ content });
356
- }
357
354
  export function openInWord(editId, html) {
358
355
  return ipc().openInWord?.(editId, html) ?? Promise.resolve({ ok: false, path: "", opener: "none" });
359
356
  }
@@ -134,9 +134,6 @@
134
134
  formatJsonc: function(content) {
135
135
  return callNode("formatJsonc", { content: content });
136
136
  },
137
- leanAccountsJsonc: function(content) {
138
- return callNode("leanAccountsJsonc", { content: content });
139
- },
140
137
  readConfigHelp: function(name) {
141
138
  return callNode("readConfigHelp", { name: name });
142
139
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.444",
3
+ "version": "1.0.446",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -36,7 +36,7 @@
36
36
  "@bobfrankston/iflow-node": "^0.1.8",
37
37
  "@bobfrankston/miscinfo": "^1.0.10",
38
38
  "@bobfrankston/oauthsupport": "^1.0.25",
39
- "@bobfrankston/msger": "^0.1.366",
39
+ "@bobfrankston/msger": "^0.1.367",
40
40
  "@bobfrankston/mailx-host": "^0.1.8",
41
41
  "@capacitor/android": "^8.3.0",
42
42
  "@capacitor/cli": "^8.3.0",
@@ -100,7 +100,7 @@
100
100
  "@bobfrankston/iflow-node": "^0.1.8",
101
101
  "@bobfrankston/miscinfo": "^1.0.10",
102
102
  "@bobfrankston/oauthsupport": "^1.0.25",
103
- "@bobfrankston/msger": "^0.1.366",
103
+ "@bobfrankston/msger": "^0.1.367",
104
104
  "@bobfrankston/mailx-host": "^0.1.8",
105
105
  "@capacitor/android": "^8.3.0",
106
106
  "@capacitor/cli": "^8.3.0",
@@ -316,16 +316,6 @@ export declare class MailxService {
316
316
  * `config.jsonc` is the local per-machine config (not cloud-synced). */
317
317
  readJsoncFile(name: string): Promise<string | null>;
318
318
  formatJsonc(content: string): Promise<string>;
319
- /** Strip default-valued fields from accounts.jsonc and return the lean
320
- * form. Called from the JSONC editor's "Lean" button so the user can
321
- * see the tidy version before saving. Round-trips through normalize→
322
- * denormalize so the canonicalization rules stay in one place
323
- * (mailx-settings). Drops port: 993, tls: true, auth: "password",
324
- * enabled: true, sig.html: false, etc. when they match the defaults
325
- * for the email's provider. Promotes a shared `name` to the file
326
- * level when every account has the same name. Only handles
327
- * accounts.jsonc — other JSONC files have hand-curated shapes. */
328
- leanAccountsJsonc(content: string): Promise<string>;
329
319
  /** Return the help section for a named config file, extracted from docs/config-help.md.
330
320
  * Matches a level-2 heading whose text equals the filename. Returns markdown. */
331
321
  readConfigHelp(name: string): Promise<string>;
@@ -9,7 +9,7 @@ import * as path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  import * as gsync from "./google-sync.js";
12
- import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo, getConfigDir, normalizeAccount, denormalizeAccount } from "@bobfrankston/mailx-settings";
12
+ import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
13
13
  import { sanitizeHtml, encodeQuotedPrintable, htmlToPlainText } from "@bobfrankston/mailx-types";
14
14
  import { simpleParser } from "mailparser";
15
15
  /** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
@@ -2053,30 +2053,6 @@ export class MailxService {
2053
2053
  });
2054
2054
  return applyEdits(content, edits);
2055
2055
  }
2056
- /** Strip default-valued fields from accounts.jsonc and return the lean
2057
- * form. Called from the JSONC editor's "Lean" button so the user can
2058
- * see the tidy version before saving. Round-trips through normalize→
2059
- * denormalize so the canonicalization rules stay in one place
2060
- * (mailx-settings). Drops port: 993, tls: true, auth: "password",
2061
- * enabled: true, sig.html: false, etc. when they match the defaults
2062
- * for the email's provider. Promotes a shared `name` to the file
2063
- * level when every account has the same name. Only handles
2064
- * accounts.jsonc — other JSONC files have hand-curated shapes. */
2065
- async leanAccountsJsonc(content) {
2066
- const { parse: parseJsonc } = await import("jsonc-parser");
2067
- const errors = [];
2068
- const cfg = parseJsonc(content, errors, { allowTrailingComma: true });
2069
- if (errors.length)
2070
- throw new Error(`JSONC parse error: ${errors.map((e) => e.error).join(", ")}`);
2071
- const raw = cfg?.accounts || (Array.isArray(cfg) ? cfg : []);
2072
- const globalName = cfg?.name;
2073
- const normalized = raw.map((a) => normalizeAccount(a, globalName));
2074
- const names = new Set(normalized.map((a) => a.name).filter(Boolean));
2075
- const sharedName = names.size === 1 ? [...names][0] : globalName;
2076
- const lean = normalized.map((a) => denormalizeAccount(a, sharedName));
2077
- const payload = sharedName ? { name: sharedName, accounts: lean } : { accounts: lean };
2078
- return JSON.stringify(payload, null, 2);
2079
- }
2080
2056
  /** Return the help section for a named config file, extracted from docs/config-help.md.
2081
2057
  * Matches a level-2 heading whose text equals the filename. Returns markdown. */
2082
2058
  async readConfigHelp(name) {
@@ -177,8 +177,6 @@ async function dispatchAction(svc, action, p) {
177
177
  return await svc.unsubscribeOneClick(p.url);
178
178
  case "openInWord":
179
179
  return await svc.openInWord(p.editId, p.html);
180
- case "leanAccountsJsonc":
181
- return { content: await svc.leanAccountsJsonc(p.content) };
182
180
  case "closeWordEdit":
183
181
  return await svc.closeWordEdit(p.editId);
184
182
  // Client-side tracing — lets webview / iframe code ship events to the
@@ -533,36 +533,64 @@ class AndroidSyncManager {
533
533
  }
534
534
  }
535
535
  }
536
- queueOutgoingLocal(accountId, rawMessage) {
536
+ /** In-flight send tracker keyed by queueUid. Prevents
537
+ * processSendQueue from re-firing the same row when it overlaps
538
+ * with an in-progress attempt (e.g., the periodic tick fires while
539
+ * the original attemptSend's promise is still pending). Without
540
+ * this, a slow Gmail/SMTP send race-conditions into a double-send. */
541
+ sendInFlight = new Set();
542
+ async queueOutgoingLocal(accountId, rawMessage) {
537
543
  // Local-first: PERSIST to sync_actions before attempting the network
538
544
  // send, so a crash / offline / process kill between now and SMTP ACK
539
545
  // doesn't drop the message. Desktop parity — PC writes `.ltr` to disk
540
- // before calling SMTP; Android writes a sync_actions row to sql.js +
541
- // IndexedDB (which saveDbToIdb persists on scheduleSave). Unique neg
542
- // uid = Date.now() to avoid colliding with real message UIDs and to
543
- // give us a stable tracking key through the async send pipeline.
546
+ // synchronously; Android writes a sync_actions row and now FLUSHES
547
+ // sql.js → IndexedDB before returning. The previous version relied on
548
+ // the 1-second scheduleSave debounce, so a tab-close inside the debounce
549
+ // window erased the row before it was persisted — the "letter just
550
+ // disappeared" symptom user-reported 2026-04-30.
544
551
  //
545
- // User-flagged 2026-04-23: "sending on android, like on the PC must
546
- // be queued." Equivalent of PC's `~/.mailx/outbox/<acct>/*.ltr`.
552
+ // Equivalent of PC's `~/.mailx/outbox/<acct>/*.ltr` durable write.
547
553
  const queueUid = -Date.now();
548
554
  this.db.queueSyncAction(accountId, "send", queueUid, -1, { rawMessage });
555
+ await this.db.flush();
549
556
  this.attemptSend(accountId, queueUid, rawMessage);
550
557
  }
551
558
  /** Kick off a send for a message that's already in the queue. Called by
552
559
  * queueOutgoingLocal on a fresh submit AND by processSendQueue on
553
- * startup / periodic tick for anything stranded from a prior run. */
560
+ * startup / periodic tick for anything stranded from a prior run.
561
+ * Guards against double-send via sendInFlight. */
554
562
  attemptSend(accountId, queueUid, rawMessage) {
563
+ if (this.sendInFlight.has(queueUid))
564
+ return;
565
+ this.sendInFlight.add(queueUid);
566
+ // Helper to mark complete + flush + clear in-flight — used on every
567
+ // success/failure exit. Flush ensures the row deletion or attempt
568
+ // counter actually reaches IndexedDB before the next process-kill,
569
+ // matching the "persist before network" rule for the post-network
570
+ // outcome too. Without flushing on completion, a successful send
571
+ // followed by a fast app-close left the row in the queue, which
572
+ // looked like a "stuck" message on next launch.
573
+ const finishSend = (success, error) => {
574
+ if (success) {
575
+ this.db.completeSyncActionByUid(accountId, "send", queueUid);
576
+ }
577
+ else {
578
+ this.db.failSyncActionByUid(accountId, "send", queueUid, error || "send failed");
579
+ }
580
+ this.db.flush().catch(() => { });
581
+ this.sendInFlight.delete(queueUid);
582
+ };
555
583
  const provider = this.getProvider(accountId);
556
584
  if (provider && typeof provider.sendRaw === "function") {
557
585
  provider.sendRaw(rawMessage)
558
586
  .then((result) => {
559
587
  console.log(`[send] ${accountId}: sent via Gmail API (id=${result.id})`);
560
- this.db.completeSyncActionByUid(accountId, "send", queueUid);
588
+ finishSend(true);
561
589
  emitEvent({ type: "sendComplete", accountId, messageId: result.id });
562
590
  })
563
591
  .catch((e) => {
564
592
  console.error(`[send] ${accountId}: Gmail send failed: ${e.message}`);
565
- this.db.failSyncActionByUid(accountId, "send", queueUid, e.message || String(e));
593
+ finishSend(false, e.message || String(e));
566
594
  emitEvent({ type: "sendError", accountId, error: e.message });
567
595
  });
568
596
  return;
@@ -574,7 +602,7 @@ class AndroidSyncManager {
574
602
  if (!row) {
575
603
  const e = "Unknown account";
576
604
  console.error(`[send] ${accountId}: ${e}`);
577
- this.db.failSyncActionByUid(accountId, "send", queueUid, e);
605
+ finishSend(false, e);
578
606
  emitEvent({ type: "sendError", accountId, error: e });
579
607
  return;
580
608
  }
@@ -584,26 +612,26 @@ class AndroidSyncManager {
584
612
  }
585
613
  catch {
586
614
  const e = "Account config malformed";
587
- this.db.failSyncActionByUid(accountId, "send", queueUid, e);
615
+ finishSend(false, e);
588
616
  emitEvent({ type: "sendError", accountId, error: e });
589
617
  return;
590
618
  }
591
619
  if (!account.smtp) {
592
620
  const e = "No SMTP config for this account";
593
621
  console.error(`[send] ${accountId}: ${e}`);
594
- this.db.failSyncActionByUid(accountId, "send", queueUid, e);
622
+ finishSend(false, e);
595
623
  emitEvent({ type: "sendError", accountId, error: e });
596
624
  return;
597
625
  }
598
626
  this.sendViaSmtpDirect(accountId, account, rawMessage)
599
627
  .then((result) => {
600
628
  console.log(`[send] ${accountId}: sent via SMTP (${result.accepted.length} accepted, ${result.rejected.length} rejected)`);
601
- this.db.completeSyncActionByUid(accountId, "send", queueUid);
629
+ finishSend(true);
602
630
  emitEvent({ type: "sendComplete", accountId });
603
631
  })
604
632
  .catch((e) => {
605
633
  console.error(`[send] ${accountId}: SMTP send failed: ${e.message}`);
606
- this.db.failSyncActionByUid(accountId, "send", queueUid, e.message || String(e));
634
+ finishSend(false, e.message || String(e));
607
635
  emitEvent({ type: "sendError", accountId, error: e.message });
608
636
  });
609
637
  }
@@ -1039,11 +1067,17 @@ export async function initAndroid() {
1039
1067
  }
1040
1068
  syncManager.syncAll().catch(e => console.error(`[android] Periodic sync error: ${e.message}`));
1041
1069
  }, SYNC_INTERVAL_MS);
1042
- // Immediate sync when app comes back to foreground (e.g. user switches from
1043
- // another app). Without this, new messages wait up to 2 minutes after resume.
1070
+ // Immediate sync + send-queue drain when app comes back to foreground
1071
+ // (e.g. user switches from another app). Without the send-queue drain,
1072
+ // a message queued while offline waits up to 2 minutes after resume
1073
+ // before retrying — long enough for the user to think it's stuck.
1044
1074
  document.addEventListener("visibilitychange", () => {
1045
1075
  if (document.visibilityState === "visible") {
1046
1076
  console.log("[sync] resume poll");
1077
+ for (const account of db.getAccounts()) {
1078
+ syncManager.processSendQueue(account.id)
1079
+ .catch(e => console.error(`[android] resume send-drain ${account.id}: ${e.message}`));
1080
+ }
1047
1081
  syncManager.syncAll().catch(e => console.error(`[android] Resume sync error: ${e.message}`));
1048
1082
  }
1049
1083
  });
@@ -30,7 +30,7 @@ export interface WebSyncManager {
30
30
  }[], targetFolderId: number): Promise<void>;
31
31
  moveMessageCrossAccount(accountId: string, uid: number, folderId: number, targetAccountId: string, targetFolderId: number): Promise<void>;
32
32
  undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void>;
33
- queueOutgoingLocal(accountId: string, rawMessage: string): void;
33
+ queueOutgoingLocal(accountId: string, rawMessage: string): void | Promise<void>;
34
34
  saveDraft(accountId: string, raw: string, previousDraftUid?: number, draftId?: string): Promise<number | null>;
35
35
  deleteDraft(accountId: string, draftUid: number): Promise<void>;
36
36
  reauthenticate(accountId: string): Promise<boolean>;
@@ -394,7 +394,12 @@ export class WebMailxService {
394
394
  ].join("\r\n");
395
395
  rawMessage = `${headers}\r\n\r\n${textEncoded}`;
396
396
  }
397
- this.syncManager.queueOutgoingLocal(account.id, rawMessage);
397
+ // queueOutgoingLocal on the Android bridge is async (it flushes
398
+ // sql.js → IndexedDB before returning so a tab-close in the
399
+ // debounce window can't lose the row). On the web-worker
400
+ // SyncManager it's synchronous and returns void; awaiting an
401
+ // undefined value is benign, so this works for both.
402
+ await this.syncManager.queueOutgoingLocal(account.id, rawMessage);
398
403
  for (const addr of msg.to)
399
404
  this.db.recordSentAddress(addr.name, addr.address);
400
405
  if (msg.cc)