@bobfrankston/rmfmail 1.1.199 → 1.1.201

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
@@ -236,10 +236,12 @@ Previously shown as showstoppers; moved here because they haven't recurred on re
236
236
 
237
237
  Small, self-contained items. Pick them up between higher-priority blocks without asking. Bump version per fix.
238
238
 
239
+ - ~~**Q152 — "Edit as new message" / resend on a sent (or any) message.**~~ **DONE 2026-05-31.** New `editAsNew` compose mode in `client/app.ts` (`openCompose` branch + `editAsNewBody` helper) clones the selected message into a fresh compose: original To/Cc/Subject verbatim, body via `sanitizeQuotedBody` with no quote/forward wrapper, no In-Reply-To/References (new Message-ID at send). Right-click menu entry "Edit as new message" added after Forward in `message-list.ts`. Replaces the move-to-Drafts kludge. *(Original entry below.)* Bob 2026-05-29: no clean way to take an already-sent message, tweak it, and send it again — today's only path is move-to-Drafts-then-edit (a kludge) or Forward (adds `Fwd:` + quote wrapper, wrong recipients). Add an **"Edit as new message"** affordance — in the viewer toolbar (next to Forward) and/or the message-list right-click menu — that loads the selected message's `.eml` **straight into a fresh compose** as the author: original To/Cc/Subject/body editable, **no `Fwd:`/`Re:` prefix, no quote indent, no In-Reply-To/References threading headers**, a new Message-ID. Effectively "duplicate into compose." Distinct from the existing Drafts `Edit & Send` (that edits the draft in place); this clones any message into a new outgoing draft. Reuse the compose-init plumbing the draft-edit path already uses (`showComposeOverlay` + the init payload built in `app.ts`); the only difference is which headers get stripped vs. carried. Most natural as the message-viewer From/To right-click "duplicate"-style action plus a toolbar button gated on non-draft messages. [S]
240
+ - ~~**Preview CSS leak — `<style>`/`<head>` contents bled into the message-list one-liner.**~~ DONE 2026-05-31. `extractPreview` (`mailx-imap/index.ts`) flattened HTML by stripping tags only, leaving the CSS *between* `<style>…</style>` (and `<head>`/`<script>`) in the preview as `*{box-sizing…}` / `@media…{…}` garbage (Bob's marketing-mail shot). Now strips those block contents + HTML comments before the tag-strip; `[image]` marker preserved. IMAP-path only (Gmail uses Google's clean snippet). Existing rows refresh via `rmfmail -repair`.
239
241
  - ~~**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.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.
242
+ - ~~**Q140 — User-configurable holiday calendars (generalization).**~~ **RETIRED 2026-05-29 already dynamic, superseded by Q143.** Bob: the holiday list is pulled automatically from whatever calendars the user has selected in Google Calendar, so it's dynamic already. Confirmed in code: `refreshCalendarEvents` enumerates `listCalendars().filter(c => c.selected)` and tags each row with its source; the old hardcoded `HOLIDAY_SOURCES` + `showHolidays`/`showJewishHolidays` toggles are retired (`mailx-service/index.ts:1485`). The user curates the set in Google's web UI no in-app holiday-calendar editor needed. The curation-gotcha + Hebcal notes below are kept only as reference IF a free-form calendar-ID *picker* is ever added (not currently plannedselection happens in Google, not mailx).
241
243
 
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:
244
+ **Curation gotcha (reference only)**: 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
245
  - `en.usa#holiday` — mixed (federal + Christian + Jewish + Orthodox + Armenian + Ethiopian Jewish + …)
244
246
  - `en.usa.official#holiday` — clean federal-secular
245
247
  - `en.jewish#holiday` — mixed (Jewish + Christian + Orthodox neighbors)
@@ -2595,10 +2595,29 @@ __export(message_list_exports, {
2595
2595
  reloadCurrentFolder: () => reloadCurrentFolder,
2596
2596
  removeMessagesAndReconcile: () => removeMessagesAndReconcile,
2597
2597
  scrollFocusedIntoView: () => scrollFocusedIntoView,
2598
+ setLiveFilter: () => setLiveFilter,
2598
2599
  setRowFlagged: () => setRowFlagged,
2599
2600
  setRowSeen: () => setRowSeen,
2600
2601
  showThreadPopup: () => showThreadPopup
2601
2602
  });
2603
+ function setLiveFilter(query) {
2604
+ liveFilterText = (query || "").trim().toLowerCase();
2605
+ const body = document.getElementById("ml-body");
2606
+ if (body)
2607
+ applyLiveFilter(body);
2608
+ }
2609
+ function applyLiveFilter(body) {
2610
+ if (!liveFilterText) {
2611
+ for (const row of body.querySelectorAll(".ml-row.filter-hidden")) {
2612
+ row.classList.remove("filter-hidden");
2613
+ }
2614
+ return;
2615
+ }
2616
+ for (const row of body.querySelectorAll(".ml-row")) {
2617
+ const text = row.textContent?.toLowerCase() || "";
2618
+ row.classList.toggle("filter-hidden", !text.includes(liveFilterText));
2619
+ }
2620
+ }
2602
2621
  function cacheKey(mode, a, f, flagged, q) {
2603
2622
  if (mode === "folder")
2604
2623
  return `folder:${a}:${f}:${flagged ? "flag" : ""}`;
@@ -3034,6 +3053,8 @@ function removeMessagesAndReconcile(uids) {
3034
3053
  function reloadCurrentFolder() {
3035
3054
  if (searchMode)
3036
3055
  return;
3056
+ if (liveFilterText)
3057
+ return;
3037
3058
  if (unifiedMode) {
3038
3059
  loadUnifiedInbox(false);
3039
3060
  } else if (currentAccountId2 && currentFolderId) {
@@ -3042,6 +3063,7 @@ function reloadCurrentFolder() {
3042
3063
  }
3043
3064
  function clearSearchMode() {
3044
3065
  searchMode = false;
3066
+ liveFilterText = "";
3045
3067
  currentSearchQuery = "";
3046
3068
  if (wasUnifiedBeforeSearch)
3047
3069
  unifiedMode = true;
@@ -3114,6 +3136,7 @@ async function loadSearchResults(query, scope = "all", accountId = "", folderId
3114
3136
  if (!searchMode)
3115
3137
  wasUnifiedBeforeSearch = unifiedMode;
3116
3138
  searchMode = true;
3139
+ liveFilterText = "";
3117
3140
  unifiedMode = false;
3118
3141
  currentSearchQuery = query;
3119
3142
  currentPage = 1;
@@ -3307,6 +3330,8 @@ function renderMessages(body, accountId, items) {
3307
3330
  focusedRow = null;
3308
3331
  }
3309
3332
  }
3333
+ if (liveFilterText)
3334
+ applyLiveFilter(body);
3310
3335
  }
3311
3336
  function selectFirst(body) {
3312
3337
  if (window.innerWidth <= 768)
@@ -3478,7 +3503,7 @@ function escapeHtml2(s) {
3478
3503
  div.textContent = s;
3479
3504
  return div.innerHTML;
3480
3505
  }
3481
- var onMessageSelect, currentAccountId2, currentFolderId, currentSpecialUse, lastClickedRow, currentPage, totalMessages, loading, unifiedMode, searchMode, currentSearchQuery, wasUnifiedBeforeSearch, showToInsteadOfFrom, touchWasScroll, currentSort, currentSortDir, loadGen, listCache, CACHE_KEY_UNIFIED, positionMemory, POSITION_STORAGE_KEY, focusedRow, rowByKey, prioritySenders, priorityDomains, timeFmt, dateFmt, dateFmtSameYear, MessageRow;
3506
+ var onMessageSelect, currentAccountId2, currentFolderId, currentSpecialUse, lastClickedRow, currentPage, totalMessages, loading, unifiedMode, searchMode, liveFilterText, currentSearchQuery, wasUnifiedBeforeSearch, showToInsteadOfFrom, touchWasScroll, currentSort, currentSortDir, loadGen, listCache, CACHE_KEY_UNIFIED, positionMemory, POSITION_STORAGE_KEY, focusedRow, rowByKey, prioritySenders, priorityDomains, timeFmt, dateFmt, dateFmtSameYear, MessageRow;
3482
3507
  var init_message_list = __esm({
3483
3508
  "client/components/message-list.js"() {
3484
3509
  "use strict";
@@ -3493,6 +3518,7 @@ var init_message_list = __esm({
3493
3518
  loading = false;
3494
3519
  unifiedMode = false;
3495
3520
  searchMode = false;
3521
+ liveFilterText = "";
3496
3522
  currentSearchQuery = "";
3497
3523
  wasUnifiedBeforeSearch = false;
3498
3524
  showToInsteadOfFrom = false;
@@ -3983,6 +4009,7 @@ var init_message_list = __esm({
3983
4009
  { label: "Reply", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "reply" } })) },
3984
4010
  { label: "Reply All", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "replyAll" } })) },
3985
4011
  { label: "Forward", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "forward" } })) },
4012
+ { label: "Edit as new message", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "editAsNew" } })) },
3986
4013
  { label: "", action: () => {
3987
4014
  }, separator: true },
3988
4015
  {
@@ -7434,13 +7461,13 @@ document.getElementById("btn-factory-reset")?.addEventListener("click", async ()
7434
7461
  async function openCompose(mode, overrideMsg, overrideAccountId) {
7435
7462
  logClientEvent("openCompose-entry", { mode });
7436
7463
  const current = overrideMsg ? { message: overrideMsg, accountId: overrideAccountId || currentAccountId3 } : getCurrentMessage();
7437
- if ((mode === "reply" || mode === "replyAll" || mode === "forward") && !current) {
7464
+ if ((mode === "reply" || mode === "replyAll" || mode === "forward" || mode === "editAsNew") && !current) {
7438
7465
  console.warn(`[compose] ${mode} \u2014 no message selected`);
7439
7466
  return;
7440
7467
  }
7441
7468
  const accountsP = getAccounts();
7442
7469
  const msg = current?.message;
7443
- const titlePrefix = mode === "reply" ? "Reply" : mode === "replyAll" ? "Reply All" : mode === "forward" ? "Forward" : "Compose";
7470
+ const titlePrefix = mode === "reply" ? "Reply" : mode === "replyAll" ? "Reply All" : mode === "forward" ? "Forward" : mode === "editAsNew" ? "Edit as new" : "Compose";
7444
7471
  const titleSubject = mode === "new" ? "" : msg?.subject || "";
7445
7472
  const frame = showComposeOverlay(titleSubject ? `${titlePrefix}: ${titleSubject}` : titlePrefix);
7446
7473
  const accounts = await accountsP;
@@ -7548,6 +7575,11 @@ async function openCompose(mode, overrideMsg, overrideAccountId) {
7548
7575
  init.subject = `Fwd: ${cleanSubject}`;
7549
7576
  init.bodyHtml = forwardBody(msg);
7550
7577
  init.fromAddress = detectReplyFrom();
7578
+ } else if (msg && mode === "editAsNew") {
7579
+ init.to = Array.isArray(msg.to) ? msg.to : [];
7580
+ init.cc = Array.isArray(msg.cc) ? msg.cc : [];
7581
+ init.subject = msg.subject || "";
7582
+ init.bodyHtml = editAsNewBody(msg);
7551
7583
  }
7552
7584
  const initJson = JSON.stringify(init);
7553
7585
  try {
@@ -7795,6 +7827,9 @@ function quoteBody(msg) {
7795
7827
  const body = sanitizeQuotedBody(msg);
7796
7828
  return `<p></p><br><br><div class="reply"><p>On ${date}, ${from} wrote:</p><blockquote>${body}</blockquote></div>`;
7797
7829
  }
7830
+ function editAsNewBody(msg) {
7831
+ return `<p></p>${sanitizeQuotedBody(msg)}`;
7832
+ }
7798
7833
  function forwardBody(msg) {
7799
7834
  const date = new Date(msg.date).toLocaleString();
7800
7835
  const from = msg.from.name ? `${msg.from.name} &lt;${msg.from.address}&gt;` : msg.from.address;
@@ -8381,14 +8416,7 @@ function doSearch(immediate = false) {
8381
8416
  }
8382
8417
  cancelServerSearch();
8383
8418
  if (localScope === "current" && !serverOn && !immediate) {
8384
- const body = document.getElementById("ml-body");
8385
- if (body) {
8386
- const lower = query.toLowerCase();
8387
- for (const row of body.querySelectorAll(".ml-row")) {
8388
- const text = row.textContent?.toLowerCase() || "";
8389
- row.classList.toggle("filter-hidden", !text.includes(lower));
8390
- }
8391
- }
8419
+ setLiveFilter(query);
8392
8420
  return;
8393
8421
  }
8394
8422
  const localScopeEff = localScope === "current" ? "current" : "all";