@bobfrankston/rmfmail 1.0.676 → 1.0.678

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/TODO.md CHANGED
@@ -237,7 +237,17 @@ Previously shown as showstoppers; moved here because they haven't recurred on re
237
237
  Small, self-contained items. Pick them up between higher-priority blocks without asking. Bump version per fix.
238
238
 
239
239
  - ~~**Q138 — Quoted-reply image sizes lost.**~~ DONE 2026-05-11 in `client/app.ts:1000` — strip skips `<img>`. Two-pass loop handles multiple stripped attrs per tag.
240
- - **Q140 — User-configurable holiday calendars (generalization).** Today the holiday-pull list is hardcoded to two Google calendars: `en.usa#holiday` and `en.jewish#holiday`, each with its own checkbox in the sidebar. Bob 2026-05-11: "Static calendars are OK. You can add generalization to the maybe-future list. Unlikely to do unless I want a more general audience." When that audience expands, generalize to `settings.calendar.holidayCalendars: Array<{ id: string; label: string; enabled: boolean }>`. The refresh loop iterates the array; the sidebar renders one checkbox per entry; a small editor in Settings → calendar lets the user add/remove entries by calendar ID (Google publishes `<locale>.<country>#holiday@group.v.calendar.google.com`, `en.christian#holiday`, etc.). Schema-side `calendar_id` column already partitions rows by source, so toggling one off doesn't affect the others — the existing reconcile logic carries straight over.
240
+ - **Q140 — User-configurable holiday calendars (generalization).** Today the holiday-pull list is hardcoded to two Google calendars: `en.usa.official#holiday` (federal-secular) and `en.judaism#holiday` (Jewish), each with its own sidebar checkbox + leading symbol (🇺🇸 / ✡). Bob 2026-05-11: "Static calendars are OK. You can add generalization to the maybe-future list. Unlikely to do unless I want a more general audience." When that audience expands, generalize to `settings.calendar.holidayCalendars: Array<{ id: string; label: string; symbol: string; enabled: boolean }>`. The refresh loop iterates the array; the sidebar renders one checkbox per entry plus its symbol on each row; a small editor in Settings → calendar lets the user add/remove entries by calendar ID. Schema-side `calendar_id` column already partitions rows by source, so toggling one off doesn't affect the others — the existing reconcile logic carries straight over.
241
+
242
+ **Curation gotcha worth carrying into the implementation**: Google's `<locale>.<tradition>#holiday@group.v.calendar.google.com` IDs are NOT all clean per-tradition feeds. The "obvious" IDs are mis-curated catch-alls; the `.official` / proper-noun variant is the actually-clean source for each tradition. Documented examples from 2026-05-11 testing:
243
+ - `en.usa#holiday` — mixed (federal + Christian + Jewish + Orthodox + Armenian + Ethiopian Jewish + …)
244
+ - `en.usa.official#holiday` — clean federal-secular
245
+ - `en.jewish#holiday` — mixed (Jewish + Christian + Orthodox neighbors)
246
+ - `en.judaism#holiday` — clean Jewish-only
247
+
248
+ When exposing the calendar picker to users, ship a curated allowlist of vetted IDs rather than a free-form text input, OR show a warning when a user enters a non-`.official` / non-proper-noun ID. The naming asymmetry is on Google.
249
+
250
+ Alternative source worth evaluating before generalizing: **Hebcal** (`https://www.hebcal.com/`) publishes a more rigorously curated Jewish-only feed (iCal + JSON API), and similar projects exist for other traditions. A `JsonHolidayProvider` interface that abstracts Google vs Hebcal vs ICS-by-URL behind a common shape would let users plug in whatever feed they trust.
241
251
  - **Q139 — General app-message channel from daemon to msger popups.** Bob 2026-05-11: "when an exit handler kills the popups by sending them a message — and can there be a general app message?" Today reminder popups are one-shot `showMessageBoxEx` (we have the handle for cross-platform reap). For graceful close-via-message AND other patterns (theme changes, in-flight content updates), spawn popups in `service: true` mode and use the bidirectional pipe — daemon calls `handle.send({type: "close"})` and the popup's JS listens for `_msgapiServiceEvent` events and reacts (`window.close()`, refresh content, etc.). Reuses the same wire mailx's main WebView already uses. Adapter shape: `showServicePopup(opts)` in `mailx-host` returns a `ServiceHandle` + a `result: Promise`; popup HTML's `<script>` registers `window._msgapiServiceEvent = (msg) => { if (msg.type === "close") window.close(); /* future: themeChanged, eventUpdated, … */ }`. Then `gracefulShutdown` in `bin/mailx.ts` iterates open popup handles and sends `{type:"close"}` instead of relying on the OS reaping. Pre-req: confirm msger's service mode supports the `rawHtml` content shape we use for reminders (may need a small msger PR).
242
252
  - **Q138 (original entry) — Quoted-reply image sizes lost.** Bob 2026-05-10: in the reply quote of a marketing email (Qatar Airways), the App Store / Google Play / AppGallery button images render larger than in the original. Cause: `sanitizeQuotedBody` in `client/app.ts` strips `width` / `height` attributes (along with `align`, `valign`, `bgcolor`, `cellpadding`, `cellspacing`, `border`) to flatten marketing-email layout tables. Stripping those attrs from `<img>` is collateral damage — table-only attributes there, but they also size images. Fix: in the regex, scope the attr strip to non-img tags, OR leave width/height alone everywhere (the layout flattening doesn't actually need them on tables either — `<table>→<div>` rewrites are doing the heavy lifting). ~10 line change. Low priority — visual nit, not data loss.
243
253
  - **Q136 — Server / provider capability matrix (forward backlog).** Catalog of server-specific features mailx could exploit. Detection is dynamic everywhere — IMAP via `CAPABILITY` + RFC 2971 `ID`, non-IMAP via provider-type at account setup. Each row below is its own future work item; this entry is the index.
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Copy `dictionary-en/index.aff` + `index.dic` into `client/lib/dict/`
4
+ * so the compose iframe can `fetch()` them via msger's custom protocol
5
+ * (msger only serves files under `contentDir = client/`). Done at build
6
+ * time rather than runtime so a fresh install doesn't need to read the
7
+ * dictionary from node_modules — the published package ships the dict.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+
12
+ const root = path.resolve(import.meta.dirname, "..");
13
+ const src = path.join(root, "node_modules", "dictionary-en");
14
+ const dst = path.join(root, "client", "lib", "dict");
15
+
16
+ if (!fs.existsSync(path.join(src, "index.aff"))) {
17
+ console.error(`build-spellcheck-dict: source not found at ${src} — run npm install first`);
18
+ process.exit(1);
19
+ }
20
+ fs.mkdirSync(dst, { recursive: true });
21
+ for (const name of ["index.aff", "index.dic"]) {
22
+ const dstName = name.replace("index", "en");
23
+ fs.copyFileSync(path.join(src, name), path.join(dst, dstName));
24
+ }
25
+ console.log(`build-spellcheck-dict: copied dictionary-en → ${path.relative(root, dst)}/{en.aff,en.dic}`);
@@ -3558,7 +3558,7 @@ function renderEvents(events) {
3558
3558
  const cid = (e.calendarId || "").toLowerCase();
3559
3559
  if (cid.includes("usa"))
3560
3560
  holidayKind = "us";
3561
- else if (cid.includes("jewish"))
3561
+ else if (cid.includes("judaism") || cid.includes("jewish"))
3562
3562
  holidayKind = "jewish";
3563
3563
  else
3564
3564
  holidayKind = "other";
@@ -3970,11 +3970,15 @@ function initCalendarSidebar() {
3970
3970
  if (!cb || cb.__wired)
3971
3971
  return;
3972
3972
  cb.__wired = true;
3973
+ let userTouched = false;
3973
3974
  getSettings().then((s) => {
3975
+ if (userTouched)
3976
+ return;
3974
3977
  cb.checked = !!s?.calendar?.[settingsKey];
3975
3978
  }).catch(() => {
3976
3979
  });
3977
3980
  cb.addEventListener("change", async () => {
3981
+ userTouched = true;
3978
3982
  try {
3979
3983
  const s = await getSettings();
3980
3984
  s.calendar = { ...s.calendar || {}, [settingsKey]: cb.checked };
@@ -6419,25 +6423,18 @@ function addComposeResizeHandles(wrapper, frame) {
6419
6423
  }
6420
6424
  }
6421
6425
  function sanitizeQuotedBody(msg) {
6422
- let body = msg.bodyHtml || `<div style="white-space:pre-wrap;font-family:inherit;margin:0">${msg.bodyText || ""}</div>`;
6423
- body = body.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
6424
- body = body.replace(/\s+style="[^"]*"/gi, "");
6425
- body = body.replace(/\s+class="[^"]*"/gi, "");
6426
- body = body.replace(
6427
- /<(?!img\b)([^>]*?)\s+(width|height|align|valign|bgcolor|cellpadding|cellspacing|border)="[^"]*"([^>]*)>/gi,
6428
- (_m, before, _attr, after) => `<${before}${after}>`
6429
- );
6430
- let prev = "";
6431
- while (prev !== body) {
6432
- prev = body;
6433
- body = body.replace(
6434
- /<(?!img\b)([^>]*?)\s+(width|height|align|valign|bgcolor|cellpadding|cellspacing|border)="[^"]*"([^>]*)>/gi,
6435
- (_m, before, _attr, after) => `<${before}${after}>`
6436
- );
6426
+ const isPlainText = !msg.bodyHtml;
6427
+ if (isPlainText) {
6428
+ const escaped = String(msg.bodyText || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
6429
+ return `<div style="white-space:pre-wrap;font-family:inherit;margin:0">${escaped}</div>`;
6437
6430
  }
6438
- body = body.replace(/<table[^>]*>/gi, "<div>").replace(/<\/table>/gi, "</div>");
6439
- body = body.replace(/<t[rdh][^>]*>/gi, "").replace(/<\/t[rdh]>/gi, " ");
6440
- body = body.replace(/<thead[^>]*>|<\/thead>|<tbody[^>]*>|<\/tbody>/gi, "");
6431
+ let body = msg.bodyHtml;
6432
+ body = body.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "");
6433
+ body = body.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
6434
+ body = body.replace(/<link[^>]*>/gi, "");
6435
+ body = body.replace(/<base[^>]*>/gi, "");
6436
+ body = body.replace(/\s+on\w+="[^"]*"/gi, "");
6437
+ body = body.replace(/\s+on\w+='[^']*'/gi, "");
6441
6438
  return body;
6442
6439
  }
6443
6440
  function quoteBody(msg) {