@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 +0 -3
- package/bin/mailx.js +2 -52
- package/client/app.js +1 -34
- package/client/components/message-list.js +28 -4
- package/client/lib/api-client.js +0 -3
- package/client/lib/mailxapi.js +0 -3
- package/package.json +3 -3
- package/packages/mailx-service/index.d.ts +0 -10
- package/packages/mailx-service/index.js +1 -25
- package/packages/mailx-service/jsonrpc.js +0 -2
- package/packages/mailx-store-web/android-bootstrap.js +51 -17
- package/packages/mailx-store-web/web-service.d.ts +1 -1
- package/packages/mailx-store-web/web-service.js +6 -1
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"
|
|
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", "
|
|
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
|
|
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
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
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();
|
package/client/lib/api-client.js
CHANGED
|
@@ -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
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
541
|
-
// IndexedDB
|
|
542
|
-
//
|
|
543
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1043
|
-
// another app). Without
|
|
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
|
-
|
|
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)
|