@bobfrankston/rmfmail 1.0.502 → 1.0.504

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 (63) hide show
  1. package/bin/mailx.js +9 -1
  2. package/bin/mailx.js.map +1 -1
  3. package/bin/mailx.ts +9 -1
  4. package/client/app.js +82 -32
  5. package/client/app.js.map +1 -1
  6. package/client/app.ts +88 -32
  7. package/client/components/message-viewer.js +51 -21
  8. package/client/components/message-viewer.js.map +1 -1
  9. package/client/components/message-viewer.ts +38 -19
  10. package/client/compose/compose.css +14 -0
  11. package/client/compose/compose.html +4 -3
  12. package/client/compose/compose.js +288 -36
  13. package/client/compose/compose.js.map +1 -1
  14. package/client/compose/compose.ts +270 -40
  15. package/client/compose/editor-help.js +107 -0
  16. package/client/compose/editor-help.js.map +1 -0
  17. package/client/compose/editor-help.ts +106 -0
  18. package/client/compose/editor.js +43 -0
  19. package/client/compose/editor.js.map +1 -1
  20. package/client/compose/editor.ts +22 -0
  21. package/client/index.html +1 -4
  22. package/docs/editor.md +92 -0
  23. package/package.json +10 -4
  24. package/packages/mailx-service/html-to-docx.d.ts +1 -0
  25. package/packages/mailx-service/index.d.ts +10 -2
  26. package/packages/mailx-service/index.d.ts.map +1 -1
  27. package/packages/mailx-service/index.js +143 -21
  28. package/packages/mailx-service/index.js.map +1 -1
  29. package/packages/mailx-service/index.ts +114 -21
  30. package/packages/mailx-service/package.json +5 -3
  31. package/packages/mailx-settings/docs/accounts.md +41 -0
  32. package/packages/mailx-settings/docs/allowlist.md +27 -0
  33. package/packages/mailx-settings/docs/clients.md +24 -0
  34. package/packages/mailx-settings/docs/config.md +32 -0
  35. package/packages/mailx-settings/docs/contact-rules.md +40 -0
  36. package/packages/mailx-settings/docs/contacts.md +48 -0
  37. package/packages/mailx-settings/docs/editor.md +92 -0
  38. package/packages/mailx-settings/docs/preferences.md +43 -0
  39. package/packages/mailx-settings/index.d.ts +2 -2
  40. package/packages/mailx-settings/index.d.ts.map +1 -1
  41. package/packages/mailx-settings/index.js +120 -23
  42. package/packages/mailx-settings/index.js.map +1 -1
  43. package/packages/mailx-settings/index.ts +98 -23
  44. package/packages/mailx-store/db.d.ts +8 -0
  45. package/packages/mailx-store/db.d.ts.map +1 -1
  46. package/packages/mailx-store/db.js +22 -0
  47. package/packages/mailx-store/db.js.map +1 -1
  48. package/packages/mailx-store/db.ts +22 -0
  49. package/packages/mailx-store/file-store.d.ts +31 -13
  50. package/packages/mailx-store/file-store.d.ts.map +1 -1
  51. package/packages/mailx-store/file-store.js +75 -31
  52. package/packages/mailx-store/file-store.js.map +1 -1
  53. package/packages/mailx-store/file-store.ts +72 -27
  54. package/packages/mailx-types/groups.d.ts +38 -0
  55. package/packages/mailx-types/groups.d.ts.map +1 -0
  56. package/packages/mailx-types/groups.js +99 -0
  57. package/packages/mailx-types/groups.js.map +1 -0
  58. package/packages/mailx-types/groups.ts +112 -0
  59. package/packages/mailx-types/index.d.ts +2 -0
  60. package/packages/mailx-types/index.d.ts.map +1 -1
  61. package/packages/mailx-types/index.js +5 -0
  62. package/packages/mailx-types/index.js.map +1 -1
  63. package/packages/mailx-types/index.ts +9 -0
@@ -678,16 +678,32 @@ export class MailxService {
678
678
  async openInWord(editId, html) {
679
679
  const dir = path.join(getConfigDir(), "external-edit");
680
680
  fs.mkdirSync(dir, { recursive: true });
681
- const filePath = path.join(dir, `${editId}.html`);
682
- // Wrap in a minimal HTML doc so Word picks up encoding + treats the
683
- // body content as the document. Word imports the <body> contents and
684
- // converts them to its own model; saving HTML preserves enough of
685
- // the structure for re-import (paragraphs, links, basic formatting).
681
+ // Pre-convert to .docx (Word's native format) so Save in Word writes
682
+ // back to the same file naturally no Save-As-Web-Page gymnastics.
683
+ // The .html-as-input flow had a 50% rate of "user saved but rmfmail
684
+ // didn't reload" because Word silently switched format to .docx.
685
+ const filePath = path.join(dir, `${editId}.docx`);
686
686
  const wrapped = `<!doctype html>
687
687
  <html><head><meta charset="utf-8"><title>mailx draft</title></head>
688
688
  <body>${html || "<p></p>"}</body></html>
689
689
  `;
690
- fs.writeFileSync(filePath, wrapped, "utf-8");
690
+ try {
691
+ const htmlToDocx = (await import("html-to-docx")).default;
692
+ const buf = await htmlToDocx(wrapped);
693
+ const out = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
694
+ fs.writeFileSync(filePath, out);
695
+ }
696
+ catch (e) {
697
+ // html-to-docx couldn't produce a docx (rare; usually CJK font
698
+ // fallback issues). Fall back to plain .html so the user still
699
+ // gets *something* — they'll need Save-As-Web-Page in Word but
700
+ // that's better than no edit path at all.
701
+ console.warn(` [word-edit] html-to-docx failed: ${e?.message || e} — falling back to .html`);
702
+ const htmlPath = path.join(dir, `${editId}.html`);
703
+ fs.writeFileSync(htmlPath, wrapped, "utf-8");
704
+ // Use the .html path instead of .docx for both launch and watch.
705
+ return this.openExternalHtmlFallback(editId, htmlPath);
706
+ }
691
707
  // Stop any existing watcher for this edit (re-open re-arms cleanly).
692
708
  this.wordEdits.get(editId)?.stop();
693
709
  // Try Word explicitly first; on failure (Word not installed, exec not
@@ -769,14 +785,19 @@ export class MailxService {
769
785
  // UI only reloads once per save. Watch the directory rather than the
770
786
  // file directly because Word writes via temp-file rename, which can
771
787
  // invalidate a file-level watch.
788
+ //
789
+ // On save: read the .docx, run mammoth to convert to HTML, emit the
790
+ // wordEditUpdated event. Mammoth is dynamically imported on first
791
+ // save so the cold-start cost is paid only when the user actually
792
+ // edits in Word — most sessions never hit it.
772
793
  let debounce = null;
773
794
  let lastSize = -1;
774
795
  const watcher = fs.watch(dir, (eventType, name) => {
775
- if (name !== `${editId}.html`)
796
+ if (name !== `${editId}.docx`)
776
797
  return;
777
798
  if (debounce)
778
799
  clearTimeout(debounce);
779
- debounce = setTimeout(() => {
800
+ debounce = setTimeout(async () => {
780
801
  let stat;
781
802
  try {
782
803
  stat = fs.statSync(filePath);
@@ -784,17 +805,28 @@ export class MailxService {
784
805
  catch {
785
806
  return;
786
807
  }
787
- // Skip duplicate events with the same size — Word fires several
788
- // change notifications per save and we only want one reload.
789
808
  if (stat.size === lastSize)
790
809
  return;
791
810
  lastSize = stat.size;
792
811
  try {
793
- const updatedHtml = fs.readFileSync(filePath, "utf-8");
794
- // Strip the wrapper to extract just the body content.
795
- const bodyMatch = updatedHtml.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
796
- const inner = bodyMatch ? bodyMatch[1] : updatedHtml;
812
+ const buf = fs.readFileSync(filePath);
813
+ const mammoth = (await import("mammoth")).default;
814
+ const result = await mammoth.convertToHtml({ buffer: buf });
815
+ let inner = result.value || "";
816
+ // mammoth returns body-content HTML (no <html><body> wrapper).
817
+ // Defensive: if a wrapper sneaks in, strip it.
818
+ const bodyMatch = inner.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
819
+ if (bodyMatch)
820
+ inner = bodyMatch[1];
797
821
  this.imapManager.emit("wordEditUpdated", { editId, html: inner });
822
+ // Bring the rmfmail compose window to front so the user
823
+ // doesn't need to Alt+Tab — saves a step in the round-trip.
824
+ try {
825
+ const host = await import("@bobfrankston/mailx-host");
826
+ if (host.focusMainWindow)
827
+ host.focusMainWindow();
828
+ }
829
+ catch { /* host doesn't expose focus yet — non-fatal */ }
798
830
  }
799
831
  catch (e) {
800
832
  console.error(` [word-edit] read after save failed: ${e.message}`);
@@ -812,6 +844,80 @@ export class MailxService {
812
844
  this.wordEdits.set(editId, { path: filePath, stop });
813
845
  return { ok: opener !== "none", path: filePath, opener };
814
846
  }
847
+ /** Fallback path used when html-to-docx fails. Reproduces the old
848
+ * .html-based flow: writes <editId>.html, watches it, emits the body
849
+ * content on save. User has to Save-As-Web-Page in Word for the file
850
+ * to update — but at least the edit path works. */
851
+ async openExternalHtmlFallback(editId, filePath) {
852
+ const dir = path.dirname(filePath);
853
+ this.wordEdits.get(editId)?.stop();
854
+ const { spawn } = await import("node:child_process");
855
+ const tryLaunch = (cmd, args) => {
856
+ try {
857
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
858
+ child.on("error", () => { });
859
+ child.unref();
860
+ return true;
861
+ }
862
+ catch {
863
+ return false;
864
+ }
865
+ };
866
+ let opener = "none";
867
+ if (process.platform === "win32") {
868
+ if (tryLaunch("cmd", ["/c", "start", "", "/B", "winword.exe", filePath]))
869
+ opener = "word";
870
+ else if (tryLaunch("cmd", ["/c", "start", "", filePath]))
871
+ opener = "default";
872
+ }
873
+ else if (process.platform === "darwin") {
874
+ if (tryLaunch("open", ["-a", "Microsoft Word", filePath]))
875
+ opener = "word";
876
+ else if (tryLaunch("open", [filePath]))
877
+ opener = "default";
878
+ }
879
+ else {
880
+ if (tryLaunch("xdg-open", [filePath]))
881
+ opener = "default";
882
+ }
883
+ let debounce = null;
884
+ let lastSize = -1;
885
+ const fileName = path.basename(filePath);
886
+ const watcher = fs.watch(dir, (_evt, name) => {
887
+ if (name !== fileName)
888
+ return;
889
+ if (debounce)
890
+ clearTimeout(debounce);
891
+ debounce = setTimeout(() => {
892
+ let stat;
893
+ try {
894
+ stat = fs.statSync(filePath);
895
+ }
896
+ catch {
897
+ return;
898
+ }
899
+ if (stat.size === lastSize)
900
+ return;
901
+ lastSize = stat.size;
902
+ try {
903
+ const updatedHtml = fs.readFileSync(filePath, "utf-8");
904
+ const bodyMatch = updatedHtml.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
905
+ const inner = bodyMatch ? bodyMatch[1] : updatedHtml;
906
+ this.imapManager.emit("wordEditUpdated", { editId, html: inner });
907
+ }
908
+ catch (e) {
909
+ console.error(` [word-edit fallback] read failed: ${e.message}`);
910
+ }
911
+ }, 300);
912
+ });
913
+ const stop = () => { try {
914
+ watcher.close();
915
+ }
916
+ catch { /* */ } if (debounce)
917
+ clearTimeout(debounce); };
918
+ this.wordEdits.set(editId, { path: filePath, stop });
919
+ return { ok: opener !== "none", path: filePath, opener };
920
+ }
815
921
  /** End external editing. Stops the watcher, removes the temp file.
816
922
  * Caller is the compose UI when the user closes the window or sends. */
817
923
  async closeWordEdit(editId) {
@@ -2188,19 +2294,35 @@ export class MailxService {
2188
2294
  });
2189
2295
  return applyEdits(content, edits);
2190
2296
  }
2191
- /** Return the help section for a named config file, extracted from docs/config-help.md.
2192
- * Matches a level-2 heading whose text equals the filename. Returns markdown. */
2297
+ /** Return the help markdown for a named config file. Prefers a per-file
2298
+ * doc (`<name>.md` minus the `.jsonc` suffix e.g. `contacts.md` for
2299
+ * `contacts.jsonc`) shipped in the package's `docs/` dir; falls back to
2300
+ * the legacy `config-help.md`-with-sections format if the per-file
2301
+ * isn't available. */
2193
2302
  async readConfigHelp(name) {
2194
- const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
2303
+ const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc", "preferences.jsonc"];
2195
2304
  if (!WHITELIST.includes(name))
2196
2305
  return "";
2197
- // Look in the repo root (dev) and in the installed package dir (production).
2198
- const candidates = [
2306
+ const stem = name.replace(/\.jsonc$/, "");
2307
+ // Per-file: `accounts.md`, `contacts.md`, etc. — preferred.
2308
+ const perFileCandidates = [
2309
+ path.join(__dirname, "..", "..", "docs", `${stem}.md`), // workspace dev
2310
+ path.join(__dirname, "docs", `${stem}.md`), // installed pkg (prepack copies here)
2311
+ path.join(__dirname, "..", "docs", `${stem}.md`),
2312
+ ];
2313
+ for (const p of perFileCandidates) {
2314
+ try {
2315
+ return fs.readFileSync(p, "utf-8").trim();
2316
+ }
2317
+ catch { /* try next */ }
2318
+ }
2319
+ // Legacy fallback: single config-help.md with `## <filename>` sections.
2320
+ const legacyCandidates = [
2199
2321
  path.join(__dirname, "..", "..", "docs", "config-help.md"),
2200
2322
  path.join(__dirname, "config-help.md"),
2201
2323
  ];
2202
2324
  let md = "";
2203
- for (const p of candidates) {
2325
+ for (const p of legacyCandidates) {
2204
2326
  try {
2205
2327
  md = fs.readFileSync(p, "utf-8");
2206
2328
  break;
@@ -2216,7 +2338,7 @@ export class MailxService {
2216
2338
  const h2 = /^##\s+(.+?)\s*$/.exec(line);
2217
2339
  if (h2) {
2218
2340
  if (inSection)
2219
- break; // next section — stop
2341
+ break;
2220
2342
  if (h2[1].trim() === name) {
2221
2343
  inSection = true;
2222
2344
  continue;