@bobfrankston/rmfmail 1.2.272 → 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 (63) hide show
  1. package/.commitmsg +26 -0
  2. package/client/android-bootstrap.bundle.js +184 -0
  3. package/client/android-bootstrap.bundle.js.map +2 -2
  4. package/client/app.bundle.js +235 -31
  5. package/client/app.bundle.js.map +4 -4
  6. package/client/app.js +35 -1
  7. package/client/app.js.map +1 -1
  8. package/client/app.ts +36 -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-list.js +9 -1
  13. package/client/components/message-list.js.map +1 -1
  14. package/client/components/message-list.ts +9 -1
  15. package/client/components/message-viewer.js +82 -19
  16. package/client/components/message-viewer.js.map +1 -1
  17. package/client/components/message-viewer.ts +79 -18
  18. package/client/compose/compose.bundle.js +4 -0
  19. package/client/compose/compose.bundle.js.map +2 -2
  20. package/client/lib/api-client.js +6 -0
  21. package/client/lib/api-client.js.map +1 -1
  22. package/client/lib/api-client.ts +6 -0
  23. package/client/lib/approved-senders.js +66 -0
  24. package/client/lib/approved-senders.js.map +1 -0
  25. package/client/lib/approved-senders.ts +63 -0
  26. package/client/lib/mailxapi.js +3 -0
  27. package/client/package.json +1 -1
  28. package/client/styles/components.css +48 -0
  29. package/package.json +10 -10
  30. package/packages/mailx-imap/package-lock.json +2 -2
  31. package/packages/mailx-imap/package.json +2 -2
  32. package/packages/mailx-service/index.d.ts +15 -0
  33. package/packages/mailx-service/index.d.ts.map +1 -1
  34. package/packages/mailx-service/index.js +18 -0
  35. package/packages/mailx-service/index.js.map +1 -1
  36. package/packages/mailx-service/index.ts +19 -0
  37. package/packages/mailx-service/jsonrpc.js +2 -0
  38. package/packages/mailx-service/jsonrpc.js.map +1 -1
  39. package/packages/mailx-service/jsonrpc.ts +2 -0
  40. package/packages/mailx-service/package.json +1 -1
  41. package/packages/mailx-settings/package.json +1 -1
  42. package/packages/mailx-store/package.json +1 -1
  43. package/packages/mailx-store/store.d.ts +5 -0
  44. package/packages/mailx-store/store.d.ts.map +1 -1
  45. package/packages/mailx-store/store.js +21 -0
  46. package/packages/mailx-store/store.js.map +1 -1
  47. package/packages/mailx-store/store.ts +27 -0
  48. package/packages/mailx-store-web/android-bootstrap.js +1 -0
  49. package/packages/mailx-store-web/android-bootstrap.js.map +1 -1
  50. package/packages/mailx-store-web/android-bootstrap.ts +1 -0
  51. package/packages/mailx-store-web/package.json +1 -1
  52. package/packages/mailx-store-web/web-service.d.ts +11 -0
  53. package/packages/mailx-store-web/web-service.d.ts.map +1 -1
  54. package/packages/mailx-store-web/web-service.js +14 -0
  55. package/packages/mailx-store-web/web-service.js.map +1 -1
  56. package/packages/mailx-store-web/web-service.ts +15 -0
  57. package/packages/mailx-types/index.d.ts +56 -0
  58. package/packages/mailx-types/index.d.ts.map +1 -1
  59. package/packages/mailx-types/index.js +187 -0
  60. package/packages/mailx-types/index.js.map +1 -1
  61. package/packages/mailx-types/index.ts +192 -0
  62. package/packages/mailx-types/package.json +1 -1
  63. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-72180 → 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 —
@@ -4668,6 +4683,26 @@ function applyThreadFilter(): void {
4668
4683
  }
4669
4684
  })();
4670
4685
 
4686
+ // Approved-sender index (the shared allowlist). Loaded once so the message
4687
+ // list can ask synchronously whether a sender has been vouched for — the
4688
+ // display-name warning is suppressed for those. Re-render on change so
4689
+ // marking a sender valid clears the warning from rows already on screen.
4690
+ (async () => {
4691
+ try {
4692
+ const { refreshApprovedSenders } = await import("./lib/approved-senders.js");
4693
+ await refreshApprovedSenders();
4694
+ document.dispatchEvent(new CustomEvent("mailx-allowlist-changed"));
4695
+ } catch (e: any) {
4696
+ console.warn("approved-sender index load failed:", e?.message || e);
4697
+ }
4698
+ })();
4699
+
4700
+ document.addEventListener("mailx-allowlist-changed", () => {
4701
+ void import("./components/message-list.js")
4702
+ .then(m => (m as any).reloadCurrentFolder?.())
4703
+ .catch(() => { /* list not mounted yet */ });
4704
+ });
4705
+
4671
4706
  // C46: same startup pickup for a pending Windows-share drop (rmfshare.exe
4672
4707
  // spawned the daemon before its fs.watch armed). Live shares during this
4673
4708
  // session arrive via the `openShare` event handled in onEvent above.
@@ -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
+ }
@@ -9,6 +9,7 @@ import { showContextMenu } from "./context-menu.js";
9
9
  import { pickFolder } from "./folder-picker.js";
10
10
  import { foldText } from "../lib/fold-text.js";
11
11
  import { seenOf, flaggedOf, draftOf, setSeen, setFlagged, isJunkPreviewText, displayNameClaimsOtherAddress } from "@bobfrankston/mailx-types";
12
+ import { isSenderApproved } from "../lib/approved-senders.js";
12
13
  let onMessageSelect;
13
14
  let currentAccountId;
14
15
  let currentFolderId;
@@ -1929,7 +1930,14 @@ class MessageRow {
1929
1930
  // isn't the real one, show the real one too so the row can't
1930
1931
  // lie on its own (Bob 2026-08-19).
1931
1932
  const fromName = msg.from.name || msg.from.address;
1932
- const fromClaim = displayNameClaimsOtherAddress(msg.from.name || "", msg.from.address || "");
1933
+ // A sender the user has vouched for is never called out, however
1934
+ // odd the display name looks. Without this the warning fired on
1935
+ // 845 of 1978 qualifying messages — ESP relays, EDAS conference
1936
+ // rewrites, "via SurveyMonkey", list traffic — which trains you to
1937
+ // ignore it (Bob 2026-08-20).
1938
+ const fromClaim = isSenderApproved(msg.from.address || "")
1939
+ ? null
1940
+ : displayNameClaimsOtherAddress(msg.from.name || "", msg.from.address || "");
1933
1941
  from.textContent = fromClaim ? `${fromName} — actually ${msg.from.address}` : fromName;
1934
1942
  if (fromClaim) {
1935
1943
  from.classList.add("ml-from-spoofed");