@bobfrankston/rmfmail 1.2.274 → 1.2.276

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 (33) hide show
  1. package/.commitmsg +26 -0
  2. package/client/android-bootstrap.bundle.js +169 -0
  3. package/client/android-bootstrap.bundle.js.map +2 -2
  4. package/client/app.bundle.js +123 -23
  5. package/client/app.bundle.js.map +4 -4
  6. package/client/app.js +16 -1
  7. package/client/app.js.map +1 -1
  8. package/client/app.ts +16 -1
  9. package/client/components/invite-card.js +107 -0
  10. package/client/components/invite-card.js.map +1 -0
  11. package/client/components/invite-card.ts +119 -0
  12. package/client/components/message-viewer.js +42 -17
  13. package/client/components/message-viewer.js.map +1 -1
  14. package/client/components/message-viewer.ts +42 -16
  15. package/client/package.json +1 -1
  16. package/client/styles/components.css +48 -0
  17. package/package.json +8 -8
  18. package/packages/mailx-imap/package-lock.json +2 -2
  19. package/packages/mailx-imap/package.json +2 -2
  20. package/packages/mailx-settings/package.json +1 -1
  21. package/packages/mailx-store/package.json +1 -1
  22. package/packages/mailx-store/store.d.ts +5 -0
  23. package/packages/mailx-store/store.d.ts.map +1 -1
  24. package/packages/mailx-store/store.js +21 -0
  25. package/packages/mailx-store/store.js.map +1 -1
  26. package/packages/mailx-store/store.ts +27 -0
  27. package/packages/mailx-types/index.d.ts +56 -0
  28. package/packages/mailx-types/index.d.ts.map +1 -1
  29. package/packages/mailx-types/index.js +187 -0
  30. package/packages/mailx-types/index.js.map +1 -1
  31. package/packages/mailx-types/index.ts +192 -0
  32. package/packages/mailx-types/package.json +1 -1
  33. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-31304 → node_modules.npmglobalize-stash-59216}/.package-lock.json +0 -0
package/client/app.ts CHANGED
@@ -156,6 +156,9 @@ propagateAppName();
156
156
  let baseTitle = APP_NAME;
157
157
  let lastSeenCount = 0;
158
158
  let badgeCount = 0;
159
+ /** Last count pushed to the Windows taskbar overlay, so an unchanged count
160
+ * is not re-pushed. -1 = nothing pushed yet. See updateBadge. */
161
+ let lastOverlayCount = -1;
159
162
 
160
163
  function updateBadge(count: number): void {
161
164
  badgeCount = count;
@@ -211,7 +214,19 @@ function updateBadge(count: number): void {
211
214
  // taskbar for the whole life of this feature (Bob 2026-05-28 "are you
212
215
  // able to show the number of new messages").
213
216
  const hostApi: any = (window as any).mailxapi;
214
- if (hostApi?.setTaskbarOverlay) {
217
+ // Push ONLY when the number actually changes. Windows keeps the
218
+ // overlay icon alive until the next SetOverlayIcon, so the host
219
+ // cannot free the icon it just handed over — each push costs the
220
+ // host process one HICON plus the two bitmaps inside it, and the
221
+ // unread-count refresh fires every few seconds. That reached the
222
+ // 10,000-object per-process GDI cap in about a day, after which the
223
+ // host logged "CreateIconIndirect failed: Not enough memory
224
+ // resources" on every tick and could create no further GDI objects
225
+ // (Bob 2026-08-23, measured: rmfmail.exe at GDI=9995). msger now
226
+ // frees the PREVIOUS icon so the leak is bounded either way; this
227
+ // just stops the pointless churn (Claude Code 2026-08-23).
228
+ if (hostApi?.setTaskbarOverlay && count !== lastOverlayCount) {
229
+ lastOverlayCount = count;
215
230
  if (count > 0) {
216
231
  // BADGE-ONLY overlay. SetOverlayIcon composites the supplied
217
232
  // icon onto the bottom-right corner of the taskbar button —
@@ -0,0 +1,107 @@
1
+ import { inviteHasGoogleRsvp } from "@bobfrankston/mailx-types";
2
+ const PARTSTAT_LABEL = {
3
+ "ACCEPTED": "accepted",
4
+ "DECLINED": "declined",
5
+ "TENTATIVE": "maybe",
6
+ "NEEDS-ACTION": "no reply yet",
7
+ "DELEGATED": "delegated",
8
+ };
9
+ /** Format the event's span. An invite states wall-clock plus a zone; showing
10
+ * it in the reader's zone is the useful reading, but when the two differ the
11
+ * original is shown too — a 08:00 America/Los_Angeles meeting read as 11:00
12
+ * local is exactly where people mis-book. */
13
+ function formatWhen(inv) {
14
+ if (!inv.start)
15
+ return "";
16
+ if (inv.allDay) {
17
+ try {
18
+ const d = new Date(inv.start + "T00:00:00");
19
+ return d.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" }) + " (all day)";
20
+ }
21
+ catch {
22
+ return inv.start + " (all day)";
23
+ }
24
+ }
25
+ // A zoned wall-clock has no offset, so Date would read it as LOCAL time.
26
+ // Resolve it through the stated zone to get the real instant.
27
+ const instant = (() => {
28
+ if (/Z$/.test(inv.start))
29
+ return new Date(inv.start);
30
+ if (!inv.tzid)
31
+ return new Date(inv.start);
32
+ try {
33
+ // Probe: what does this wall-clock read as in the stated zone?
34
+ const guess = new Date(inv.start + "Z");
35
+ const asZone = new Date(guess.toLocaleString("en-US", { timeZone: inv.tzid }));
36
+ const asUtc = new Date(guess.toLocaleString("en-US", { timeZone: "UTC" }));
37
+ return new Date(guess.getTime() + (asUtc.getTime() - asZone.getTime()));
38
+ }
39
+ catch {
40
+ return new Date(inv.start);
41
+ }
42
+ })();
43
+ if (isNaN(instant.getTime()))
44
+ return inv.start;
45
+ const localZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
46
+ const opts = {
47
+ weekday: "short", month: "short", day: "numeric",
48
+ hour: "numeric", minute: "2-digit",
49
+ };
50
+ let out = instant.toLocaleString(undefined, opts);
51
+ if (inv.end && !/Z$/.test(inv.end)) {
52
+ try {
53
+ const endInstant = new Date(instant.getTime() + (new Date(inv.end + "Z").getTime() - new Date(inv.start + "Z").getTime()));
54
+ out += " – " + endInstant.toLocaleString(undefined, { hour: "numeric", minute: "2-digit" });
55
+ }
56
+ catch { /* single-point event */ }
57
+ }
58
+ if (inv.tzid && inv.tzid !== localZone) {
59
+ out += ` (sent as ${inv.start.slice(11, 16)} ${inv.tzid})`;
60
+ }
61
+ return out;
62
+ }
63
+ const esc = (s) => (s || "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
64
+ /** Build the card, or null when there is nothing worth showing. */
65
+ export function buildInviteCard(inv, bodyHtml) {
66
+ if (!inv || (!inv.summary && !inv.start))
67
+ return null;
68
+ const card = document.createElement("div");
69
+ card.className = "mv-invite";
70
+ if (inv.method === "CANCEL")
71
+ card.classList.add("mv-invite-cancelled");
72
+ const when = formatWhen(inv);
73
+ const isMeetLink = /^https?:/; //i.test(inv.location);
74
+ const googleHandles = inv.fromGoogle && inviteHasGoogleRsvp(bodyHtml);
75
+ const rows = [];
76
+ if (when)
77
+ rows.push(`<div class="mv-invite-when">${esc(when)}</div>`);
78
+ if (inv.location) {
79
+ rows.push(isMeetLink
80
+ ? `<div class="mv-invite-row"><span class="mv-invite-label">Where</span><a href="${esc(inv.location)}" target="_blank" rel="noopener noreferrer">${esc(inv.location)}</a></div>`
81
+ : `<div class="mv-invite-row"><span class="mv-invite-label">Where</span>${esc(inv.location)}</div>`);
82
+ }
83
+ if (inv.organizer.email) {
84
+ rows.push(`<div class="mv-invite-row"><span class="mv-invite-label">Organizer</span>${esc(inv.organizer.name)}${inv.organizer.name !== inv.organizer.email ? " &lt;" + esc(inv.organizer.email) + "&gt;" : ""}</div>`);
85
+ }
86
+ if (inv.attendees.length) {
87
+ const who = inv.attendees
88
+ .map(a => `${esc(a.name || a.email)} <span class="mv-invite-partstat">(${esc(PARTSTAT_LABEL[a.status] || a.status.toLowerCase())})</span>`)
89
+ .join(", ");
90
+ rows.push(`<div class="mv-invite-row"><span class="mv-invite-label">Guests</span>${who}</div>`);
91
+ }
92
+ if (inv.rrule)
93
+ rows.push(`<div class="mv-invite-row"><span class="mv-invite-label">Repeats</span><code>${esc(inv.rrule)}</code></div>`);
94
+ const badge = inv.method === "CANCEL" ? "Cancelled"
95
+ : inv.method === "REPLY" ? "Reply"
96
+ : inv.status === "TENTATIVE" ? "Tentative"
97
+ : "Invitation";
98
+ card.innerHTML =
99
+ `<div class="mv-invite-head"><span class="mv-invite-badge">${esc(badge)}</span>` +
100
+ `<span class="mv-invite-summary">${esc(inv.summary || "(no title)")}</span></div>` +
101
+ rows.join("") +
102
+ (googleHandles
103
+ ? `<div class="mv-invite-note">Respond with the Yes / No / Maybe buttons in the message below — they go straight to Google Calendar.</div>`
104
+ : "");
105
+ return card;
106
+ }
107
+ //# sourceMappingURL=invite-card.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invite-card.js","sourceRoot":"","sources":["invite-card.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Calendar-invite card.
3
+ *
4
+ * A message carrying a text/calendar part gets a summary rendered ABOVE the
5
+ * body — time in your zone, location, organizer, who has replied — instead of
6
+ * mailx handing invite.ics to whatever program happens to own .ics on this
7
+ * machine (Bob 2026-08-22 saw a bare console window and asked "is this RMF
8
+ * Mail?"; it was not, and the fact that it could have been is the problem).
9
+ *
10
+ * RSVP is deliberately NOT duplicated for Google invitations. Google's own
11
+ * HTML body carries working Yes / No / Maybe links
12
+ * (calendar.google.com/calendar/event?action=RESPOND&rst=1|2|3), and two
13
+ * responders racing to set PARTSTAT is worse than one. For those we show the
14
+ * summary and point at the buttons already in the message.
15
+ */
16
+ import type { ParsedInvite } from "@bobfrankston/mailx-types";
17
+ import { inviteHasGoogleRsvp } from "@bobfrankston/mailx-types";
18
+
19
+ const PARTSTAT_LABEL: Record<string, string> = {
20
+ "ACCEPTED": "accepted",
21
+ "DECLINED": "declined",
22
+ "TENTATIVE": "maybe",
23
+ "NEEDS-ACTION": "no reply yet",
24
+ "DELEGATED": "delegated",
25
+ };
26
+
27
+ /** Format the event's span. An invite states wall-clock plus a zone; showing
28
+ * it in the reader's zone is the useful reading, but when the two differ the
29
+ * original is shown too — a 08:00 America/Los_Angeles meeting read as 11:00
30
+ * local is exactly where people mis-book. */
31
+ function formatWhen(inv: ParsedInvite): string {
32
+ if (!inv.start) return "";
33
+ if (inv.allDay) {
34
+ try {
35
+ const d = new Date(inv.start + "T00:00:00");
36
+ return d.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" }) + " (all day)";
37
+ } catch { return inv.start + " (all day)"; }
38
+ }
39
+ // A zoned wall-clock has no offset, so Date would read it as LOCAL time.
40
+ // Resolve it through the stated zone to get the real instant.
41
+ const instant = (() => {
42
+ if (/Z$/.test(inv.start)) return new Date(inv.start);
43
+ if (!inv.tzid) return new Date(inv.start);
44
+ try {
45
+ // Probe: what does this wall-clock read as in the stated zone?
46
+ const guess = new Date(inv.start + "Z");
47
+ const asZone = new Date(guess.toLocaleString("en-US", { timeZone: inv.tzid }));
48
+ const asUtc = new Date(guess.toLocaleString("en-US", { timeZone: "UTC" }));
49
+ return new Date(guess.getTime() + (asUtc.getTime() - asZone.getTime()));
50
+ } catch { return new Date(inv.start); }
51
+ })();
52
+ if (isNaN(instant.getTime())) return inv.start;
53
+
54
+ const localZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
55
+ const opts: Intl.DateTimeFormatOptions = {
56
+ weekday: "short", month: "short", day: "numeric",
57
+ hour: "numeric", minute: "2-digit",
58
+ };
59
+ let out = instant.toLocaleString(undefined, opts);
60
+ if (inv.end && !/Z$/.test(inv.end)) {
61
+ try {
62
+ const endInstant = new Date(instant.getTime() + (new Date(inv.end + "Z").getTime() - new Date(inv.start + "Z").getTime()));
63
+ out += " – " + endInstant.toLocaleString(undefined, { hour: "numeric", minute: "2-digit" });
64
+ } catch { /* single-point event */ }
65
+ }
66
+ if (inv.tzid && inv.tzid !== localZone) {
67
+ out += ` (sent as ${inv.start.slice(11, 16)} ${inv.tzid})`;
68
+ }
69
+ return out;
70
+ }
71
+
72
+ const esc = (s: string): string =>
73
+ (s || "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!));
74
+
75
+ /** Build the card, or null when there is nothing worth showing. */
76
+ export function buildInviteCard(inv: ParsedInvite | null | undefined, bodyHtml: string): HTMLElement | null {
77
+ if (!inv || (!inv.summary && !inv.start)) return null;
78
+
79
+ const card = document.createElement("div");
80
+ card.className = "mv-invite";
81
+ if (inv.method === "CANCEL") card.classList.add("mv-invite-cancelled");
82
+
83
+ const when = formatWhen(inv);
84
+ const isMeetLink = /^https?:///i.test(inv.location);
85
+ const googleHandles = inv.fromGoogle && inviteHasGoogleRsvp(bodyHtml);
86
+
87
+ const rows: string[] = [];
88
+ if (when) rows.push(`<div class="mv-invite-when">${esc(when)}</div>`);
89
+ if (inv.location) {
90
+ rows.push(isMeetLink
91
+ ? `<div class="mv-invite-row"><span class="mv-invite-label">Where</span><a href="${esc(inv.location)}" target="_blank" rel="noopener noreferrer">${esc(inv.location)}</a></div>`
92
+ : `<div class="mv-invite-row"><span class="mv-invite-label">Where</span>${esc(inv.location)}</div>`);
93
+ }
94
+ if (inv.organizer.email) {
95
+ rows.push(`<div class="mv-invite-row"><span class="mv-invite-label">Organizer</span>${esc(inv.organizer.name)}${inv.organizer.name !== inv.organizer.email ? " &lt;" + esc(inv.organizer.email) + "&gt;" : ""}</div>`);
96
+ }
97
+ if (inv.attendees.length) {
98
+ const who = inv.attendees
99
+ .map(a => `${esc(a.name || a.email)} <span class="mv-invite-partstat">(${esc(PARTSTAT_LABEL[a.status] || a.status.toLowerCase())})</span>`)
100
+ .join(", ");
101
+ rows.push(`<div class="mv-invite-row"><span class="mv-invite-label">Guests</span>${who}</div>`);
102
+ }
103
+ if (inv.rrule) rows.push(`<div class="mv-invite-row"><span class="mv-invite-label">Repeats</span><code>${esc(inv.rrule)}</code></div>`);
104
+
105
+ const badge = inv.method === "CANCEL" ? "Cancelled"
106
+ : inv.method === "REPLY" ? "Reply"
107
+ : inv.status === "TENTATIVE" ? "Tentative"
108
+ : "Invitation";
109
+
110
+ card.innerHTML =
111
+ `<div class="mv-invite-head"><span class="mv-invite-badge">${esc(badge)}</span>` +
112
+ `<span class="mv-invite-summary">${esc(inv.summary || "(no title)")}</span></div>` +
113
+ rows.join("") +
114
+ (googleHandles
115
+ ? `<div class="mv-invite-note">Respond with the Yes / No / Maybe buttons in the message below — they go straight to Google Calendar.</div>`
116
+ : "");
117
+
118
+ return card;
119
+ }
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { getMessage, updateFlags, allowRemoteContent, flagSenderOrDomain, getAttachment, openAttachment, getMessageSource, addContact, listContacts, upsertContact, unsubscribeOneClick, addPreferredContact, onEvent, subscribeStore } from "../lib/api-client.js";
12
12
  import { isSenderApproved, noteApprovedLocally, refreshApprovedSenders } from "../lib/approved-senders.js";
13
+ import { buildInviteCard } from "./invite-card.js";
13
14
  import { showContextMenu } from "./context-menu.js";
14
15
  import { updateMessageFlags as stateUpdateFlags } from "../lib/message-state.js";
15
16
  import { setRowSeen } from "./message-list.js";
@@ -788,23 +789,27 @@ function installPreviewControls(iframe) {
788
789
  // the iframe selects exactly this document.
789
790
  if (e.ctrlKey && !e.altKey && !e.metaKey && (e.key === "a" || e.key === "A"))
790
791
  return;
791
- // Forward EVERY keydown to the parent no duplicated hotkey list.
792
- // If the parent's handler calls preventDefault (because it owns the
793
- // shortcut), dispatchEvent returns false, and we preventDefault on
794
- // the iframe side too so the browser doesn't ALSO act on it
795
- // (Ctrl+N otherwise pops a new browser window in some hosts).
796
- // Single source of truth = app.ts hotkey handlers. Plain typing in
797
- // the email body — letters, etc. propagates with no parent
798
- // handler matching, so dispatchEvent returns true and the iframe
799
- // event is left alone.
800
- const synth = new KeyboardEvent("keydown", {
801
- key: e.key, code: e.code,
802
- ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,
803
- bubbles: true, cancelable: true,
804
- });
805
- const allowDefault = document.dispatchEvent(synth);
806
- if (!allowDefault)
807
- e.preventDefault();
792
+ // Everything else is FORWARDED BY THE IFRAME ITSELF the inline
793
+ // script wrapHtmlBody installs posts every keydown to the parent
794
+ // as `previewKey`, and app.ts re-dispatches it on document. This
795
+ // listener must NOT forward as well.
796
+ //
797
+ // It used to, and both routes are live on desktop WebView2 (the
798
+ // srcdoc document is same-origin, so this contentDocument listener
799
+ // attaches, and the inline script runs too). One Ctrl+F in the
800
+ // preview therefore reached app.ts TWICE and opened TWO compose
801
+ // windows one of which never finished loading and sat blank and
802
+ // "(Not Responding)" until it was killed (Bob 2026-08-23,
803
+ // "Compose: Fwd: FirstCare Slide Deck (Not Responding)"; daemon
804
+ // log shows openCompose-entry mode=forward twice in the same
805
+ // millisecond and two popoutCompose windows). Ctrl+D / Ctrl+P
806
+ // doubled the same way.
807
+ //
808
+ // The inline forwarder is the one that survives, because it is the
809
+ // only one that works on Android WebView, where parent-side
810
+ // contentDocument listeners never fire. Zoom and Ctrl+A stay here:
811
+ // they act on THIS document and the inline script deliberately
812
+ // skips them. (Claude Code 2026-08-23)
808
813
  });
809
814
  doc.addEventListener("wheel", (e) => {
810
815
  if (!e.ctrlKey)
@@ -864,6 +869,10 @@ export function clearViewer() {
864
869
  headerEl.hidden = true;
865
870
  if (attEl)
866
871
  attEl.hidden = true;
872
+ // The invite card is a SIBLING of the body (it sits after the header), so
873
+ // clearing bodyEl leaves it behind — a stale meeting hovering over "Select
874
+ // a message to read" is exactly the viewer-must-clear failure.
875
+ document.getElementById("mv-invite")?.remove();
867
876
  // Divergence watchdog: highlight and viewer are supposed to be one
868
877
  // thing. If a second after a clear some row still shows .selected while
869
878
  // the pane shows the placeholder, that's the "message selected but it's
@@ -1342,6 +1351,22 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
1342
1351
  fromEl.textContent = formatAddr(msg.from);
1343
1352
  setRecipientLine(toEl, msg.to, msg.cc, msg.deliveredTo);
1344
1353
  headerEl.querySelector(".mv-subject").textContent = msg.subject;
1354
+ // Calendar invite card. Lives BETWEEN the header and the body as its
1355
+ // own element, not inside .mv-body — the body's innerHTML is replaced
1356
+ // wholesale on every render, which would take the card with it.
1357
+ // Removed and rebuilt per message so a non-invite never inherits the
1358
+ // previous one (the "viewer must clear" rule).
1359
+ document.getElementById("mv-invite")?.remove();
1360
+ try {
1361
+ const card = buildInviteCard(msg.invite, String(msg.bodyHtml || ""));
1362
+ if (card) {
1363
+ card.id = "mv-invite";
1364
+ headerEl.insertAdjacentElement("afterend", card);
1365
+ }
1366
+ }
1367
+ catch (e) {
1368
+ console.warn("[viewer] invite card failed:", e?.message || e);
1369
+ }
1345
1370
  document.dispatchEvent(new CustomEvent("mailx-message-shown", { detail: { accountId } }));
1346
1371
  // Right-click on email addresses in header: copy name, copy address,
1347
1372
  // copy both, add to contacts, plus reply actions for the whole message.