@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.
- package/.commitmsg +26 -0
- package/client/android-bootstrap.bundle.js +184 -0
- package/client/android-bootstrap.bundle.js.map +2 -2
- package/client/app.bundle.js +235 -31
- package/client/app.bundle.js.map +4 -4
- package/client/app.js +35 -1
- package/client/app.js.map +1 -1
- package/client/app.ts +36 -1
- package/client/components/invite-card.js +107 -0
- package/client/components/invite-card.js.map +1 -0
- package/client/components/invite-card.ts +119 -0
- package/client/components/message-list.js +9 -1
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +9 -1
- package/client/components/message-viewer.js +82 -19
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +79 -18
- package/client/compose/compose.bundle.js +4 -0
- package/client/compose/compose.bundle.js.map +2 -2
- package/client/lib/api-client.js +6 -0
- package/client/lib/api-client.js.map +1 -1
- package/client/lib/api-client.ts +6 -0
- package/client/lib/approved-senders.js +66 -0
- package/client/lib/approved-senders.js.map +1 -0
- package/client/lib/approved-senders.ts +63 -0
- package/client/lib/mailxapi.js +3 -0
- package/client/package.json +1 -1
- package/client/styles/components.css +48 -0
- package/package.json +10 -10
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +2 -2
- package/packages/mailx-service/index.d.ts +15 -0
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +18 -0
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +19 -0
- package/packages/mailx-service/jsonrpc.js +2 -0
- package/packages/mailx-service/jsonrpc.js.map +1 -1
- package/packages/mailx-service/jsonrpc.ts +2 -0
- package/packages/mailx-service/package.json +1 -1
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store/store.d.ts +5 -0
- package/packages/mailx-store/store.d.ts.map +1 -1
- package/packages/mailx-store/store.js +21 -0
- package/packages/mailx-store/store.js.map +1 -1
- package/packages/mailx-store/store.ts +27 -0
- package/packages/mailx-store-web/android-bootstrap.js +1 -0
- package/packages/mailx-store-web/android-bootstrap.js.map +1 -1
- package/packages/mailx-store-web/android-bootstrap.ts +1 -0
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-store-web/web-service.d.ts +11 -0
- package/packages/mailx-store-web/web-service.d.ts.map +1 -1
- package/packages/mailx-store-web/web-service.js +14 -0
- package/packages/mailx-store-web/web-service.js.map +1 -1
- package/packages/mailx-store-web/web-service.ts +15 -0
- package/packages/mailx-types/index.d.ts +56 -0
- package/packages/mailx-types/index.d.ts.map +1 -1
- package/packages/mailx-types/index.js +187 -0
- package/packages/mailx-types/index.js.map +1 -1
- package/packages/mailx-types/index.ts +192 -0
- package/packages/mailx-types/package.json +1 -1
- /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
|
-
|
|
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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 ? " <" + esc(inv.organizer.email) + ">" : ""}</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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 ? " <" + esc(inv.organizer.email) + ">" : ""}</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
|
-
|
|
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");
|