@bobfrankston/mailx 1.0.305 → 1.0.310
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 +1 -0
- package/bin/mailx.js +1 -1
- package/client/app.js +336 -7
- package/client/components/message-viewer.js +76 -18
- package/client/compose/compose.css +50 -0
- package/client/compose/compose.js +49 -11
- package/client/index.html +1 -0
- package/client/lib/api-client.js +6 -0
- package/client/lib/mailxapi.js +13 -7
- package/client/styles/components.css +115 -0
- package/package.json +6 -3
- package/packages/mailx-core/index.d.ts +3 -0
- package/packages/mailx-core/index.js +45 -7
- package/packages/mailx-host/index.d.ts +20 -0
- package/packages/mailx-host/index.js +30 -0
- package/packages/mailx-host/package.json +20 -0
- package/packages/mailx-service/index.d.ts +12 -0
- package/packages/mailx-service/index.js +98 -6
- package/packages/mailx-service/jsonrpc.js +4 -0
- package/packages/mailx-store/db.js +47 -5
- package/packages/mailx-store-web/android-bootstrap.js +91 -2
- package/packages/mailx-store-web/db.js +4 -1
- package/packages/mailx-store-web/main-thread-host.js +2 -2
- package/tempfix.cmd +77 -0
package/README.md
CHANGED
|
@@ -402,6 +402,7 @@ Use `mailx --verbose` to see logs in the terminal instead.
|
|
|
402
402
|
## Architecture
|
|
403
403
|
|
|
404
404
|
- **IPC-first** -- Default mode uses msger (Rust/WebView2) with bidirectional IPC. No TCP server, no CLOSE_WAIT zombies.
|
|
405
|
+
- **Host abstraction** -- mailx talks to the WebView host through `@bobfrankston/mailx-host`, which picks msger (default) or msgview (Electron, planned for Mac and niche Linux) at runtime. Override with `MAILX_HOST=msger|msgview`.
|
|
405
406
|
- **HTTP fallback** -- `--server` flag starts Express for browser/remote access.
|
|
406
407
|
- **Local-first** -- Changes update local DB immediately; background worker syncs to IMAP.
|
|
407
408
|
- **Offline reading** -- Full message bodies cached as .eml files.
|
package/bin/mailx.js
CHANGED
|
@@ -21,7 +21,7 @@ import path from "node:path";
|
|
|
21
21
|
import os from "node:os";
|
|
22
22
|
import net from "node:net";
|
|
23
23
|
import { ports } from "@bobfrankston/miscinfo";
|
|
24
|
-
import { showMessageBox, showService, setAppName } from "@bobfrankston/
|
|
24
|
+
import { showMessageBox, showService, setAppName } from "@bobfrankston/mailx-host";
|
|
25
25
|
setAppName("mailx");
|
|
26
26
|
const PORT = ports.mailx;
|
|
27
27
|
const args = process.argv.slice(2);
|
package/client/app.js
CHANGED
|
@@ -1422,7 +1422,7 @@ document.getElementById("btn-edit-jsonc")?.addEventListener("click", async () =>
|
|
|
1422
1422
|
await openJsoncEditor("accounts.jsonc");
|
|
1423
1423
|
});
|
|
1424
1424
|
async function openJsoncEditor(initialFile) {
|
|
1425
|
-
const { readJsoncFile, writeJsoncFile } = await import("./lib/api-client.js");
|
|
1425
|
+
const { readJsoncFile, writeJsoncFile, readConfigHelp } = await import("./lib/api-client.js");
|
|
1426
1426
|
const backdrop = document.createElement("div");
|
|
1427
1427
|
backdrop.className = "mailx-modal-backdrop";
|
|
1428
1428
|
const panel = document.createElement("div");
|
|
@@ -1437,9 +1437,17 @@ async function openJsoncEditor(initialFile) {
|
|
|
1437
1437
|
<option value="config.jsonc">config.jsonc — local per-machine overrides (not synced)</option>
|
|
1438
1438
|
</select>
|
|
1439
1439
|
</label>
|
|
1440
|
-
<
|
|
1441
|
-
<
|
|
1442
|
-
|
|
1440
|
+
<div class="mailx-modal-split">
|
|
1441
|
+
<label class="mailx-modal-label mailx-modal-split-left">Contents (JSONC — comments and trailing commas allowed)
|
|
1442
|
+
<textarea class="mailx-modal-input mailx-modal-textarea" id="jsonc-content" spellcheck="false"></textarea>
|
|
1443
|
+
</label>
|
|
1444
|
+
<div class="mailx-modal-split-right mailx-help-panel">
|
|
1445
|
+
<div class="mailx-help-title">
|
|
1446
|
+
<button type="button" class="mailx-help-toggle" id="jsonc-help-toggle" aria-expanded="true" title="Hide/show help">▾ Help</button>
|
|
1447
|
+
</div>
|
|
1448
|
+
<div class="mailx-help-body" id="jsonc-help-body"></div>
|
|
1449
|
+
</div>
|
|
1450
|
+
</div>
|
|
1443
1451
|
<div class="mailx-modal-error" id="jsonc-error" hidden></div>
|
|
1444
1452
|
<div class="mailx-modal-buttons">
|
|
1445
1453
|
<span class="mailx-modal-spacer"></span>
|
|
@@ -1451,13 +1459,64 @@ async function openJsoncEditor(initialFile) {
|
|
|
1451
1459
|
const fileSelect = panel.querySelector("#jsonc-file");
|
|
1452
1460
|
const textarea = panel.querySelector("#jsonc-content");
|
|
1453
1461
|
const errorEl = panel.querySelector("#jsonc-error");
|
|
1462
|
+
const saveBtn = panel.querySelector('[data-action="save"]');
|
|
1463
|
+
const helpBody = panel.querySelector("#jsonc-help-body");
|
|
1464
|
+
const helpToggle = panel.querySelector("#jsonc-help-toggle");
|
|
1465
|
+
const helpPanel = panel.querySelector(".mailx-help-panel");
|
|
1454
1466
|
fileSelect.value = initialFile;
|
|
1467
|
+
helpToggle.addEventListener("click", () => {
|
|
1468
|
+
const open = helpPanel.classList.toggle("mailx-help-collapsed");
|
|
1469
|
+
helpToggle.textContent = open ? "▸ Help" : "▾ Help";
|
|
1470
|
+
helpToggle.setAttribute("aria-expanded", open ? "false" : "true");
|
|
1471
|
+
});
|
|
1472
|
+
const loadHelp = async () => {
|
|
1473
|
+
helpBody.textContent = "Loading help…";
|
|
1474
|
+
try {
|
|
1475
|
+
const r = await readConfigHelp(fileSelect.value);
|
|
1476
|
+
const md = (r?.content || "").trim();
|
|
1477
|
+
helpBody.innerHTML = md ? renderMarkdown(md) : "<em>No help available for this file.</em>";
|
|
1478
|
+
}
|
|
1479
|
+
catch (e) {
|
|
1480
|
+
helpBody.textContent = `Help unavailable: ${e.message}`;
|
|
1481
|
+
}
|
|
1482
|
+
};
|
|
1483
|
+
const clearValidation = () => {
|
|
1484
|
+
errorEl.hidden = true;
|
|
1485
|
+
errorEl.textContent = "";
|
|
1486
|
+
textarea.classList.remove("mailx-modal-input-error");
|
|
1487
|
+
saveBtn.disabled = false;
|
|
1488
|
+
};
|
|
1489
|
+
const showValidation = (err) => {
|
|
1490
|
+
errorEl.textContent = `Line ${err.line}, col ${err.col}: ${err.message}`;
|
|
1491
|
+
errorEl.hidden = false;
|
|
1492
|
+
textarea.classList.add("mailx-modal-input-error");
|
|
1493
|
+
saveBtn.disabled = true;
|
|
1494
|
+
// Select the problem character so the browser draws a visible marker
|
|
1495
|
+
try {
|
|
1496
|
+
textarea.setSelectionRange(err.pos, err.pos + 1);
|
|
1497
|
+
}
|
|
1498
|
+
catch { /* out-of-range → ignore */ }
|
|
1499
|
+
};
|
|
1500
|
+
let validateTimer;
|
|
1501
|
+
const scheduleValidate = () => {
|
|
1502
|
+
if (validateTimer)
|
|
1503
|
+
window.clearTimeout(validateTimer);
|
|
1504
|
+
validateTimer = window.setTimeout(() => {
|
|
1505
|
+
const err = validateJsonc(textarea.value);
|
|
1506
|
+
if (err)
|
|
1507
|
+
showValidation(err);
|
|
1508
|
+
else
|
|
1509
|
+
clearValidation();
|
|
1510
|
+
}, 600);
|
|
1511
|
+
};
|
|
1512
|
+
textarea.addEventListener("input", scheduleValidate);
|
|
1455
1513
|
const loadFile = async () => {
|
|
1456
1514
|
textarea.value = "Loading...";
|
|
1457
|
-
|
|
1515
|
+
clearValidation();
|
|
1458
1516
|
try {
|
|
1459
1517
|
const r = await readJsoncFile(fileSelect.value);
|
|
1460
1518
|
textarea.value = r?.content || "";
|
|
1519
|
+
scheduleValidate();
|
|
1461
1520
|
}
|
|
1462
1521
|
catch (e) {
|
|
1463
1522
|
textarea.value = "";
|
|
@@ -1465,9 +1524,11 @@ async function openJsoncEditor(initialFile) {
|
|
|
1465
1524
|
errorEl.hidden = false;
|
|
1466
1525
|
}
|
|
1467
1526
|
};
|
|
1468
|
-
await loadFile();
|
|
1469
|
-
fileSelect.addEventListener("change", loadFile);
|
|
1527
|
+
await Promise.all([loadFile(), loadHelp()]);
|
|
1528
|
+
fileSelect.addEventListener("change", () => { loadFile(); loadHelp(); });
|
|
1470
1529
|
const close = () => {
|
|
1530
|
+
if (validateTimer)
|
|
1531
|
+
window.clearTimeout(validateTimer);
|
|
1471
1532
|
backdrop.remove();
|
|
1472
1533
|
document.removeEventListener("keydown", onKey, true);
|
|
1473
1534
|
};
|
|
@@ -1487,6 +1548,12 @@ async function openJsoncEditor(initialFile) {
|
|
|
1487
1548
|
return;
|
|
1488
1549
|
}
|
|
1489
1550
|
if (action === "save") {
|
|
1551
|
+
// Final sync-check; refuse to save if it doesn't parse
|
|
1552
|
+
const err = validateJsonc(textarea.value);
|
|
1553
|
+
if (err) {
|
|
1554
|
+
showValidation(err);
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1490
1557
|
errorEl.hidden = true;
|
|
1491
1558
|
btn.disabled = true;
|
|
1492
1559
|
btn.textContent = "Saving...";
|
|
@@ -1509,6 +1576,268 @@ async function openJsoncEditor(initialFile) {
|
|
|
1509
1576
|
backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
|
|
1510
1577
|
close(); });
|
|
1511
1578
|
}
|
|
1579
|
+
// JSONC validator — strips comments + trailing commas (preserving source positions
|
|
1580
|
+
// by replacing stripped chars with spaces/newlines) and runs JSON.parse. Reports
|
|
1581
|
+
// only the *first* error; cascading errors are suppressed.
|
|
1582
|
+
function validateJsonc(src) {
|
|
1583
|
+
const stripped = stripJsoncPreservingPositions(src);
|
|
1584
|
+
if (stripped.error) {
|
|
1585
|
+
const { pos, line, col } = offsetToLineCol(src, stripped.error.pos);
|
|
1586
|
+
return { message: stripped.error.message, pos, line, col };
|
|
1587
|
+
}
|
|
1588
|
+
if (stripped.text.trim() === "")
|
|
1589
|
+
return null; // empty file: treat as valid (settings code handles)
|
|
1590
|
+
try {
|
|
1591
|
+
JSON.parse(stripped.text);
|
|
1592
|
+
return null;
|
|
1593
|
+
}
|
|
1594
|
+
catch (e) {
|
|
1595
|
+
const msg = String(e?.message || "parse error");
|
|
1596
|
+
const m = msg.match(/at position (\d+)/i);
|
|
1597
|
+
const pos = m ? Math.min(parseInt(m[1], 10), src.length - 1) : 0;
|
|
1598
|
+
const lc = offsetToLineCol(src, pos);
|
|
1599
|
+
return { message: msg.replace(/\s*at position \d+/i, ""), pos: lc.pos, line: lc.line, col: lc.col };
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
function stripJsoncPreservingPositions(src) {
|
|
1603
|
+
const out = new Array(src.length);
|
|
1604
|
+
let i = 0;
|
|
1605
|
+
const n = src.length;
|
|
1606
|
+
while (i < n) {
|
|
1607
|
+
const c = src[i];
|
|
1608
|
+
const next = src[i + 1];
|
|
1609
|
+
if (c === '"') {
|
|
1610
|
+
out[i] = c;
|
|
1611
|
+
i++;
|
|
1612
|
+
while (i < n) {
|
|
1613
|
+
const ch = src[i];
|
|
1614
|
+
out[i] = ch;
|
|
1615
|
+
i++;
|
|
1616
|
+
if (ch === "\\" && i < n) {
|
|
1617
|
+
out[i] = src[i];
|
|
1618
|
+
i++;
|
|
1619
|
+
continue;
|
|
1620
|
+
}
|
|
1621
|
+
if (ch === '"')
|
|
1622
|
+
break;
|
|
1623
|
+
if (ch === "\n")
|
|
1624
|
+
return { text: out.join(""), error: { message: "unterminated string", pos: i - 1 } };
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
else if (c === "/" && next === "/") {
|
|
1628
|
+
while (i < n && src[i] !== "\n") {
|
|
1629
|
+
out[i] = " ";
|
|
1630
|
+
i++;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
else if (c === "/" && next === "*") {
|
|
1634
|
+
const start = i;
|
|
1635
|
+
out[i] = " ";
|
|
1636
|
+
out[i + 1] = " ";
|
|
1637
|
+
i += 2;
|
|
1638
|
+
let closed = false;
|
|
1639
|
+
while (i < n) {
|
|
1640
|
+
if (src[i] === "*" && src[i + 1] === "/") {
|
|
1641
|
+
out[i] = " ";
|
|
1642
|
+
out[i + 1] = " ";
|
|
1643
|
+
i += 2;
|
|
1644
|
+
closed = true;
|
|
1645
|
+
break;
|
|
1646
|
+
}
|
|
1647
|
+
out[i] = src[i] === "\n" ? "\n" : " ";
|
|
1648
|
+
i++;
|
|
1649
|
+
}
|
|
1650
|
+
if (!closed)
|
|
1651
|
+
return { text: out.join(""), error: { message: "unterminated block comment", pos: start } };
|
|
1652
|
+
}
|
|
1653
|
+
else if (c === ",") {
|
|
1654
|
+
// trailing comma before } or ] → replace with space
|
|
1655
|
+
let j = i + 1;
|
|
1656
|
+
while (j < n && /\s/.test(src[j]))
|
|
1657
|
+
j++;
|
|
1658
|
+
if (j < n && (src[j] === "}" || src[j] === "]")) {
|
|
1659
|
+
out[i] = " ";
|
|
1660
|
+
i++;
|
|
1661
|
+
}
|
|
1662
|
+
else {
|
|
1663
|
+
out[i] = c;
|
|
1664
|
+
i++;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
else {
|
|
1668
|
+
out[i] = c;
|
|
1669
|
+
i++;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
return { text: out.join("") };
|
|
1673
|
+
}
|
|
1674
|
+
function offsetToLineCol(src, pos) {
|
|
1675
|
+
pos = Math.max(0, Math.min(pos, src.length));
|
|
1676
|
+
let line = 1, col = 1;
|
|
1677
|
+
for (let i = 0; i < pos; i++) {
|
|
1678
|
+
if (src[i] === "\n") {
|
|
1679
|
+
line++;
|
|
1680
|
+
col = 1;
|
|
1681
|
+
}
|
|
1682
|
+
else
|
|
1683
|
+
col++;
|
|
1684
|
+
}
|
|
1685
|
+
return { pos, line, col };
|
|
1686
|
+
}
|
|
1687
|
+
// Minimal markdown renderer for the help panel. Supports: headings, fenced code blocks,
|
|
1688
|
+
// inline code, bold, italic, links, bullet lists, paragraphs. HTML is escaped first.
|
|
1689
|
+
function renderMarkdown(md) {
|
|
1690
|
+
const esc = (s) => s.replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
1691
|
+
// Pull fenced code blocks out first so their contents aren't processed as markdown.
|
|
1692
|
+
const blocks = [];
|
|
1693
|
+
let src = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
|
1694
|
+
const i = blocks.length;
|
|
1695
|
+
blocks.push(`<pre class="mailx-help-code"><code>${esc(code)}</code></pre>`);
|
|
1696
|
+
return `\u0000BLOCK${i}\u0000`;
|
|
1697
|
+
});
|
|
1698
|
+
const lines = src.split(/\r?\n/);
|
|
1699
|
+
const out = [];
|
|
1700
|
+
let inList = false;
|
|
1701
|
+
let para = [];
|
|
1702
|
+
const flushPara = () => {
|
|
1703
|
+
if (para.length) {
|
|
1704
|
+
out.push(`<p>${inline(para.join(" "))}</p>`);
|
|
1705
|
+
para = [];
|
|
1706
|
+
}
|
|
1707
|
+
};
|
|
1708
|
+
const closeList = () => { if (inList) {
|
|
1709
|
+
out.push("</ul>");
|
|
1710
|
+
inList = false;
|
|
1711
|
+
} };
|
|
1712
|
+
function inline(s) {
|
|
1713
|
+
s = esc(s);
|
|
1714
|
+
s = s.replace(/`([^`]+)`/g, (_m, c) => `<code>${c}</code>`);
|
|
1715
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
1716
|
+
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
|
|
1717
|
+
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
1718
|
+
return s;
|
|
1719
|
+
}
|
|
1720
|
+
for (const raw of lines) {
|
|
1721
|
+
const blockMatch = /^\u0000BLOCK(\d+)\u0000$/.exec(raw);
|
|
1722
|
+
if (blockMatch) {
|
|
1723
|
+
flushPara();
|
|
1724
|
+
closeList();
|
|
1725
|
+
out.push(blocks[parseInt(blockMatch[1], 10)]);
|
|
1726
|
+
continue;
|
|
1727
|
+
}
|
|
1728
|
+
const h = /^(#{1,6})\s+(.+)$/.exec(raw);
|
|
1729
|
+
if (h) {
|
|
1730
|
+
flushPara();
|
|
1731
|
+
closeList();
|
|
1732
|
+
const lvl = h[1].length;
|
|
1733
|
+
out.push(`<h${lvl}>${inline(h[2])}</h${lvl}>`);
|
|
1734
|
+
continue;
|
|
1735
|
+
}
|
|
1736
|
+
const bullet = /^\s*[-*]\s+(.+)$/.exec(raw);
|
|
1737
|
+
if (bullet) {
|
|
1738
|
+
flushPara();
|
|
1739
|
+
if (!inList) {
|
|
1740
|
+
out.push("<ul>");
|
|
1741
|
+
inList = true;
|
|
1742
|
+
}
|
|
1743
|
+
out.push(`<li>${inline(bullet[1])}</li>`);
|
|
1744
|
+
continue;
|
|
1745
|
+
}
|
|
1746
|
+
if (raw.trim() === "") {
|
|
1747
|
+
flushPara();
|
|
1748
|
+
closeList();
|
|
1749
|
+
continue;
|
|
1750
|
+
}
|
|
1751
|
+
para.push(raw);
|
|
1752
|
+
}
|
|
1753
|
+
flushPara();
|
|
1754
|
+
closeList();
|
|
1755
|
+
return out.join("\n");
|
|
1756
|
+
}
|
|
1757
|
+
// ── About dialog ──
|
|
1758
|
+
document.getElementById("btn-about")?.addEventListener("click", () => {
|
|
1759
|
+
const settingsDropdown = document.getElementById("settings-dropdown");
|
|
1760
|
+
if (settingsDropdown)
|
|
1761
|
+
settingsDropdown.hidden = true;
|
|
1762
|
+
openAboutDialog();
|
|
1763
|
+
});
|
|
1764
|
+
// Clicking the version string (toolbar in wide mode, status bar in narrow mode) also opens About
|
|
1765
|
+
document.querySelectorAll(".app-version").forEach(el => {
|
|
1766
|
+
el.style.cursor = "pointer";
|
|
1767
|
+
el.addEventListener("click", openAboutDialog);
|
|
1768
|
+
});
|
|
1769
|
+
async function openAboutDialog() {
|
|
1770
|
+
const backdrop = document.createElement("div");
|
|
1771
|
+
backdrop.className = "mailx-modal-backdrop";
|
|
1772
|
+
const panel = document.createElement("div");
|
|
1773
|
+
panel.className = "mailx-modal";
|
|
1774
|
+
panel.innerHTML = `
|
|
1775
|
+
<div class="mailx-modal-title">About mailx</div>
|
|
1776
|
+
<div class="mailx-about" id="about-body">Loading...</div>
|
|
1777
|
+
<div class="mailx-modal-buttons">
|
|
1778
|
+
<span class="mailx-modal-spacer"></span>
|
|
1779
|
+
<button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="close">Close</button>
|
|
1780
|
+
</div>`;
|
|
1781
|
+
backdrop.appendChild(panel);
|
|
1782
|
+
document.body.appendChild(panel.parentElement);
|
|
1783
|
+
const body = panel.querySelector("#about-body");
|
|
1784
|
+
const close = () => {
|
|
1785
|
+
backdrop.remove();
|
|
1786
|
+
document.removeEventListener("keydown", onKey, true);
|
|
1787
|
+
};
|
|
1788
|
+
const onKey = (e) => {
|
|
1789
|
+
if (e.key === "Escape") {
|
|
1790
|
+
e.stopPropagation();
|
|
1791
|
+
e.preventDefault();
|
|
1792
|
+
close();
|
|
1793
|
+
}
|
|
1794
|
+
};
|
|
1795
|
+
document.addEventListener("keydown", onKey, true);
|
|
1796
|
+
panel.querySelector('[data-action="close"]')
|
|
1797
|
+
.addEventListener("click", close);
|
|
1798
|
+
backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
|
|
1799
|
+
close(); });
|
|
1800
|
+
try {
|
|
1801
|
+
const [v, accounts] = await Promise.all([
|
|
1802
|
+
getVersion().catch(() => ({})),
|
|
1803
|
+
getAccounts().catch(() => []),
|
|
1804
|
+
]);
|
|
1805
|
+
const storage = v.storage || {};
|
|
1806
|
+
const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
|
|
1807
|
+
const platform = isApp ? (mailxapi?.platform || "app") : "browser";
|
|
1808
|
+
const rows = [
|
|
1809
|
+
["Version", v.version ? `v${v.version}` : "unknown"],
|
|
1810
|
+
["Platform", platform],
|
|
1811
|
+
["Storage", storage.provider || "local"],
|
|
1812
|
+
];
|
|
1813
|
+
if (storage.cloudPath)
|
|
1814
|
+
rows.push(["Cloud path", `My Drive/${storage.cloudPath}/`]);
|
|
1815
|
+
if (storage.mode)
|
|
1816
|
+
rows.push(["Storage mode", storage.mode]);
|
|
1817
|
+
rows.push(["Accounts", String((accounts || []).length)]);
|
|
1818
|
+
rows.push(["User agent", navigator.userAgent]);
|
|
1819
|
+
rows.push(["Screen", `${screen.width}×${screen.height}`]);
|
|
1820
|
+
rows.push(["Window", `${window.innerWidth}×${window.innerHeight}`]);
|
|
1821
|
+
body.innerHTML = `
|
|
1822
|
+
<dl class="mailx-about-dl">
|
|
1823
|
+
${rows.map(([k, val]) => `<dt>${k}</dt><dd>${escapeHtml(val)}</dd>`).join("")}
|
|
1824
|
+
</dl>
|
|
1825
|
+
${(accounts || []).length ? `
|
|
1826
|
+
<div class="mailx-about-accounts">
|
|
1827
|
+
<div class="mailx-about-section">Accounts</div>
|
|
1828
|
+
<ul>
|
|
1829
|
+
${accounts.map(a => `<li>${escapeHtml(a.email || a.id)}${a.name ? ` — ${escapeHtml(a.name)}` : ""}</li>`).join("")}
|
|
1830
|
+
</ul>
|
|
1831
|
+
</div>` : ""}
|
|
1832
|
+
<div class="mailx-about-foot">mailx — local-first mail client</div>`;
|
|
1833
|
+
}
|
|
1834
|
+
catch (e) {
|
|
1835
|
+
body.textContent = `Failed to load: ${e.message}`;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
function escapeHtml(s) {
|
|
1839
|
+
return String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
1840
|
+
}
|
|
1512
1841
|
// Threaded view toggle
|
|
1513
1842
|
optThreaded?.addEventListener("change", () => {
|
|
1514
1843
|
const body = document.getElementById("ml-body");
|
|
@@ -243,23 +243,55 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
243
243
|
}
|
|
244
244
|
headerEl.querySelector(".mv-date").textContent = new Date(msg.date).toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
|
|
245
245
|
// Unsubscribe button (upper right of header).
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
246
|
+
// Priority:
|
|
247
|
+
// 1. RFC 8058 one-click (HTTPS + List-Unsubscribe-Post header) — POST server-side
|
|
248
|
+
// 2. HTTPS URL — open in a new tab (two-click flow, usually a confirmation page)
|
|
249
|
+
// 3. mailto: URL — open a pre-filled compose window (so the unsubscribe
|
|
250
|
+
// reply gets sent from the correct mailx account, not the OS default
|
|
251
|
+
// mail handler)
|
|
250
252
|
const unsubBtn = document.getElementById("mv-unsubscribe");
|
|
251
|
-
const
|
|
253
|
+
const httpUrl = msg.listUnsubscribeHttp || "";
|
|
254
|
+
const mailUrl = msg.listUnsubscribeMail || "";
|
|
255
|
+
const oneClick = !!msg.listUnsubscribeOneClick;
|
|
256
|
+
const anyUrl = httpUrl || mailUrl || msg.listUnsubscribe || "";
|
|
252
257
|
if (unsubBtn) {
|
|
253
|
-
if (
|
|
258
|
+
if (anyUrl) {
|
|
254
259
|
unsubBtn.hidden = false;
|
|
255
|
-
unsubBtn.textContent = "Unsubscribe";
|
|
256
|
-
unsubBtn.title =
|
|
260
|
+
unsubBtn.textContent = oneClick && httpUrl ? "Unsubscribe (1-click)" : "Unsubscribe";
|
|
261
|
+
unsubBtn.title = anyUrl;
|
|
257
262
|
unsubBtn.href = "#";
|
|
258
|
-
unsubBtn.onclick = (e) => {
|
|
263
|
+
unsubBtn.onclick = async (e) => {
|
|
259
264
|
e.preventDefault();
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
265
|
+
const status = document.getElementById("status-sync");
|
|
266
|
+
if (oneClick && httpUrl) {
|
|
267
|
+
unsubBtn.textContent = "Unsubscribing…";
|
|
268
|
+
try {
|
|
269
|
+
const { unsubscribeOneClick } = await import("../lib/api-client.js");
|
|
270
|
+
const r = await unsubscribeOneClick(httpUrl);
|
|
271
|
+
if (r?.ok) {
|
|
272
|
+
unsubBtn.textContent = "Unsubscribed";
|
|
273
|
+
if (status)
|
|
274
|
+
status.textContent = `Unsubscribed (HTTP ${r.status})`;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
unsubBtn.textContent = `Failed: HTTP ${r?.status ?? "?"}`;
|
|
278
|
+
if (status)
|
|
279
|
+
status.textContent = `Unsubscribe failed: ${r?.status} ${r?.statusText || ""}`.trim();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
unsubBtn.textContent = "Unsubscribe failed";
|
|
284
|
+
if (status)
|
|
285
|
+
status.textContent = `Unsubscribe error: ${err.message}`;
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (httpUrl) {
|
|
290
|
+
window.open(httpUrl, "_blank");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (mailUrl) {
|
|
294
|
+
const m = mailUrl.match(/^mailto:([^?]*)(?:\?(.*))?$/i);
|
|
263
295
|
const to = m?.[1] ? decodeURIComponent(m[1]) : "";
|
|
264
296
|
const qs = new URLSearchParams(m?.[2] || "");
|
|
265
297
|
const subject = qs.get("subject") || "Unsubscribe";
|
|
@@ -278,9 +310,6 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
278
310
|
sessionStorage.setItem("composeInit", JSON.stringify(init));
|
|
279
311
|
document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "new" } }));
|
|
280
312
|
}
|
|
281
|
-
else {
|
|
282
|
-
window.open(unsubUrl, "_blank");
|
|
283
|
-
}
|
|
284
313
|
};
|
|
285
314
|
}
|
|
286
315
|
else {
|
|
@@ -620,7 +649,8 @@ function wrapHtmlBody(html, allowRemote = false) {
|
|
|
620
649
|
<meta charset="UTF-8">
|
|
621
650
|
${csp}
|
|
622
651
|
<style>
|
|
623
|
-
html {
|
|
652
|
+
html, body { touch-action: pan-y pinch-zoom; }
|
|
653
|
+
html { height: 100%; overflow-y: auto; overflow-x: hidden; }
|
|
624
654
|
body {
|
|
625
655
|
font-family: system-ui, sans-serif;
|
|
626
656
|
font-size: 17.5px;
|
|
@@ -659,15 +689,43 @@ ${csp}
|
|
|
659
689
|
window.parent.postMessage({ type: "linkClick", url: url }, "*");
|
|
660
690
|
}
|
|
661
691
|
document.addEventListener("click", handleLinkTap, true);
|
|
692
|
+
// Android WebView fallback: some builds drop the synthetic click after
|
|
693
|
+
// touchend, so treat a stationary touchstart→touchend on the same link
|
|
694
|
+
// as a tap. Anything that moves more than TAP_SLOP pixels is a scroll
|
|
695
|
+
// and must NOT activate the link.
|
|
696
|
+
var TAP_SLOP = 10;
|
|
662
697
|
var lastTouchTarget = null;
|
|
698
|
+
var lastTouchX = 0;
|
|
699
|
+
var lastTouchY = 0;
|
|
700
|
+
var touchMoved = false;
|
|
701
|
+
// All touch listeners are passive so Android WebView can compositor-scroll
|
|
702
|
+
// the iframe without waiting on our JS. handleLinkTap's preventDefault only
|
|
703
|
+
// matters for the "click" path (which is non-passive by default).
|
|
663
704
|
document.addEventListener("touchstart", function (e) {
|
|
705
|
+
var t0 = e.touches && e.touches[0];
|
|
706
|
+
lastTouchX = t0 ? t0.clientX : 0;
|
|
707
|
+
lastTouchY = t0 ? t0.clientY : 0;
|
|
708
|
+
touchMoved = false;
|
|
664
709
|
lastTouchTarget = (e.target && e.target.closest) ? e.target.closest("a[href]") || e.target : e.target;
|
|
665
|
-
}, true);
|
|
710
|
+
}, { passive: true, capture: true });
|
|
711
|
+
document.addEventListener("touchmove", function (e) {
|
|
712
|
+
if (touchMoved) return;
|
|
713
|
+
var t = e.touches && e.touches[0];
|
|
714
|
+
if (!t) return;
|
|
715
|
+
if (Math.abs(t.clientX - lastTouchX) > TAP_SLOP || Math.abs(t.clientY - lastTouchY) > TAP_SLOP) {
|
|
716
|
+
touchMoved = true;
|
|
717
|
+
lastTouchTarget = null;
|
|
718
|
+
}
|
|
719
|
+
}, { passive: true, capture: true });
|
|
666
720
|
document.addEventListener("touchend", function (e) {
|
|
721
|
+
if (touchMoved) { lastTouchTarget = null; touchMoved = false; return; }
|
|
667
722
|
var t = (e.target && e.target.closest) ? e.target.closest("a[href]") || e.target : e.target;
|
|
668
723
|
if (lastTouchTarget && lastTouchTarget === t) handleLinkTap(e);
|
|
669
724
|
lastTouchTarget = null;
|
|
670
|
-
}, true);
|
|
725
|
+
}, { passive: true, capture: true });
|
|
726
|
+
document.addEventListener("touchcancel", function () {
|
|
727
|
+
lastTouchTarget = null; touchMoved = false;
|
|
728
|
+
}, { passive: true, capture: true });
|
|
671
729
|
document.addEventListener("mouseover", function (e) {
|
|
672
730
|
var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
|
|
673
731
|
if (a) {
|
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
/* Compose window styles */
|
|
2
2
|
|
|
3
|
+
.compose-modal-overlay {
|
|
4
|
+
position: fixed;
|
|
5
|
+
inset: 0;
|
|
6
|
+
background: rgba(0, 0, 0, 0.4);
|
|
7
|
+
display: flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
justify-content: center;
|
|
10
|
+
z-index: 9999;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.compose-modal {
|
|
14
|
+
background: var(--color-bg, #fff);
|
|
15
|
+
border-radius: 6px;
|
|
16
|
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
|
|
17
|
+
padding: 20px 24px;
|
|
18
|
+
min-width: 320px;
|
|
19
|
+
max-width: 480px;
|
|
20
|
+
|
|
21
|
+
& .compose-modal-msg {
|
|
22
|
+
margin-bottom: 18px;
|
|
23
|
+
font-size: 14px;
|
|
24
|
+
color: var(--color-text, #222);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
& .compose-modal-buttons {
|
|
28
|
+
display: flex;
|
|
29
|
+
justify-content: flex-end;
|
|
30
|
+
gap: 8px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
& .compose-modal-btn {
|
|
34
|
+
padding: 6px 14px;
|
|
35
|
+
border: 1px solid var(--color-border, #ccc);
|
|
36
|
+
background: var(--color-bg-surface, #f6f6f6);
|
|
37
|
+
border-radius: 4px;
|
|
38
|
+
font: inherit;
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
|
|
41
|
+
&:hover { background: var(--color-bg-hover, #ececec); }
|
|
42
|
+
|
|
43
|
+
&.primary {
|
|
44
|
+
background: #0b6bcb;
|
|
45
|
+
color: #fff;
|
|
46
|
+
border-color: #0b6bcb;
|
|
47
|
+
|
|
48
|
+
&:hover { background: #095aa8; }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
3
53
|
body {
|
|
4
54
|
display: flex;
|
|
5
55
|
flex-direction: column;
|
|
@@ -467,17 +467,55 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
|
467
467
|
function composeHasContent() {
|
|
468
468
|
return !!(editor.getText().trim() || toInput.value.trim() || ccInput.value.trim() || bccInput.value.trim() || subjectInput.value.trim());
|
|
469
469
|
}
|
|
470
|
-
/** Ask Save/Discard/Cancel. Returns "save" | "discard" | "cancel".
|
|
470
|
+
/** Ask Save/Discard/Cancel. Returns "save" | "discard" | "cancel".
|
|
471
|
+
* Uses an in-page modal so all three choices are presented at once — the
|
|
472
|
+
* native confirm() flow forced the user through two sequential dialogs and
|
|
473
|
+
* hid Discard behind a Cancel click, which was confusing. */
|
|
471
474
|
function promptSaveOrDiscard() {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
475
|
+
return new Promise(resolve => {
|
|
476
|
+
const overlay = document.createElement("div");
|
|
477
|
+
overlay.className = "compose-modal-overlay";
|
|
478
|
+
const box = document.createElement("div");
|
|
479
|
+
box.className = "compose-modal";
|
|
480
|
+
const msg = document.createElement("div");
|
|
481
|
+
msg.className = "compose-modal-msg";
|
|
482
|
+
msg.textContent = "Save this message as a draft?";
|
|
483
|
+
const btnRow = document.createElement("div");
|
|
484
|
+
btnRow.className = "compose-modal-buttons";
|
|
485
|
+
const mkBtn = (label, choice, primary) => {
|
|
486
|
+
const b = document.createElement("button");
|
|
487
|
+
b.type = "button";
|
|
488
|
+
b.textContent = label;
|
|
489
|
+
b.className = primary ? "compose-modal-btn primary" : "compose-modal-btn";
|
|
490
|
+
b.addEventListener("click", () => { cleanup(); resolve(choice); });
|
|
491
|
+
return b;
|
|
492
|
+
};
|
|
493
|
+
const cleanup = () => {
|
|
494
|
+
document.removeEventListener("keydown", onKey);
|
|
495
|
+
overlay.remove();
|
|
496
|
+
};
|
|
497
|
+
const onKey = (e) => {
|
|
498
|
+
if (e.key === "Escape") {
|
|
499
|
+
e.preventDefault();
|
|
500
|
+
cleanup();
|
|
501
|
+
resolve("cancel");
|
|
502
|
+
}
|
|
503
|
+
else if (e.key === "Enter") {
|
|
504
|
+
e.preventDefault();
|
|
505
|
+
cleanup();
|
|
506
|
+
resolve("save");
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
document.addEventListener("keydown", onKey);
|
|
510
|
+
btnRow.appendChild(mkBtn("Save draft", "save", true));
|
|
511
|
+
btnRow.appendChild(mkBtn("Discard", "discard", false));
|
|
512
|
+
btnRow.appendChild(mkBtn("Cancel", "cancel", false));
|
|
513
|
+
box.appendChild(msg);
|
|
514
|
+
box.appendChild(btnRow);
|
|
515
|
+
overlay.appendChild(box);
|
|
516
|
+
document.body.appendChild(overlay);
|
|
517
|
+
btnRow.firstChild.focus();
|
|
518
|
+
});
|
|
481
519
|
}
|
|
482
520
|
/** Handle any "close the compose" action (Discard button, Escape, X, window close). */
|
|
483
521
|
async function handleCloseRequest() {
|
|
@@ -485,7 +523,7 @@ async function handleCloseRequest() {
|
|
|
485
523
|
closeCompose();
|
|
486
524
|
return true;
|
|
487
525
|
}
|
|
488
|
-
const choice = promptSaveOrDiscard();
|
|
526
|
+
const choice = await promptSaveOrDiscard();
|
|
489
527
|
if (choice === "cancel")
|
|
490
528
|
return false;
|
|
491
529
|
// Stop auto-save so it can't race with our explicit save/discard.
|