@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/README.md +15 -2
- package/bin/mailx.js +56 -1
- package/client/app.js +436 -21
- package/client/components/folder-picker.js +119 -0
- package/client/components/message-list.js +23 -1
- package/client/components/message-viewer.js +140 -22
- package/client/compose/compose.css +50 -0
- package/client/compose/compose.js +68 -12
- package/client/compose/editor.js +51 -7
- package/client/index.html +19 -0
- package/client/lib/api-client.js +12 -0
- package/client/lib/mailxapi.js +16 -7
- package/client/styles/components.css +115 -0
- package/client/styles/layout.css +64 -14
- package/client/styles/variables.css +1 -0
- package/package.json +8 -5
- package/packages/mailx-core/index.d.ts +3 -0
- package/packages/mailx-core/index.js +45 -7
- package/packages/mailx-host/index.d.ts +21 -0
- package/packages/mailx-host/index.js +31 -0
- package/packages/mailx-host/package.json +23 -0
- package/packages/mailx-imap/index.js +33 -0
- package/packages/mailx-service/index.d.ts +18 -1
- package/packages/mailx-service/index.js +198 -6
- package/packages/mailx-service/jsonrpc.js +8 -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/packages/mailx-types/index.d.ts +21 -0
- package/tempfix.cmd +77 -0
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
|
|
435
|
-
//
|
|
436
|
-
//
|
|
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
|
|
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
|
-
|
|
443
|
-
|
|
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(
|
|
447
|
-
console.log(`[compose]
|
|
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
|
-
<
|
|
1441
|
-
<
|
|
1442
|
-
|
|
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
|
-
|
|
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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2000
|
+
function persistAi(mutator) {
|
|
1589
2001
|
getAutocompleteSettings().then((ac) => {
|
|
1590
|
-
ac
|
|
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
|