@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 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/msger";
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
- <label class="mailx-modal-label">Contents (JSONC — comments and trailing commas allowed)
1441
- <textarea class="mailx-modal-input mailx-modal-textarea" id="jsonc-content" spellcheck="false"></textarea>
1442
- </label>
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
- errorEl.hidden = true;
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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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
- // - mailto: URLs open a pre-filled compose window (so the unsubscribe
247
- // reply gets sent from the correct mailx account, not the OS default
248
- // mail handler)
249
- // - https: URLs open a new tab
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 unsubUrl = msg.listUnsubscribe || "";
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 (unsubUrl) {
258
+ if (anyUrl) {
254
259
  unsubBtn.hidden = false;
255
- unsubBtn.textContent = "Unsubscribe";
256
- unsubBtn.title = unsubUrl;
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
- if (/^mailto:/i.test(unsubUrl)) {
261
- // Parse mailto:addr?subject=... and pre-fill compose
262
- const m = unsubUrl.match(/^mailto:([^?]*)(?:\?(.*))?$/i);
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 { height: 100%; overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; }
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
- // Three-way prompt built from two native dialogs: OK(save) / Cancel → then
473
- // for Cancel, ask "Really discard?" which becomes Discard / Cancel.
474
- const saveIt = confirm("Save this message as a draft?\n\nOK = Save as draft\nCancel = continue...");
475
- if (saveIt)
476
- return "save";
477
- const discard = confirm("Discard the message without saving?\n\nOK = Discard\nCancel = Keep editing");
478
- if (discard)
479
- return "discard";
480
- return "cancel";
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.