@bobfrankston/rmfmail 1.0.678 → 1.0.680

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.ts CHANGED
@@ -1071,6 +1071,12 @@ function sanitizeQuotedBody(msg: any): string {
1071
1071
  // future provider/path that didn't go through that pipeline.
1072
1072
  const isPlainText = !msg.bodyHtml;
1073
1073
  if (isPlainText) {
1074
+ // Full HTML escape. Leaving `>` unescaped was tempting for source
1075
+ // readability but breaks HTML in edge cases — TinyMCE's normalize-
1076
+ // on-paste re-interprets the input, and stray `>` near sequences
1077
+ // like `<!--` / `-->` / `<!` in plain-text bodies can be misread
1078
+ // by the parser. Per Bob 2026-05-12: "not just ugly, it breaks
1079
+ // the HTML." Trivial source-clutter is the lesser evil.
1074
1080
  const escaped = String(msg.bodyText || "")
1075
1081
  .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1076
1082
  return `<div style="white-space:pre-wrap;font-family:inherit;margin:0">${escaped}</div>`;
@@ -596,6 +596,27 @@ async function createTinyMceEditor(container2, opts = {}) {
596
596
  // everything that the schema allows.
597
597
  paste_word_valid_elements: "@[style|class],-strong/b,-em/i,-u,-s,-sub,-sup,-strike,-p,-ol,-ul,-li,-h1,-h2,-h3,-h4,-h5,-h6,-blockquote,-table[border|cellpadding|cellspacing|width|height|class|style],-tr,-td[colspan|rowspan|width|height|class|style|valign|align|background|bgcolor],-th,-thead,-tbody,-tfoot,-pre,-br,-a[href|target|title],-img[src|alt|width|height|style|class]",
598
598
  paste_retain_style_properties: "color background background-color font-family font-size font-weight font-style text-decoration text-align padding padding-top padding-bottom padding-left padding-right margin margin-top margin-bottom margin-left margin-right border border-top border-bottom border-left border-right",
599
+ // Auto-link bare URLs in pasted content. TinyMCE's `autolink`
600
+ // plugin only fires on TYPED space/enter; URLs that arrive via
601
+ // clipboard (browser address bar, terminal copy) come in as
602
+ // plain text and stay un-linked. paste_preprocess runs on the
603
+ // HTML the paste plugin produced.
604
+ //
605
+ // CRITICAL: skip auto-link when the content already contains
606
+ // anchors. Naive regex over the whole content would wrap
607
+ // `<a href="X">X</a>` in ANOTHER anchor (nested anchors are
608
+ // invalid HTML and browsers split them, producing visible
609
+ // junk). For HTML pastes that already have linked URLs (the
610
+ // common case from a browser address bar or another mail
611
+ // client), TinyMCE preserves them — no auto-link pass needed.
612
+ // For plain-text pastes (no anchors present), wrap any bare
613
+ // http(s)://… runs. Trailing sentence punctuation is excluded
614
+ // from the URL.
615
+ paste_preprocess: (_plugin, args) => {
616
+ if (/<a[\s>]/i.test(args.content))
617
+ return;
618
+ args.content = args.content.replace(/(^|[\s(\[])((?:https?|ftp):\/\/[^\s<>"']+[^\s<>"'.,;:!?)\]])/gi, (_m, lead, url) => `${lead}<a href="${url}">${url}</a>`);
619
+ },
599
620
  content_style: "body { font-family: system-ui, sans-serif; font-size: 14px; }",
600
621
  init_instance_callback: (ed) => resolve(ed),
601
622
  setup: (ed) => {
@@ -1729,52 +1750,120 @@ function addToUserDict(word, sp) {
1729
1750
  }
1730
1751
  sp.add(word);
1731
1752
  }
1732
- function wordAtPoint(doc, x, y) {
1733
- const anyDoc = doc;
1734
- let caretNode = null;
1735
- let caretOffset = 0;
1736
- if (typeof anyDoc.caretRangeFromPoint === "function") {
1737
- const r = anyDoc.caretRangeFromPoint(x, y);
1738
- if (!r)
1739
- return null;
1740
- caretNode = r.startContainer;
1741
- caretOffset = r.startOffset;
1742
- } else if (typeof anyDoc.caretPositionFromPoint === "function") {
1743
- const p = anyDoc.caretPositionFromPoint(x, y);
1744
- if (!p)
1745
- return null;
1746
- caretNode = p.offsetNode;
1747
- caretOffset = p.offset;
1748
- } else {
1749
- return null;
1753
+ function decorate(editor2, sp) {
1754
+ const body = editor2.getBody?.();
1755
+ const doc = editor2.getDoc?.();
1756
+ if (!body || !doc)
1757
+ return;
1758
+ const sel = doc.getSelection();
1759
+ if (sel && sel.rangeCount > 0) {
1760
+ const focus = sel.focusNode;
1761
+ let p = focus;
1762
+ while (p && p !== body) {
1763
+ if (p.nodeType === Node.ELEMENT_NODE && p.hasAttribute?.(MARKER_ATTR)) {
1764
+ return;
1765
+ }
1766
+ p = p.parentNode;
1767
+ }
1750
1768
  }
1751
- if (!caretNode || caretNode.nodeType !== Node.TEXT_NODE)
1752
- return null;
1753
- const text = caretNode.data;
1754
- const isWordChar = (ch) => /[\p{L}\p{N}'’\-]/u.test(ch);
1755
- let start = caretOffset;
1756
- let end = caretOffset;
1757
- while (start > 0 && isWordChar(text[start - 1]))
1758
- start--;
1759
- while (end < text.length && isWordChar(text[end]))
1760
- end++;
1761
- if (start === end)
1762
- return null;
1763
- let word = text.slice(start, end);
1764
- while (word.length && /^['’\-]/.test(word)) {
1765
- word = word.slice(1);
1766
- start++;
1769
+ const bookmark = editor2.selection?.getBookmark?.(2);
1770
+ try {
1771
+ editor2.undoManager?.ignore?.(() => {
1772
+ const old = body.querySelectorAll(`span[${MARKER_ATTR}]`);
1773
+ for (const m of old) {
1774
+ const parent2 = m.parentNode;
1775
+ if (!parent2)
1776
+ continue;
1777
+ while (m.firstChild)
1778
+ parent2.insertBefore(m.firstChild, m);
1779
+ parent2.removeChild(m);
1780
+ }
1781
+ body.normalize();
1782
+ const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
1783
+ acceptNode(node) {
1784
+ let p = node.parentNode;
1785
+ while (p && p !== body) {
1786
+ if (p.nodeType === Node.ELEMENT_NODE && SKIP_TAGS.has(p.tagName)) {
1787
+ return NodeFilter.FILTER_REJECT;
1788
+ }
1789
+ p = p.parentNode;
1790
+ }
1791
+ return NodeFilter.FILTER_ACCEPT;
1792
+ }
1793
+ });
1794
+ const hits = [];
1795
+ let n = walker.nextNode();
1796
+ const WORD_RE = /[\p{L}][\p{L}'’\-]*/gu;
1797
+ while (n) {
1798
+ const tn = n;
1799
+ const text = tn.data;
1800
+ let m;
1801
+ WORD_RE.lastIndex = 0;
1802
+ while ((m = WORD_RE.exec(text)) !== null) {
1803
+ const word = m[0];
1804
+ if (word.length < MIN_WORD_LEN)
1805
+ continue;
1806
+ if (sp.correct(word))
1807
+ continue;
1808
+ hits.push({ node: tn, start: m.index, end: m.index + word.length });
1809
+ }
1810
+ n = walker.nextNode();
1811
+ }
1812
+ hits.reverse();
1813
+ for (const h of hits) {
1814
+ const range = doc.createRange();
1815
+ range.setStart(h.node, h.start);
1816
+ range.setEnd(h.node, h.end);
1817
+ const span = doc.createElement("span");
1818
+ span.setAttribute(MARKER_ATTR, "1");
1819
+ try {
1820
+ range.surroundContents(span);
1821
+ } catch {
1822
+ }
1823
+ }
1824
+ });
1825
+ } finally {
1826
+ if (bookmark)
1827
+ try {
1828
+ editor2.selection?.moveToBookmark?.(bookmark);
1829
+ } catch {
1830
+ }
1767
1831
  }
1768
- while (word.length && /['’\-]$/.test(word)) {
1769
- word = word.slice(0, -1);
1770
- end--;
1832
+ }
1833
+ function installDecorationStyle(editor2) {
1834
+ const doc = editor2.getDoc?.();
1835
+ if (!doc)
1836
+ return;
1837
+ if (doc.getElementById("mailx-spell-style"))
1838
+ return;
1839
+ const style = doc.createElement("style");
1840
+ style.id = "mailx-spell-style";
1841
+ style.textContent = `
1842
+ span[${MARKER_ATTR}] {
1843
+ text-decoration: underline wavy #d33;
1844
+ text-decoration-skip-ink: none;
1845
+ text-underline-offset: 2px;
1846
+ /* No background \u2014 keeps the styling subtle, like a native
1847
+ * spell underline, not a Find-highlight. */
1848
+ background: transparent;
1849
+ }
1850
+ `;
1851
+ doc.head.appendChild(style);
1852
+ }
1853
+ function installSerializerFilter(editor2) {
1854
+ if (editor2.__mailxSpellSerializerWired)
1855
+ return;
1856
+ editor2.__mailxSpellSerializerWired = true;
1857
+ try {
1858
+ editor2.serializer.addAttributeFilter(MARKER_ATTR, (nodes) => {
1859
+ for (const node of nodes) {
1860
+ if (typeof node.unwrap === "function")
1861
+ node.unwrap();
1862
+ }
1863
+ });
1864
+ } catch (e) {
1865
+ console.warn("[spellcheck] serializer filter setup failed:", e);
1771
1866
  }
1772
- if (!word)
1773
- return null;
1774
- const range = doc.createRange();
1775
- range.setStart(caretNode, start);
1776
- range.setEnd(caretNode, end);
1777
- return { word, range };
1778
1867
  }
1779
1868
  function showSuggestionsMenu(parentDoc, x, y, items) {
1780
1869
  parentDoc.getElementById("mailx-spell-menu")?.remove();
@@ -1791,11 +1880,11 @@ function showSuggestionsMenu(parentDoc, x, y, items) {
1791
1880
  box-shadow: 0 4px 16px rgba(0,0,0,0.18);
1792
1881
  padding: 4px 0;
1793
1882
  font: 13px system-ui, sans-serif;
1794
- min-width: 160px;
1883
+ min-width: 180px;
1795
1884
  max-width: 320px;
1796
1885
  `;
1797
1886
  for (const it of items) {
1798
- if (it.label === "---") {
1887
+ if (it.separator) {
1799
1888
  const sep = parentDoc.createElement("div");
1800
1889
  sep.style.cssText = "border-top:1px solid var(--color-border,#ddd); margin: 4px 0;";
1801
1890
  menu.appendChild(sep);
@@ -1845,7 +1934,10 @@ function showSuggestionsMenu(parentDoc, x, y, items) {
1845
1934
  parentDoc.addEventListener("keydown", dismiss2, true);
1846
1935
  }, 0);
1847
1936
  }
1848
- function replaceRange(doc, range, replacement) {
1937
+ function replaceMarker(editor2, marker, replacement) {
1938
+ const doc = editor2.getDoc();
1939
+ const range = doc.createRange();
1940
+ range.selectNode(marker);
1849
1941
  const sel = doc.getSelection();
1850
1942
  if (!sel)
1851
1943
  return;
@@ -1865,62 +1957,92 @@ function wireSpellcheck(editor2) {
1865
1957
  if (editor2.__mailxSpellWired)
1866
1958
  return;
1867
1959
  editor2.__mailxSpellWired = true;
1868
- const iframeDoc = editor2.getDoc?.() || null;
1869
- if (!iframeDoc)
1870
- return;
1960
+ let sp = null;
1961
+ let decorateTimer = null;
1962
+ const scheduleDecorate = () => {
1963
+ if (!sp)
1964
+ return;
1965
+ if (decorateTimer)
1966
+ clearTimeout(decorateTimer);
1967
+ decorateTimer = setTimeout(() => {
1968
+ decorateTimer = null;
1969
+ if (sp)
1970
+ decorate(editor2, sp);
1971
+ }, DECORATE_DEBOUNCE_MS);
1972
+ };
1973
+ getSpell().then((loaded) => {
1974
+ sp = loaded;
1975
+ installDecorationStyle(editor2);
1976
+ installSerializerFilter(editor2);
1977
+ decorate(editor2, loaded);
1978
+ }).catch((err) => {
1979
+ console.error("[spellcheck] dict load failed:", err);
1980
+ });
1981
+ editor2.on("input nodechange setcontent paste keyup", scheduleDecorate);
1982
+ const iframeDoc = editor2.getDoc();
1871
1983
  iframeDoc.addEventListener("contextmenu", (ev) => {
1872
1984
  const e = ev;
1873
- const iframeEl = editor2.iframeElement;
1874
- if (!iframeEl)
1985
+ const target = e.target;
1986
+ if (!target)
1875
1987
  return;
1876
- const found = wordAtPoint(iframeDoc, e.clientX, e.clientY);
1877
- if (!found)
1988
+ const marker = target.closest?.(`span[${MARKER_ATTR}]`);
1989
+ if (!marker)
1878
1990
  return;
1879
- getSpell().then((sp) => {
1880
- if (sp.correct(found.word))
1881
- return;
1882
- const sugs = sp.suggest(found.word).slice(0, 7);
1883
- const iframeRect = iframeEl.getBoundingClientRect();
1884
- const menuX = iframeRect.left + e.clientX;
1885
- const menuY = iframeRect.top + e.clientY;
1886
- const items = [];
1887
- if (sugs.length === 0) {
1888
- items.push({ label: "(no suggestions)", action: () => {
1889
- } });
1890
- } else {
1891
- for (const s of sugs) {
1892
- items.push({
1893
- label: s,
1894
- emphasized: true,
1895
- action: () => replaceRange(iframeDoc, found.range, s)
1896
- });
1897
- }
1898
- }
1899
- items.push({ label: "---", action: () => {
1900
- } });
1901
- items.push({
1902
- label: `Add "${found.word}" to dictionary`,
1903
- action: () => addToUserDict(found.word, sp)
1904
- });
1905
- items.push({
1906
- label: "Ignore (this session)",
1907
- action: () => sp.add(found.word)
1908
- });
1909
- showSuggestionsMenu(document, menuX, menuY, items);
1910
- }).catch((err) => {
1911
- console.error("[spellcheck] dict load failed:", err);
1991
+ const word = marker.textContent || "";
1992
+ if (!word || !sp)
1912
1993
  return;
1913
- });
1914
1994
  e.preventDefault();
1915
1995
  e.stopPropagation();
1996
+ const sugs = sp.suggest(word).slice(0, 7);
1997
+ const iframeEl = editor2.iframeElement;
1998
+ const iframeRect = iframeEl ? iframeEl.getBoundingClientRect() : { left: 0, top: 0 };
1999
+ const items = [];
2000
+ if (sugs.length === 0) {
2001
+ items.push({ label: "(no suggestions)", action: () => {
2002
+ } });
2003
+ } else {
2004
+ for (const s of sugs) {
2005
+ items.push({
2006
+ label: s,
2007
+ emphasized: true,
2008
+ action: () => {
2009
+ replaceMarker(editor2, marker, s);
2010
+ scheduleDecorate();
2011
+ }
2012
+ });
2013
+ }
2014
+ }
2015
+ items.push({ label: "", action: () => {
2016
+ }, separator: true });
2017
+ items.push({
2018
+ label: `Add "${word}" to dictionary`,
2019
+ action: () => {
2020
+ if (sp)
2021
+ addToUserDict(word, sp);
2022
+ scheduleDecorate();
2023
+ }
2024
+ });
2025
+ items.push({
2026
+ label: "Ignore (this session)",
2027
+ action: () => {
2028
+ if (sp)
2029
+ sp.add(word);
2030
+ scheduleDecorate();
2031
+ }
2032
+ });
2033
+ showSuggestionsMenu(document, iframeRect.left + e.clientX, iframeRect.top + e.clientY, items);
1916
2034
  }, true);
1917
2035
  }
1918
- var import_nspell, USER_DICT_KEY, spellPromise;
2036
+ var import_nspell, USER_DICT_KEY, MARKER_ATTR, DECORATE_DEBOUNCE_MS, MIN_WORD_LEN, SKIP_TAGS, spellPromise;
1919
2037
  var init_spellcheck = __esm({
1920
2038
  "client/compose/spellcheck.js"() {
1921
2039
  "use strict";
1922
2040
  import_nspell = __toESM(require_lib(), 1);
1923
2041
  USER_DICT_KEY = "mailx-user-dict";
2042
+ MARKER_ATTR = "data-mailx-spellerror";
2043
+ DECORATE_DEBOUNCE_MS = 500;
2044
+ MIN_WORD_LEN = 3;
2045
+ SKIP_TAGS = /* @__PURE__ */ new Set(["BLOCKQUOTE", "CODE", "PRE", "A", "SCRIPT", "STYLE", "KBD", "SAMP", "VAR"]);
1924
2046
  spellPromise = null;
1925
2047
  }
1926
2048
  });