@bobfrankston/mailx 1.0.306 → 1.0.313

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/client/app.js CHANGED
@@ -415,6 +415,28 @@ document.getElementById("btn-factory-reset")?.addEventListener("click", async ()
415
415
  });
416
416
  async function openCompose(mode) {
417
417
  const current = getCurrentMessage();
418
+ // Reply / Reply-All / Forward all need an original message to populate
419
+ // From, To, Subject, and the quoted body. Two failure modes used to
420
+ // silently produce a blank compose:
421
+ // (1) getCurrentMessage() returns null — viewer still loading, message
422
+ // cleared mid-folder-switch, or fetch failed.
423
+ // (2) currentMessage is set but is a stub — header metadata arrived
424
+ // but body / from / subject haven't been populated yet.
425
+ // Bail out in both cases instead of opening an empty form.
426
+ if (mode === "reply" || mode === "replyAll" || mode === "forward") {
427
+ const m = current?.message;
428
+ const stubReason = !current ? "no current message" :
429
+ !m?.from ? "msg.from missing" :
430
+ !m?.subject && m?.subject !== "" ? "msg.subject missing" :
431
+ (mode !== "forward" && !m?.messageId) ? "msg.messageId missing (can't thread reply)" :
432
+ null;
433
+ if (stubReason) {
434
+ console.warn(`[compose] ${mode} ignored — ${stubReason}; current=`, current);
435
+ alert(`Cannot ${mode === "forward" ? "forward" : "reply to"} this message yet — ` +
436
+ `it's still loading (${stubReason}). Please wait a moment and try again.`);
437
+ return;
438
+ }
439
+ }
418
440
  const accounts = await getAccounts();
419
441
  const accountId = current?.accountId || accounts[0]?.id || "";
420
442
  const msg = current?.message;
@@ -431,20 +453,34 @@ async function openCompose(mode) {
431
453
  references: [],
432
454
  accounts: accounts.map((a) => ({ id: a.id, name: a.name, email: a.email })),
433
455
  };
434
- // Auto-detect reply From: if the message was delivered to an identity domain,
435
- // reply from that address instead of the default account address.
436
- // Identity domains configured per-account in accounts.jsonc (identityDomains array).
456
+ // Auto-detect reply From: if the message was delivered to an identity address
457
+ // (an alias on the account's domain, or the explicit `identityDomains` list
458
+ // in accounts.jsonc), reply from that address instead of the account's
459
+ // primary. Always derive identityDomains from the account email's domain
460
+ // when not configured — explicit list was a regression source (users would
461
+ // see Reply pick the wrong From silently when the list was missing).
437
462
  const account = accounts.find((a) => a.id === accountId);
438
- const identityDomains = account?.identityDomains || [];
463
+ const explicitDomains = (account?.identityDomains || []).map((d) => d.toLowerCase());
464
+ const accountDomain = (account?.email || "").split("@")[1]?.toLowerCase();
465
+ const identityDomains = explicitDomains.length > 0
466
+ ? explicitDomains
467
+ : (accountDomain ? [accountDomain] : []);
439
468
  function detectReplyFrom() {
440
469
  if (!msg || identityDomains.length === 0)
441
470
  return undefined;
442
- const candidates = [msg.deliveredTo, ...(msg.to || []).map((a) => a.address)].filter(Boolean);
443
- console.log(`[compose] detectReplyFrom: deliveredTo=${msg.deliveredTo}, to=${msg.to?.map((a) => a.address)}, domains=${identityDomains}`);
471
+ // Prefer Delivered-To header (the address the server actually delivered
472
+ // to, which is the alias the message arrived at). Fall back to To, then
473
+ // Cc, in order. Bcc isn't visible to recipients so skipped.
474
+ const candidates = [
475
+ msg.deliveredTo,
476
+ ...((msg.to || []).map((a) => a.address)),
477
+ ...((msg.cc || []).map((a) => a.address)),
478
+ ].filter(Boolean);
479
+ console.log(`[compose] detectReplyFrom: deliveredTo=${msg.deliveredTo}, to=${msg.to?.map((a) => a.address)}, cc=${msg.cc?.map((a) => a.address)}, identityDomains=${identityDomains}, accountEmail=${account?.email}`);
444
480
  for (const addr of candidates) {
445
481
  const domain = addr.split("@")[1]?.toLowerCase();
446
- if (domain && identityDomains.some((d) => domain === d.toLowerCase())) {
447
- console.log(`[compose] matched: ${addr}`);
482
+ if (domain && identityDomains.some(d => domain === d || domain.endsWith(`.${d}`))) {
483
+ console.log(`[compose] reply From → ${addr}`);
448
484
  return addr;
449
485
  }
450
486
  }
@@ -789,6 +825,45 @@ document.getElementById("btn-compose")?.addEventListener("click", () => openComp
789
825
  document.getElementById("btn-reply")?.addEventListener("click", () => openCompose("reply"));
790
826
  document.getElementById("btn-reply-all")?.addEventListener("click", () => openCompose("replyAll"));
791
827
  document.getElementById("btn-forward")?.addEventListener("click", () => openCompose("forward"));
828
+ // ── Icon rail wiring ──
829
+ // Rail is the always-visible vertical bar on the far left (Thunderbird/Dovecot
830
+ // style). Mostly mirrors toolbar/menu actions for one-click access; calendar /
831
+ // tasks / contacts buttons are placeholders until those features ship.
832
+ document.getElementById("rail-compose")?.addEventListener("click", () => openCompose("new"));
833
+ document.getElementById("rail-inbox")?.addEventListener("click", () => {
834
+ // Trigger the existing folder-tree click on the first inbox folder.
835
+ const inbox = document.querySelector('.folder-tree .folder-item[data-special-use="inbox"]');
836
+ inbox?.click();
837
+ });
838
+ document.getElementById("rail-unified")?.addEventListener("click", () => {
839
+ const unified = document.querySelector('.folder-tree .all-inboxes')
840
+ || document.getElementById("ft-all-inboxes");
841
+ unified?.click();
842
+ });
843
+ document.getElementById("rail-settings")?.addEventListener("click", () => {
844
+ document.getElementById("btn-settings")?.click();
845
+ });
846
+ document.getElementById("rail-help")?.addEventListener("click", () => {
847
+ document.getElementById("btn-about")?.click();
848
+ });
849
+ document.getElementById("rail-theme")?.addEventListener("click", () => {
850
+ // Cycle theme: system → dark → light → system.
851
+ const root = document.documentElement;
852
+ const cur = root.getAttribute("data-theme") || "system";
853
+ const next = cur === "system" ? "dark" : cur === "dark" ? "light" : "system";
854
+ root.setAttribute("data-theme", next);
855
+ try {
856
+ localStorage.setItem("mailx-theme", next);
857
+ }
858
+ catch { /* private mode */ }
859
+ });
860
+ // Highlight the current rail target. For now just inbox is the default; once
861
+ // calendar/tasks ship, update this on view change.
862
+ function setRailActive(id) {
863
+ document.querySelectorAll(".rail-btn[data-active]").forEach(el => el.removeAttribute("data-active"));
864
+ document.getElementById(id)?.setAttribute("data-active", "true");
865
+ }
866
+ document.addEventListener("mailx-folder-changed", () => setRailActive("rail-inbox"));
792
867
  // Context menu events from message-list right-click
793
868
  document.addEventListener("mailx-compose", ((e) => {
794
869
  if (e.detail.mode === "draft" && sessionStorage.getItem("composeInit")) {
@@ -1422,7 +1497,7 @@ document.getElementById("btn-edit-jsonc")?.addEventListener("click", async () =>
1422
1497
  await openJsoncEditor("accounts.jsonc");
1423
1498
  });
1424
1499
  async function openJsoncEditor(initialFile) {
1425
- const { readJsoncFile, writeJsoncFile } = await import("./lib/api-client.js");
1500
+ const { readJsoncFile, writeJsoncFile, readConfigHelp } = await import("./lib/api-client.js");
1426
1501
  const backdrop = document.createElement("div");
1427
1502
  backdrop.className = "mailx-modal-backdrop";
1428
1503
  const panel = document.createElement("div");
@@ -1437,9 +1512,17 @@ async function openJsoncEditor(initialFile) {
1437
1512
  <option value="config.jsonc">config.jsonc — local per-machine overrides (not synced)</option>
1438
1513
  </select>
1439
1514
  </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>
1515
+ <div class="mailx-modal-split">
1516
+ <label class="mailx-modal-label mailx-modal-split-left">Contents (JSONC — comments and trailing commas allowed)
1517
+ <textarea class="mailx-modal-input mailx-modal-textarea" id="jsonc-content" spellcheck="false"></textarea>
1518
+ </label>
1519
+ <div class="mailx-modal-split-right mailx-help-panel">
1520
+ <div class="mailx-help-title">
1521
+ <button type="button" class="mailx-help-toggle" id="jsonc-help-toggle" aria-expanded="true" title="Hide/show help">▾ Help</button>
1522
+ </div>
1523
+ <div class="mailx-help-body" id="jsonc-help-body"></div>
1524
+ </div>
1525
+ </div>
1443
1526
  <div class="mailx-modal-error" id="jsonc-error" hidden></div>
1444
1527
  <div class="mailx-modal-buttons">
1445
1528
  <span class="mailx-modal-spacer"></span>
@@ -1451,13 +1534,64 @@ async function openJsoncEditor(initialFile) {
1451
1534
  const fileSelect = panel.querySelector("#jsonc-file");
1452
1535
  const textarea = panel.querySelector("#jsonc-content");
1453
1536
  const errorEl = panel.querySelector("#jsonc-error");
1537
+ const saveBtn = panel.querySelector('[data-action="save"]');
1538
+ const helpBody = panel.querySelector("#jsonc-help-body");
1539
+ const helpToggle = panel.querySelector("#jsonc-help-toggle");
1540
+ const helpPanel = panel.querySelector(".mailx-help-panel");
1454
1541
  fileSelect.value = initialFile;
1542
+ helpToggle.addEventListener("click", () => {
1543
+ const open = helpPanel.classList.toggle("mailx-help-collapsed");
1544
+ helpToggle.textContent = open ? "▸ Help" : "▾ Help";
1545
+ helpToggle.setAttribute("aria-expanded", open ? "false" : "true");
1546
+ });
1547
+ const loadHelp = async () => {
1548
+ helpBody.textContent = "Loading help…";
1549
+ try {
1550
+ const r = await readConfigHelp(fileSelect.value);
1551
+ const md = (r?.content || "").trim();
1552
+ helpBody.innerHTML = md ? renderMarkdown(md) : "<em>No help available for this file.</em>";
1553
+ }
1554
+ catch (e) {
1555
+ helpBody.textContent = `Help unavailable: ${e.message}`;
1556
+ }
1557
+ };
1558
+ const clearValidation = () => {
1559
+ errorEl.hidden = true;
1560
+ errorEl.textContent = "";
1561
+ textarea.classList.remove("mailx-modal-input-error");
1562
+ saveBtn.disabled = false;
1563
+ };
1564
+ const showValidation = (err) => {
1565
+ errorEl.textContent = `Line ${err.line}, col ${err.col}: ${err.message}`;
1566
+ errorEl.hidden = false;
1567
+ textarea.classList.add("mailx-modal-input-error");
1568
+ saveBtn.disabled = true;
1569
+ // Select the problem character so the browser draws a visible marker
1570
+ try {
1571
+ textarea.setSelectionRange(err.pos, err.pos + 1);
1572
+ }
1573
+ catch { /* out-of-range → ignore */ }
1574
+ };
1575
+ let validateTimer;
1576
+ const scheduleValidate = () => {
1577
+ if (validateTimer)
1578
+ window.clearTimeout(validateTimer);
1579
+ validateTimer = window.setTimeout(() => {
1580
+ const err = validateJsonc(textarea.value);
1581
+ if (err)
1582
+ showValidation(err);
1583
+ else
1584
+ clearValidation();
1585
+ }, 600);
1586
+ };
1587
+ textarea.addEventListener("input", scheduleValidate);
1455
1588
  const loadFile = async () => {
1456
1589
  textarea.value = "Loading...";
1457
- errorEl.hidden = true;
1590
+ clearValidation();
1458
1591
  try {
1459
1592
  const r = await readJsoncFile(fileSelect.value);
1460
1593
  textarea.value = r?.content || "";
1594
+ scheduleValidate();
1461
1595
  }
1462
1596
  catch (e) {
1463
1597
  textarea.value = "";
@@ -1465,9 +1599,11 @@ async function openJsoncEditor(initialFile) {
1465
1599
  errorEl.hidden = false;
1466
1600
  }
1467
1601
  };
1468
- await loadFile();
1469
- fileSelect.addEventListener("change", loadFile);
1602
+ await Promise.all([loadFile(), loadHelp()]);
1603
+ fileSelect.addEventListener("change", () => { loadFile(); loadHelp(); });
1470
1604
  const close = () => {
1605
+ if (validateTimer)
1606
+ window.clearTimeout(validateTimer);
1471
1607
  backdrop.remove();
1472
1608
  document.removeEventListener("keydown", onKey, true);
1473
1609
  };
@@ -1487,6 +1623,12 @@ async function openJsoncEditor(initialFile) {
1487
1623
  return;
1488
1624
  }
1489
1625
  if (action === "save") {
1626
+ // Final sync-check; refuse to save if it doesn't parse
1627
+ const err = validateJsonc(textarea.value);
1628
+ if (err) {
1629
+ showValidation(err);
1630
+ return;
1631
+ }
1490
1632
  errorEl.hidden = true;
1491
1633
  btn.disabled = true;
1492
1634
  btn.textContent = "Saving...";
@@ -1509,6 +1651,268 @@ async function openJsoncEditor(initialFile) {
1509
1651
  backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
1510
1652
  close(); });
1511
1653
  }
1654
+ // JSONC validator — strips comments + trailing commas (preserving source positions
1655
+ // by replacing stripped chars with spaces/newlines) and runs JSON.parse. Reports
1656
+ // only the *first* error; cascading errors are suppressed.
1657
+ function validateJsonc(src) {
1658
+ const stripped = stripJsoncPreservingPositions(src);
1659
+ if (stripped.error) {
1660
+ const { pos, line, col } = offsetToLineCol(src, stripped.error.pos);
1661
+ return { message: stripped.error.message, pos, line, col };
1662
+ }
1663
+ if (stripped.text.trim() === "")
1664
+ return null; // empty file: treat as valid (settings code handles)
1665
+ try {
1666
+ JSON.parse(stripped.text);
1667
+ return null;
1668
+ }
1669
+ catch (e) {
1670
+ const msg = String(e?.message || "parse error");
1671
+ const m = msg.match(/at position (\d+)/i);
1672
+ const pos = m ? Math.min(parseInt(m[1], 10), src.length - 1) : 0;
1673
+ const lc = offsetToLineCol(src, pos);
1674
+ return { message: msg.replace(/\s*at position \d+/i, ""), pos: lc.pos, line: lc.line, col: lc.col };
1675
+ }
1676
+ }
1677
+ function stripJsoncPreservingPositions(src) {
1678
+ const out = new Array(src.length);
1679
+ let i = 0;
1680
+ const n = src.length;
1681
+ while (i < n) {
1682
+ const c = src[i];
1683
+ const next = src[i + 1];
1684
+ if (c === '"') {
1685
+ out[i] = c;
1686
+ i++;
1687
+ while (i < n) {
1688
+ const ch = src[i];
1689
+ out[i] = ch;
1690
+ i++;
1691
+ if (ch === "\\" && i < n) {
1692
+ out[i] = src[i];
1693
+ i++;
1694
+ continue;
1695
+ }
1696
+ if (ch === '"')
1697
+ break;
1698
+ if (ch === "\n")
1699
+ return { text: out.join(""), error: { message: "unterminated string", pos: i - 1 } };
1700
+ }
1701
+ }
1702
+ else if (c === "/" && next === "/") {
1703
+ while (i < n && src[i] !== "\n") {
1704
+ out[i] = " ";
1705
+ i++;
1706
+ }
1707
+ }
1708
+ else if (c === "/" && next === "*") {
1709
+ const start = i;
1710
+ out[i] = " ";
1711
+ out[i + 1] = " ";
1712
+ i += 2;
1713
+ let closed = false;
1714
+ while (i < n) {
1715
+ if (src[i] === "*" && src[i + 1] === "/") {
1716
+ out[i] = " ";
1717
+ out[i + 1] = " ";
1718
+ i += 2;
1719
+ closed = true;
1720
+ break;
1721
+ }
1722
+ out[i] = src[i] === "\n" ? "\n" : " ";
1723
+ i++;
1724
+ }
1725
+ if (!closed)
1726
+ return { text: out.join(""), error: { message: "unterminated block comment", pos: start } };
1727
+ }
1728
+ else if (c === ",") {
1729
+ // trailing comma before } or ] → replace with space
1730
+ let j = i + 1;
1731
+ while (j < n && /\s/.test(src[j]))
1732
+ j++;
1733
+ if (j < n && (src[j] === "}" || src[j] === "]")) {
1734
+ out[i] = " ";
1735
+ i++;
1736
+ }
1737
+ else {
1738
+ out[i] = c;
1739
+ i++;
1740
+ }
1741
+ }
1742
+ else {
1743
+ out[i] = c;
1744
+ i++;
1745
+ }
1746
+ }
1747
+ return { text: out.join("") };
1748
+ }
1749
+ function offsetToLineCol(src, pos) {
1750
+ pos = Math.max(0, Math.min(pos, src.length));
1751
+ let line = 1, col = 1;
1752
+ for (let i = 0; i < pos; i++) {
1753
+ if (src[i] === "\n") {
1754
+ line++;
1755
+ col = 1;
1756
+ }
1757
+ else
1758
+ col++;
1759
+ }
1760
+ return { pos, line, col };
1761
+ }
1762
+ // Minimal markdown renderer for the help panel. Supports: headings, fenced code blocks,
1763
+ // inline code, bold, italic, links, bullet lists, paragraphs. HTML is escaped first.
1764
+ function renderMarkdown(md) {
1765
+ const esc = (s) => s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1766
+ // Pull fenced code blocks out first so their contents aren't processed as markdown.
1767
+ const blocks = [];
1768
+ let src = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
1769
+ const i = blocks.length;
1770
+ blocks.push(`<pre class="mailx-help-code"><code>${esc(code)}</code></pre>`);
1771
+ return `\u0000BLOCK${i}\u0000`;
1772
+ });
1773
+ const lines = src.split(/\r?\n/);
1774
+ const out = [];
1775
+ let inList = false;
1776
+ let para = [];
1777
+ const flushPara = () => {
1778
+ if (para.length) {
1779
+ out.push(`<p>${inline(para.join(" "))}</p>`);
1780
+ para = [];
1781
+ }
1782
+ };
1783
+ const closeList = () => { if (inList) {
1784
+ out.push("</ul>");
1785
+ inList = false;
1786
+ } };
1787
+ function inline(s) {
1788
+ s = esc(s);
1789
+ s = s.replace(/`([^`]+)`/g, (_m, c) => `<code>${c}</code>`);
1790
+ s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
1791
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
1792
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
1793
+ return s;
1794
+ }
1795
+ for (const raw of lines) {
1796
+ const blockMatch = /^\u0000BLOCK(\d+)\u0000$/.exec(raw);
1797
+ if (blockMatch) {
1798
+ flushPara();
1799
+ closeList();
1800
+ out.push(blocks[parseInt(blockMatch[1], 10)]);
1801
+ continue;
1802
+ }
1803
+ const h = /^(#{1,6})\s+(.+)$/.exec(raw);
1804
+ if (h) {
1805
+ flushPara();
1806
+ closeList();
1807
+ const lvl = h[1].length;
1808
+ out.push(`<h${lvl}>${inline(h[2])}</h${lvl}>`);
1809
+ continue;
1810
+ }
1811
+ const bullet = /^\s*[-*]\s+(.+)$/.exec(raw);
1812
+ if (bullet) {
1813
+ flushPara();
1814
+ if (!inList) {
1815
+ out.push("<ul>");
1816
+ inList = true;
1817
+ }
1818
+ out.push(`<li>${inline(bullet[1])}</li>`);
1819
+ continue;
1820
+ }
1821
+ if (raw.trim() === "") {
1822
+ flushPara();
1823
+ closeList();
1824
+ continue;
1825
+ }
1826
+ para.push(raw);
1827
+ }
1828
+ flushPara();
1829
+ closeList();
1830
+ return out.join("\n");
1831
+ }
1832
+ // ── About dialog ──
1833
+ document.getElementById("btn-about")?.addEventListener("click", () => {
1834
+ const settingsDropdown = document.getElementById("settings-dropdown");
1835
+ if (settingsDropdown)
1836
+ settingsDropdown.hidden = true;
1837
+ openAboutDialog();
1838
+ });
1839
+ // Clicking the version string (toolbar in wide mode, status bar in narrow mode) also opens About
1840
+ document.querySelectorAll(".app-version").forEach(el => {
1841
+ el.style.cursor = "pointer";
1842
+ el.addEventListener("click", openAboutDialog);
1843
+ });
1844
+ async function openAboutDialog() {
1845
+ const backdrop = document.createElement("div");
1846
+ backdrop.className = "mailx-modal-backdrop";
1847
+ const panel = document.createElement("div");
1848
+ panel.className = "mailx-modal";
1849
+ panel.innerHTML = `
1850
+ <div class="mailx-modal-title">About mailx</div>
1851
+ <div class="mailx-about" id="about-body">Loading...</div>
1852
+ <div class="mailx-modal-buttons">
1853
+ <span class="mailx-modal-spacer"></span>
1854
+ <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="close">Close</button>
1855
+ </div>`;
1856
+ backdrop.appendChild(panel);
1857
+ document.body.appendChild(panel.parentElement);
1858
+ const body = panel.querySelector("#about-body");
1859
+ const close = () => {
1860
+ backdrop.remove();
1861
+ document.removeEventListener("keydown", onKey, true);
1862
+ };
1863
+ const onKey = (e) => {
1864
+ if (e.key === "Escape") {
1865
+ e.stopPropagation();
1866
+ e.preventDefault();
1867
+ close();
1868
+ }
1869
+ };
1870
+ document.addEventListener("keydown", onKey, true);
1871
+ panel.querySelector('[data-action="close"]')
1872
+ .addEventListener("click", close);
1873
+ backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
1874
+ close(); });
1875
+ try {
1876
+ const [v, accounts] = await Promise.all([
1877
+ getVersion().catch(() => ({})),
1878
+ getAccounts().catch(() => []),
1879
+ ]);
1880
+ const storage = v.storage || {};
1881
+ const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
1882
+ const platform = isApp ? (mailxapi?.platform || "app") : "browser";
1883
+ const rows = [
1884
+ ["Version", v.version ? `v${v.version}` : "unknown"],
1885
+ ["Platform", platform],
1886
+ ["Storage", storage.provider || "local"],
1887
+ ];
1888
+ if (storage.cloudPath)
1889
+ rows.push(["Cloud path", `My Drive/${storage.cloudPath}/`]);
1890
+ if (storage.mode)
1891
+ rows.push(["Storage mode", storage.mode]);
1892
+ rows.push(["Accounts", String((accounts || []).length)]);
1893
+ rows.push(["User agent", navigator.userAgent]);
1894
+ rows.push(["Screen", `${screen.width}×${screen.height}`]);
1895
+ rows.push(["Window", `${window.innerWidth}×${window.innerHeight}`]);
1896
+ body.innerHTML = `
1897
+ <dl class="mailx-about-dl">
1898
+ ${rows.map(([k, val]) => `<dt>${k}</dt><dd>${escapeHtml(val)}</dd>`).join("")}
1899
+ </dl>
1900
+ ${(accounts || []).length ? `
1901
+ <div class="mailx-about-accounts">
1902
+ <div class="mailx-about-section">Accounts</div>
1903
+ <ul>
1904
+ ${accounts.map(a => `<li>${escapeHtml(a.email || a.id)}${a.name ? ` — ${escapeHtml(a.name)}` : ""}</li>`).join("")}
1905
+ </ul>
1906
+ </div>` : ""}
1907
+ <div class="mailx-about-foot">mailx — local-first mail client</div>`;
1908
+ }
1909
+ catch (e) {
1910
+ body.textContent = `Failed to load: ${e.message}`;
1911
+ }
1912
+ }
1913
+ function escapeHtml(s) {
1914
+ return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1915
+ }
1512
1916
  // Threaded view toggle
1513
1917
  optThreaded?.addEventListener("change", () => {
1514
1918
  const body = document.getElementById("ml-body");
@@ -1578,19 +1982,30 @@ optEditorTiptap?.addEventListener("change", () => {
1578
1982
  if (optEditorTiptap.checked)
1579
1983
  saveEditorSetting("tiptap");
1580
1984
  });
1581
- // ── AI autocomplete toggle ──
1985
+ // ── AI feature toggles ──
1986
+ // One umbrella settings record (AutocompleteSettings) holds the provider config
1987
+ // + per-feature on/off flags. All features default OFF — user must opt into
1988
+ // each AI behavior individually. Per user preference (2026-04-21).
1582
1989
  const optAutocomplete = document.getElementById("opt-autocomplete");
1583
- // Load current autocomplete setting
1990
+ const optAiTranslate = document.getElementById("opt-ai-translate");
1991
+ const optAiProofread = document.getElementById("opt-ai-proofread");
1584
1992
  getAutocompleteSettings().then((ac) => {
1585
1993
  if (optAutocomplete)
1586
- optAutocomplete.checked = ac.enabled || false;
1994
+ optAutocomplete.checked = !!ac.enabled;
1995
+ if (optAiTranslate)
1996
+ optAiTranslate.checked = !!ac.translateEnabled;
1997
+ if (optAiProofread)
1998
+ optAiProofread.checked = !!ac.proofreadEnabled;
1587
1999
  }).catch(() => { });
1588
- optAutocomplete?.addEventListener("change", () => {
2000
+ function persistAi(mutator) {
1589
2001
  getAutocompleteSettings().then((ac) => {
1590
- ac.enabled = optAutocomplete.checked;
2002
+ mutator(ac);
1591
2003
  saveAutocompleteSettings(ac);
1592
2004
  }).catch(() => { });
1593
- });
2005
+ }
2006
+ optAutocomplete?.addEventListener("change", () => persistAi((ac) => { ac.enabled = optAutocomplete.checked; }));
2007
+ optAiTranslate?.addEventListener("change", () => persistAi((ac) => { ac.translateEnabled = optAiTranslate.checked; }));
2008
+ optAiProofread?.addEventListener("change", () => persistAi((ac) => { ac.proofreadEnabled = optAiProofread.checked; }));
1594
2009
  const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
1595
2010
  // Wait for server ready signal, then fetch version
1596
2011
  const versionPromise = getVersion();
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Folder picker — small modal for choosing a destination folder.
3
+ * Used by the message-list right-click "Move to folder…" item and any
4
+ * other UI that needs the user to pick a folder.
5
+ *
6
+ * Reads folders from the local DB via getFolders() (local-first — no
7
+ * server round-trip). Filters by typed text. Returns the selected
8
+ * folder, or null if the user dismissed.
9
+ */
10
+ import { getFolders } from "../lib/api-client.js";
11
+ /** Show a modal folder picker. Returns a promise resolving to the picked
12
+ * folder, or null if dismissed. The list is restricted to one account
13
+ * (the current message's account) so it doesn't get cluttered with
14
+ * unrelated folders; cross-account moves can be added later via an
15
+ * account selector at the top of the picker. */
16
+ export function pickFolder(accountId, opts) {
17
+ return new Promise(async (resolve) => {
18
+ const overlay = document.createElement("div");
19
+ overlay.className = "folder-picker-overlay";
20
+ overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:10000;display:flex;align-items:center;justify-content:center;";
21
+ const modal = document.createElement("div");
22
+ modal.className = "folder-picker-modal";
23
+ modal.style.cssText = "background:var(--bg, #fff);color:var(--fg, #000);border:1px solid var(--border, #ccc);border-radius:6px;box-shadow:0 4px 24px rgba(0,0,0,0.3);width:380px;max-width:90vw;max-height:70vh;display:flex;flex-direction:column;overflow:hidden;";
24
+ const header = document.createElement("div");
25
+ header.style.cssText = "padding:10px 14px;border-bottom:1px solid var(--border, #ddd);font-weight:600;";
26
+ header.textContent = opts?.title || "Move to folder…";
27
+ modal.appendChild(header);
28
+ const search = document.createElement("input");
29
+ search.type = "text";
30
+ search.placeholder = "Filter folders…";
31
+ search.style.cssText = "margin:8px 12px;padding:6px 10px;border:1px solid var(--border, #ccc);border-radius:4px;font-size:13px;";
32
+ modal.appendChild(search);
33
+ const listEl = document.createElement("div");
34
+ listEl.style.cssText = "flex:1;overflow-y:auto;padding:4px 0;";
35
+ modal.appendChild(listEl);
36
+ const footer = document.createElement("div");
37
+ footer.style.cssText = "padding:8px 12px;border-top:1px solid var(--border, #ddd);display:flex;justify-content:flex-end;gap:8px;";
38
+ const cancelBtn = document.createElement("button");
39
+ cancelBtn.textContent = "Cancel";
40
+ cancelBtn.style.cssText = "padding:6px 14px;cursor:pointer;";
41
+ footer.appendChild(cancelBtn);
42
+ modal.appendChild(footer);
43
+ overlay.appendChild(modal);
44
+ document.body.appendChild(overlay);
45
+ const dismiss = (result) => {
46
+ overlay.remove();
47
+ document.removeEventListener("keydown", onKey);
48
+ resolve(result);
49
+ };
50
+ const onKey = (e) => {
51
+ if (e.key === "Escape") {
52
+ e.preventDefault();
53
+ dismiss(null);
54
+ }
55
+ if (e.key === "Enter") {
56
+ const first = listEl.querySelector(".folder-picker-row.match");
57
+ if (first)
58
+ first.click();
59
+ }
60
+ };
61
+ document.addEventListener("keydown", onKey);
62
+ overlay.addEventListener("click", (e) => { if (e.target === overlay)
63
+ dismiss(null); });
64
+ cancelBtn.addEventListener("click", () => dismiss(null));
65
+ // Local-first: load from DB synchronously-ish (one IPC round-trip).
66
+ let folders = [];
67
+ try {
68
+ folders = (await getFolders(accountId)) || [];
69
+ }
70
+ catch (e) {
71
+ listEl.textContent = "Failed to load folders";
72
+ return;
73
+ }
74
+ // Hide special-use that don't make sense as targets (Outbox).
75
+ // Allow Trash / Junk so users can manually file into them.
76
+ const excluded = new Set(opts?.excludeFolderIds || []);
77
+ const targets = folders
78
+ .filter((f) => !excluded.has(f.id))
79
+ .filter((f) => f.specialUse !== "outbox")
80
+ .sort((a, b) => a.path.localeCompare(b.path));
81
+ function render(filter) {
82
+ listEl.innerHTML = "";
83
+ const lc = filter.toLowerCase().trim();
84
+ let firstMatchSet = false;
85
+ for (const f of targets) {
86
+ const row = document.createElement("div");
87
+ row.className = "folder-picker-row";
88
+ row.style.cssText = "padding:6px 14px;cursor:pointer;font-size:13px;display:flex;justify-content:space-between;gap:8px;";
89
+ const name = document.createElement("span");
90
+ name.textContent = f.path;
91
+ const tag = document.createElement("span");
92
+ tag.style.cssText = "color:var(--muted, #888);font-size:11px;";
93
+ tag.textContent = f.specialUse || "";
94
+ row.appendChild(name);
95
+ row.appendChild(tag);
96
+ const matches = !lc || f.path.toLowerCase().includes(lc);
97
+ if (matches) {
98
+ row.classList.add("match");
99
+ if (!firstMatchSet) {
100
+ row.style.background = "var(--hover, #eee)";
101
+ firstMatchSet = true;
102
+ }
103
+ }
104
+ row.addEventListener("mouseenter", () => row.style.background = "var(--hover, #eee)");
105
+ row.addEventListener("mouseleave", () => row.style.background = "");
106
+ row.addEventListener("click", () => {
107
+ dismiss({ accountId, folderId: f.id, folderPath: f.path, folderName: f.path.split(/[./]/).pop() || f.path });
108
+ });
109
+ if (!matches)
110
+ row.style.display = "none";
111
+ listEl.appendChild(row);
112
+ }
113
+ }
114
+ render("");
115
+ search.addEventListener("input", () => render(search.value));
116
+ setTimeout(() => search.focus(), 0);
117
+ });
118
+ }
119
+ //# sourceMappingURL=folder-picker.js.map