diff --git a/server/src/mock/data.ts b/server/src/mock/data.ts index 162210a..bdec512 100644 --- a/server/src/mock/data.ts +++ b/server/src/mock/data.ts @@ -201,7 +201,7 @@ export function addEmail(o: { from: [string, string]; to?: string; subject: stri attachments, bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: o.styled ? STYLED_MARKETING_HTML : html, isEncodingProblem: false, isTruncated: false } } : {}) }, bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] }, - "header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? ", " : null, + "header:List-Unsubscribe:asURLs": o.from[1].includes("newsletter") ? ["mailto:unsub@newsletter.example?subject=unsubscribe", "https://newsletter.example/unsub"] : null, "header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : null, // Stalwart's spam filter writes the SpamAssassin-shaped set at delivery, so // delivered mail carries it and mail this account wrote does not. diff --git a/web/src/jmap/types.ts b/web/src/jmap/types.ts index 1187411..42f56e6 100644 --- a/web/src/jmap/types.ts +++ b/web/src/jmap/types.ts @@ -254,7 +254,8 @@ export interface Email { attachments?: EmailBodyPart[]; headers?: EmailHeader[]; // convenience header fetches - "header:List-Unsubscribe:asText"?: string | null; + /** RFC 2369 defines List-Unsubscribe, so asText is not a form it has (RFC 8621 ยง4.1.2.3). */ + "header:List-Unsubscribe:asURLs"?: string[] | null; "header:List-Unsubscribe-Post:asText"?: string | null; "header:List-Id:asText"?: string | null; "header:Disposition-Notification-To:asAddresses"?: EmailAddress[] | null; @@ -263,9 +264,7 @@ export interface Email { "header:Auto-Submitted:asText"?: string | null; /** Bulk/list mail marks itself here; read receipts for it only confirm the address. */ "header:Precedence:asText"?: string | null; - "header:Return-Path:asText"?: string | null; "header:Authentication-Results:asText"?: string | null; - "header:Received:asText:all"?: string[] | null; "header:X-Spam-Status:asText"?: string | null; "header:X-Spam-Result:asText"?: string | null; /** inbuxa: the language model's opinion, when AI spam classification is on (lib/llmOpinion). */ diff --git a/web/src/store/mail/props.ts b/web/src/store/mail/props.ts index 0ded0c2..bb50e52 100644 --- a/web/src/store/mail/props.ts +++ b/web/src/store/mail/props.ts @@ -58,7 +58,7 @@ export const FULL_PROPS = [ "textBody", "htmlBody", "attachments", - "header:List-Unsubscribe:asText", + "header:List-Unsubscribe:asURLs", "header:List-Unsubscribe-Post:asText", "header:List-Id:asText", "header:Disposition-Notification-To:asAddresses", diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index 438a246..9daca03 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -192,7 +192,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn const attachments = useMemo(() => (e.attachments ?? []).filter((a) => !(a.cid && a.disposition === "inline" && a.type.startsWith("image/") && htmlRaw?.includes(`cid:${a.cid}`))), [e.attachments, htmlRaw]); const icsPart = useMemo(() => findPart(e.bodyStructure, (p) => p.type === "text/calendar" || (p.name ?? "").toLowerCase().endsWith(".ics")), [e.bodyStructure]); const vcfParts = useMemo(() => (e.attachments ?? []).filter((p) => p.type === "text/vcard" || p.type === "text/x-vcard" || (p.name ?? "").toLowerCase().endsWith(".vcf")), [e.attachments]); - const unsubscribe = e["header:List-Unsubscribe:asText"]; + const unsubscribe = e["header:List-Unsubscribe:asURLs"]; const isHighPriority = /^[12]/.test(e["header:X-Priority:asText"] ?? "") || /high/i.test(e["header:Importance:asText"] ?? ""); const receiptRequested = Boolean(e["header:Disposition-Notification-To:asAddresses"]?.length); const authFailed = /\b(dkim|spf|dmarc)=fail\b/i.test(e["header:Authentication-Results:asText"] ?? ""); @@ -250,8 +250,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn }; const onUnsubscribe = async () => { - if (!unsubscribe) return; - const urls = [...unsubscribe.matchAll(/<([^>]+)>/g)].map((m) => m[1]!); + const urls = unsubscribe ?? []; const mailto = urls.find((u) => u.startsWith("mailto:")); const http = urls.find((u) => /^https?:/i.test(u)); if (mailto) { @@ -465,12 +464,12 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn {showHtml && rendered ? : } {attachments.length > 0 && } - {unsubscribe && ( + {unsubscribe?.length ? (
{translate("This looks like a mailing list.")}
- )} + ) : null} )} {addrMenu.node}