@bobfrankston/rmfmail 1.0.502 → 1.0.503

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.
Files changed (50) hide show
  1. package/client/app.js +82 -32
  2. package/client/app.js.map +1 -1
  3. package/client/app.ts +88 -32
  4. package/client/components/message-viewer.js +51 -21
  5. package/client/components/message-viewer.js.map +1 -1
  6. package/client/components/message-viewer.ts +38 -19
  7. package/client/compose/compose.css +14 -0
  8. package/client/compose/compose.html +4 -3
  9. package/client/compose/compose.js +288 -36
  10. package/client/compose/compose.js.map +1 -1
  11. package/client/compose/compose.ts +270 -40
  12. package/client/compose/editor-help.js +107 -0
  13. package/client/compose/editor-help.js.map +1 -0
  14. package/client/compose/editor-help.ts +106 -0
  15. package/client/compose/editor.js +43 -0
  16. package/client/compose/editor.js.map +1 -1
  17. package/client/compose/editor.ts +22 -0
  18. package/client/index.html +1 -4
  19. package/docs/editor.md +92 -0
  20. package/package.json +4 -2
  21. package/packages/mailx-service/html-to-docx.d.ts +1 -0
  22. package/packages/mailx-service/index.d.ts +10 -2
  23. package/packages/mailx-service/index.d.ts.map +1 -1
  24. package/packages/mailx-service/index.js +143 -21
  25. package/packages/mailx-service/index.js.map +1 -1
  26. package/packages/mailx-service/index.ts +114 -21
  27. package/packages/mailx-service/package.json +5 -3
  28. package/packages/mailx-settings/docs/accounts.md +41 -0
  29. package/packages/mailx-settings/docs/allowlist.md +27 -0
  30. package/packages/mailx-settings/docs/clients.md +24 -0
  31. package/packages/mailx-settings/docs/config.md +32 -0
  32. package/packages/mailx-settings/docs/contact-rules.md +40 -0
  33. package/packages/mailx-settings/docs/contacts.md +48 -0
  34. package/packages/mailx-settings/docs/editor.md +92 -0
  35. package/packages/mailx-settings/docs/preferences.md +43 -0
  36. package/packages/mailx-settings/index.d.ts +2 -2
  37. package/packages/mailx-settings/index.d.ts.map +1 -1
  38. package/packages/mailx-settings/index.js +7 -9
  39. package/packages/mailx-settings/index.js.map +1 -1
  40. package/packages/mailx-settings/index.ts +7 -9
  41. package/packages/mailx-types/groups.d.ts +38 -0
  42. package/packages/mailx-types/groups.d.ts.map +1 -0
  43. package/packages/mailx-types/groups.js +99 -0
  44. package/packages/mailx-types/groups.js.map +1 -0
  45. package/packages/mailx-types/groups.ts +112 -0
  46. package/packages/mailx-types/index.d.ts +2 -0
  47. package/packages/mailx-types/index.d.ts.map +1 -1
  48. package/packages/mailx-types/index.js +5 -0
  49. package/packages/mailx-types/index.js.map +1 -1
  50. package/packages/mailx-types/index.ts +9 -0
@@ -19,7 +19,7 @@
19
19
  </div>
20
20
  <div class="compose-field">
21
21
  <label for="compose-to">To</label>
22
- <input type="text" id="compose-to" autocomplete="off">
22
+ <textarea id="compose-to" class="compose-addr-input" autocomplete="off" rows="1" spellcheck="false"></textarea>
23
23
  <span class="compose-recipient-toggle">
24
24
  <button type="button" class="compose-toggle-btn" id="btn-toggle-cc" tabindex="-1" title="Show/hide Cc">Cc</button>
25
25
  <button type="button" class="compose-toggle-btn" id="btn-toggle-bcc" tabindex="-1" title="Show/hide Bcc">Bcc</button>
@@ -27,11 +27,11 @@
27
27
  </div>
28
28
  <div class="compose-field" id="compose-cc-row" hidden>
29
29
  <label for="compose-cc">Cc</label>
30
- <input type="text" id="compose-cc" autocomplete="off">
30
+ <textarea id="compose-cc" class="compose-addr-input" autocomplete="off" rows="1" spellcheck="false"></textarea>
31
31
  </div>
32
32
  <div class="compose-field" id="compose-bcc-row" hidden>
33
33
  <label for="compose-bcc">Bcc</label>
34
- <input type="text" id="compose-bcc" autocomplete="off">
34
+ <textarea id="compose-bcc" class="compose-addr-input" autocomplete="off" rows="1" spellcheck="false"></textarea>
35
35
  </div>
36
36
  <div class="compose-field">
37
37
  <label for="compose-subject">Subject</label>
@@ -43,6 +43,7 @@
43
43
  <button class="tb-btn" id="btn-attach">Attach</button>
44
44
  <input type="file" id="compose-file" multiple hidden>
45
45
  <button class="tb-btn" id="btn-edit-in-word" title="Open the body in Microsoft Word — saves in Word reload back into the editor">Edit in Word</button>
46
+ <button class="tb-btn" id="btn-compose-help" title="Editor shortcuts and formatting reference (Quill vs tiptap differences)">?</button>
46
47
  <button class="tb-btn" id="btn-discard">Discard</button>
47
48
  <span id="compose-status" class="compose-status"></span>
48
49
  </div>
@@ -92,34 +92,48 @@ catch { /* private-mode / SecurityError — default quill */ }
92
92
  }
93
93
  catch { /* non-fatal */ }
94
94
  })();
95
- // Whatever happens in editor init, surface failures to the mailx log
96
- // AND fall through to a plain-contenteditable fallback so the rest of
97
- // the compose script (Discard, X, Send, save-draft) still runs. Earlier
98
- // versions re-threw asset-load failures, which left compose with dead
99
- // buttons and the user with no recourse exactly the "Reply window
100
- // won't close" symptom we hit when Quill's CDN was unreachable.
95
+ // Editor init try the preferred type first; if its CDN/setup fails,
96
+ // fall back to the OTHER editor before giving up on the toolbar entirely.
97
+ // Both Quill and tiptap fetch from jsdelivr; transient CDN issues used to
98
+ // drop the user into a plain-textarea fallback with no formatting controls.
99
+ // Now we keep trying until either an editor with a toolbar comes up, or
100
+ // both fail and we render the minimal contenteditable as last resort.
101
101
  let editor;
102
- let editorAssetError = null;
103
- try {
104
- await loadEditorAssets(editorType);
105
- }
106
- catch (e) {
107
- editorAssetError = e;
108
- logClientEvent("compose-editor-assets-failed", { type: editorType, error: String(e?.message || e) });
109
- }
110
102
  const container = document.getElementById("compose-editor");
111
- container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
112
- try {
113
- if (editorAssetError)
114
- throw editorAssetError;
115
- editor = await createEditor(container, editorType);
103
+ async function tryEditor(type) {
104
+ try {
105
+ await loadEditorAssets(type);
106
+ }
107
+ catch (e) {
108
+ logClientEvent("compose-editor-assets-failed", { type, error: String(e?.message || e) });
109
+ return null;
110
+ }
111
+ container.classList.remove("editor-tiptap", "editor-quill");
112
+ container.classList.add(type === "tiptap" ? "editor-tiptap" : "editor-quill");
113
+ try {
114
+ return await createEditor(container, type);
115
+ }
116
+ catch (e) {
117
+ logClientEvent("compose-editor-create-failed", { type, error: String(e?.message || e) });
118
+ return null;
119
+ }
116
120
  }
117
- catch (e) {
118
- logClientEvent("compose-editor-create-failed", { type: editorType, error: String(e?.message || e) });
119
- // Render a minimal contenteditable fallback so the user can still type
120
- // SOMETHING. Without this, an editor failure leaves the compose form
121
- // half-functional (To/Cc/Bcc work, body doesn't) and the user doesn't
122
- // know why. The fallback is a plain div no toolbar, no rich text.
121
+ editor = (await tryEditor(editorType));
122
+ if (!editor) {
123
+ // Preferred editor failed try the other one before giving up.
124
+ const fallbackType = editorType === "tiptap" ? "quill" : "tiptap";
125
+ logClientEvent("compose-editor-fallback-other", { from: editorType, to: fallbackType });
126
+ container.innerHTML = ""; // clear any partial render from the failed try
127
+ editor = (await tryEditor(fallbackType));
128
+ if (editor) {
129
+ setTimeout(() => showDraftStatus(`${editorType} editor unavailable — using ${fallbackType} instead.`, false), 0);
130
+ }
131
+ }
132
+ if (!editor) {
133
+ // Both editors failed — render a minimal contenteditable as last resort.
134
+ // No toolbar, no rich-text shortcuts; user can still type. Status bar
135
+ // surfaces the failure so the missing toolbar isn't a silent mystery.
136
+ logClientEvent("compose-editor-create-failed-both", {});
123
137
  container.innerHTML = `<div class="compose-fallback-editor" contenteditable="true" style="border:1px solid #c00;padding:8px;min-height:200px;background:#fff" data-fallback="true"></div>`;
124
138
  const fallback = container.querySelector(".compose-fallback-editor");
125
139
  editor = {
@@ -144,9 +158,7 @@ catch (e) {
144
158
  }
145
159
  },
146
160
  };
147
- // Surface the failure to the user in the status bar so they know
148
- // why the toolbar is missing and the editor is plain.
149
- setTimeout(() => showDraftStatus(`Editor failed to load (${editorType}). Plain-text fallback in use. Open log for details.`, true), 0);
161
+ setTimeout(() => showDraftStatus(`Both editors failed to load. Plain-text fallback in use. Check the log; CDN may be unreachable.`, true), 0);
150
162
  }
151
163
  // Ctrl+scroll / Ctrl+= / Ctrl+- / Ctrl+0 zoom for the compose editor body.
152
164
  // Persists per-session in localStorage so zoom survives window pop/close cycles.
@@ -193,10 +205,44 @@ catch (e) {
193
205
  // separate "Other..." escape hatch, no hidden custom input toggle.
194
206
  const fromInput = document.getElementById("compose-from-input");
195
207
  const fromOptions = document.getElementById("compose-from-options");
208
+ // Address fields are textareas (not inputs) so they wrap to multiple lines
209
+ // when the recipient list gets long — single-line inputs scrolled off-screen
210
+ // horizontally. JS auto-grows their height on input below.
196
211
  const toInput = document.getElementById("compose-to");
197
212
  const ccInput = document.getElementById("compose-cc");
198
213
  const bccInput = document.getElementById("compose-bcc");
199
214
  const subjectInput = document.getElementById("compose-subject");
215
+ /** Resize an address textarea to fit its content. Called on input + after
216
+ * programmatic value sets (applyInit). Caps at 8 lines — beyond that the
217
+ * field scrolls to keep the overall compose window manageable. */
218
+ function autoGrowAddrInput(el) {
219
+ if (!el)
220
+ return;
221
+ el.style.height = "auto";
222
+ const max = 8 * 22; // ~8 lines × line-height
223
+ el.style.height = Math.min(el.scrollHeight, max) + "px";
224
+ if (el.scrollHeight > max)
225
+ el.style.overflowY = "auto";
226
+ else
227
+ el.style.overflowY = "hidden";
228
+ }
229
+ for (const el of [toInput, ccInput, bccInput]) {
230
+ el?.addEventListener("input", () => autoGrowAddrInput(el));
231
+ // Treat Enter as a recipient separator (split on comma OR newline at
232
+ // send time), but don't insert a literal newline — that'd break the
233
+ // implicit "comma-separated list" assumption downstream. Replace with
234
+ // a comma + space.
235
+ el?.addEventListener("keydown", (e) => {
236
+ if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
237
+ e.preventDefault();
238
+ const start = el.selectionStart || 0;
239
+ const v = el.value;
240
+ el.value = v.slice(0, start).replace(/[,\s]+$/, "") + ", " + v.slice(start).replace(/^[,\s]+/, "");
241
+ el.selectionStart = el.selectionEnd = start + 2;
242
+ autoGrowAddrInput(el);
243
+ }
244
+ });
245
+ }
200
246
  let knownAccounts = [];
201
247
  // ── AI ghost text autocomplete ──
202
248
  if (appSettings?.autocomplete?.enabled && appSettings.autocomplete.provider !== "off") {
@@ -485,17 +531,18 @@ function setupAutocomplete(input) {
485
531
  if (!dropdown)
486
532
  return;
487
533
  const items = dropdown.querySelectorAll(".ac-item");
488
- if (e.key === "ArrowDown") {
534
+ const ke = e;
535
+ if (ke.key === "ArrowDown") {
489
536
  e.preventDefault();
490
537
  activeIndex = Math.min(activeIndex + 1, items.length - 1);
491
538
  items.forEach((el, i) => el.classList.toggle("ac-active", i === activeIndex));
492
539
  }
493
- else if (e.key === "ArrowUp") {
540
+ else if (ke.key === "ArrowUp") {
494
541
  e.preventDefault();
495
542
  activeIndex = Math.max(activeIndex - 1, 0);
496
543
  items.forEach((el, i) => el.classList.toggle("ac-active", i === activeIndex));
497
544
  }
498
- else if (e.key === "Tab" || e.key === "Enter") {
545
+ else if (ke.key === "Tab" || ke.key === "Enter") {
499
546
  if (items.length > 0) {
500
547
  e.preventDefault();
501
548
  const idx = activeIndex >= 0 ? activeIndex : 0;
@@ -504,7 +551,7 @@ function setupAutocomplete(input) {
504
551
  return;
505
552
  }
506
553
  }
507
- else if (e.key === "Escape") {
554
+ else if (ke.key === "Escape") {
508
555
  closeDropdown();
509
556
  }
510
557
  });
@@ -534,6 +581,47 @@ function parseAddrs(s) {
534
581
  return { name: "", address: part };
535
582
  });
536
583
  }
584
+ /** Expand any group names in a recipient string against the user's
585
+ * contacts.jsonc → groups map. Recipients that match a group name (case
586
+ * insensitive) get replaced by their member list, recursively. Unresolved
587
+ * tokens are left in place so the user sees them and can fix the typo.
588
+ * Loaded once per send via `getContactsConfig`; cached for the session. */
589
+ let _groupsCache = null;
590
+ async function loadGroupsMap() {
591
+ if (_groupsCache)
592
+ return _groupsCache;
593
+ try {
594
+ const { readJsoncFile } = await import("../lib/api-client.js");
595
+ const r = await readJsoncFile("contacts.jsonc");
596
+ if (!r?.content) {
597
+ _groupsCache = {};
598
+ return _groupsCache;
599
+ }
600
+ // Use a loose JSONC parse: strip // comments + trailing commas.
601
+ const stripped = r.content.replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1");
602
+ const cfg = JSON.parse(stripped);
603
+ _groupsCache = (cfg && typeof cfg.groups === "object" && cfg.groups) ? cfg.groups : {};
604
+ return _groupsCache;
605
+ }
606
+ catch {
607
+ _groupsCache = {};
608
+ return _groupsCache;
609
+ }
610
+ }
611
+ /** Expand the raw recipient string against the loaded groups map. */
612
+ async function expandGroups(raw) {
613
+ if (!raw.trim())
614
+ return raw;
615
+ const groups = await loadGroupsMap();
616
+ if (Object.keys(groups).length === 0)
617
+ return raw;
618
+ const { expandRecipients } = await import("@bobfrankston/mailx-types");
619
+ const r = expandRecipients(raw, groups);
620
+ if (r.unresolved.length > 0) {
621
+ console.warn(`[groups] unresolved recipients: ${r.unresolved.join(", ")}`);
622
+ }
623
+ return r.addresses.join(", ");
624
+ }
537
625
  function applyInit(init) {
538
626
  // Populate the From datalist with known accounts
539
627
  populateFromOptions(init.accounts, init.accountId);
@@ -547,6 +635,12 @@ function applyInit(init) {
547
635
  toInput.value = formatAddrs(init.to);
548
636
  ccInput.value = formatAddrs(init.cc);
549
637
  subjectInput.value = init.subject;
638
+ // Resize the textareas to match the freshly-loaded recipient lists —
639
+ // without this the textareas stay 1-row even when the To: contains a
640
+ // 200-character reply-all address list.
641
+ autoGrowAddrInput(toInput);
642
+ autoGrowAddrInput(ccInput);
643
+ autoGrowAddrInput(bccInput);
550
644
  // Auto-expand Cc row if the init already has Cc content (reply-all, draft-with-cc)
551
645
  if (ccInput.value.trim()) {
552
646
  const ccRowEl = document.getElementById("compose-cc-row");
@@ -837,18 +931,24 @@ window.addEventListener("blur", () => {
837
931
  }
838
932
  catch { /* */ }
839
933
  });
840
- document.getElementById("btn-send")?.addEventListener("click", () => {
934
+ document.getElementById("btn-send")?.addEventListener("click", async () => {
841
935
  // Loud tracing through the whole send pipeline. Every step ships a
842
936
  // `[client] compose-send-*` event to the Node log so a "vanished message"
843
937
  // report can be traced end-to-end without devtools. If the log stops
844
938
  // at any step, that's where the pipeline broke.
845
939
  logClientEvent("compose-send-click");
940
+ // Group expansion — replace any group names (family, neighbors, etc.)
941
+ // with their address lists from contacts.jsonc → groups before parsing
942
+ // into the validated {name, address}[] shape the service expects.
943
+ const toExpanded = await expandGroups(toInput.value);
944
+ const ccExpanded = await expandGroups(ccInput.value);
945
+ const bccExpanded = await expandGroups(bccInput.value);
846
946
  const body = {
847
947
  from: getFromAccountId(),
848
948
  fromAddress: getFromAddress(),
849
- to: parseAddrs(toInput.value),
850
- cc: parseAddrs(ccInput.value),
851
- bcc: parseAddrs(bccInput.value),
949
+ to: parseAddrs(toExpanded),
950
+ cc: parseAddrs(ccExpanded),
951
+ bcc: parseAddrs(bccExpanded),
852
952
  subject: subjectInput.value,
853
953
  bodyHtml: editor.getHtml(),
854
954
  bodyText: editor.getText(),
@@ -1170,11 +1270,163 @@ document.getElementById("btn-edit-in-word")?.addEventListener("click", async ()
1170
1270
  result.opener === "libreoffice" ? "LibreOffice" :
1171
1271
  "your default editor";
1172
1272
  showDraftStatus(`Editing in ${label} — saves there will reload here.`, false);
1273
+ showExternalEditHint(label);
1173
1274
  }
1174
1275
  catch (e) {
1175
1276
  showDraftStatus(`Edit-in-Word failed: ${e?.message || e}`, true);
1176
1277
  }
1177
1278
  });
1279
+ // Editor help button — opens a reference modal with shortcuts and the
1280
+ // Quill / tiptap differences. Source content lives in app/docs/editor.md
1281
+ // (and is mirrored in editor-help.ts for runtime embed).
1282
+ document.getElementById("btn-compose-help")?.addEventListener("click", async () => {
1283
+ try {
1284
+ const { EDITOR_HELP_MD } = await import("./editor-help.js");
1285
+ showEditorHelpModal(EDITOR_HELP_MD);
1286
+ }
1287
+ catch (e) {
1288
+ showDraftStatus(`Couldn't open help: ${e?.message || e}`, true);
1289
+ }
1290
+ });
1291
+ /** Render a markdown string in a centered modal. Click outside / Esc /
1292
+ * Close button dismiss. Lightweight markdown rendering — handles headings,
1293
+ * paragraphs, lists, tables, inline code, bold/italic. Intentionally
1294
+ * doesn't pull a full markdown library since this is one document. */
1295
+ function showEditorHelpModal(md) {
1296
+ if (document.getElementById("mailx-editor-help-modal"))
1297
+ return;
1298
+ const backdrop = document.createElement("div");
1299
+ backdrop.id = "mailx-editor-help-modal";
1300
+ backdrop.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10001;display:flex;align-items:center;justify-content:center;padding:24px;";
1301
+ const panel = document.createElement("div");
1302
+ panel.style.cssText = "background:var(--color-bg, #fff);color:var(--color-text, #000);border-radius:8px;max-width:780px;max-height:88vh;width:100%;display:flex;flex-direction:column;box-shadow:0 8px 32px rgba(0,0,0,0.4);";
1303
+ const header = document.createElement("div");
1304
+ header.style.cssText = "display:flex;justify-content:space-between;align-items:center;padding:12px 18px;border-bottom:1px solid var(--color-border, #ddd);";
1305
+ header.innerHTML = `<span style="font-weight:600;">Editor help</span><button id="mailx-editor-help-close" style="background:none;border:0;font-size:18px;cursor:pointer;color:var(--color-text);padding:2px 8px;" aria-label="Close">&times;</button>`;
1306
+ const body = document.createElement("div");
1307
+ body.style.cssText = "flex:1;overflow:auto;padding:18px 22px;font:14px/1.55 system-ui;";
1308
+ body.innerHTML = renderMarkdownLite(md);
1309
+ panel.appendChild(header);
1310
+ panel.appendChild(body);
1311
+ backdrop.appendChild(panel);
1312
+ document.body.appendChild(backdrop);
1313
+ const close = () => {
1314
+ backdrop.remove();
1315
+ document.removeEventListener("keydown", onKey, true);
1316
+ };
1317
+ const onKey = (e) => {
1318
+ if (e.key === "Escape") {
1319
+ e.stopPropagation();
1320
+ e.preventDefault();
1321
+ close();
1322
+ }
1323
+ };
1324
+ document.addEventListener("keydown", onKey, true);
1325
+ header.querySelector("#mailx-editor-help-close")?.addEventListener("click", close);
1326
+ backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
1327
+ close(); });
1328
+ }
1329
+ /** Compact markdown renderer. Handles headings, paragraphs, code blocks,
1330
+ * inline code, bold, italic, links, lists, and pipe-tables. Built for the
1331
+ * editor help doc shape — not a general-purpose markdown engine. */
1332
+ function renderMarkdownLite(md) {
1333
+ const esc = (s) => s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1334
+ const inline = (s) => esc(s)
1335
+ .replace(/`([^`]+)`/g, '<code style="background:var(--color-bg-surface,#f3f3f3);padding:1px 4px;border-radius:3px;">$1</code>')
1336
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
1337
+ .replace(/(?<![*\w])\*([^*\n]+)\*(?!\w)/g, "<em>$1</em>")
1338
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
1339
+ const lines = md.split(/\r?\n/);
1340
+ const out = [];
1341
+ let i = 0;
1342
+ while (i < lines.length) {
1343
+ const line = lines[i];
1344
+ const h = /^(#{1,6})\s+(.*)$/.exec(line);
1345
+ if (h) {
1346
+ out.push(`<h${h[1].length} style="margin:18px 0 8px 0;">${inline(h[2])}</h${h[1].length}>`);
1347
+ i++;
1348
+ continue;
1349
+ }
1350
+ if (/^\s*$/.test(line)) {
1351
+ i++;
1352
+ continue;
1353
+ }
1354
+ // Pipe-table
1355
+ if (line.includes("|") && /^\s*\|/.test(line)) {
1356
+ const rows = [];
1357
+ while (i < lines.length && /^\s*\|/.test(lines[i])) {
1358
+ rows.push(lines[i].trim().split("|").slice(1, -1).map(s => s.trim()));
1359
+ i++;
1360
+ }
1361
+ if (rows.length >= 2 && /^[\s\-:]+$/.test(rows[1].join(""))) {
1362
+ const head = rows[0];
1363
+ const data = rows.slice(2);
1364
+ out.push(`<table style="border-collapse:collapse;margin:8px 0;">`);
1365
+ out.push(`<thead><tr>${head.map(h => `<th style="border-bottom:2px solid var(--color-border,#ccc);padding:4px 10px;text-align:left;">${inline(h)}</th>`).join("")}</tr></thead>`);
1366
+ out.push(`<tbody>${data.map(r => `<tr>${r.map(c => `<td style="border-bottom:1px solid var(--color-border,#eee);padding:4px 10px;">${inline(c)}</td>`).join("")}</tr>`).join("")}</tbody>`);
1367
+ out.push(`</table>`);
1368
+ continue;
1369
+ }
1370
+ }
1371
+ // Bullet / numbered list
1372
+ if (/^\s*[-*]\s+/.test(line)) {
1373
+ const items = [];
1374
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
1375
+ items.push(lines[i].replace(/^\s*[-*]\s+/, ""));
1376
+ i++;
1377
+ }
1378
+ out.push(`<ul style="margin:6px 0 6px 22px;">${items.map(it => `<li style="margin:3px 0;">${inline(it)}</li>`).join("")}</ul>`);
1379
+ continue;
1380
+ }
1381
+ // Default: paragraph
1382
+ out.push(`<p style="margin:6px 0;">${inline(line)}</p>`);
1383
+ i++;
1384
+ }
1385
+ return out.join("\n");
1386
+ }
1387
+ /** Modal hint shown when Edit-in-Word launches. The user is about to lose
1388
+ * focus to Word and may not know what to do next, so spell it out: save in
1389
+ * Word → switch back here → click Send. Dismissed by Esc, the close button,
1390
+ * or any click outside the panel. */
1391
+ function showExternalEditHint(editorLabel) {
1392
+ if (document.getElementById("mailx-extedit-hint"))
1393
+ return; // don't stack
1394
+ const backdrop = document.createElement("div");
1395
+ backdrop.id = "mailx-extedit-hint";
1396
+ backdrop.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:10001;display:flex;align-items:center;justify-content:center;";
1397
+ const panel = document.createElement("div");
1398
+ panel.style.cssText = "background:var(--color-bg, #fff);color:var(--color-text, #000);border:1px solid var(--color-border, #ccc);border-radius:8px;padding:18px 22px;max-width:480px;box-shadow:0 8px 32px rgba(0,0,0,0.4);font:14px/1.5 system-ui;";
1399
+ panel.innerHTML = `
1400
+ <div style="font-weight:600;font-size:16px;margin-bottom:10px;">Editing in ${escapeHtml(editorLabel)}</div>
1401
+ <ol style="margin:0 0 12px 18px;padding:0;">
1402
+ <li>Edit your message in <b>${escapeHtml(editorLabel)}</b>.</li>
1403
+ <li>Save (<b>Ctrl+S</b>). Today, choose <b>"Web Page, Filtered (.htm)"</b> if Word asks for a format — keep the same filename.</li>
1404
+ <li>Switch back to this window (<b>Alt+Tab</b>). The body will reload here.</li>
1405
+ <li>Click <b>Send</b> in this window when ready.</li>
1406
+ </ol>
1407
+ <div style="text-align:right;margin-top:8px;">
1408
+ <button type="button" id="mailx-extedit-hint-ok" style="padding:6px 16px;border:1px solid var(--color-border, #ccc);background:var(--color-bg-surface, #f6f6f6);border-radius:4px;cursor:pointer;font:inherit;">Got it</button>
1409
+ </div>
1410
+ `;
1411
+ backdrop.appendChild(panel);
1412
+ document.body.appendChild(backdrop);
1413
+ const close = () => {
1414
+ backdrop.remove();
1415
+ document.removeEventListener("keydown", onKey, true);
1416
+ };
1417
+ const onKey = (e) => {
1418
+ if (e.key === "Escape") {
1419
+ e.stopPropagation();
1420
+ e.preventDefault();
1421
+ close();
1422
+ }
1423
+ };
1424
+ document.addEventListener("keydown", onKey, true);
1425
+ panel.querySelector("#mailx-extedit-hint-ok")?.addEventListener("click", close);
1426
+ // Click outside the panel (i.e. on the backdrop) dismisses.
1427
+ backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
1428
+ close(); });
1429
+ }
1178
1430
  // Listen for external-editor saves. Only react to events for this compose's
1179
1431
  // editId — multiple compose windows can be open and should not stomp each
1180
1432
  // other's bodies.