Fetch List-Unsubscribe as URLs, the form RFC 8621 gives it #76

Merged
jcoffey-dev merged 1 commits from fix/list-unsubscribe-as-urls into main 2026-10-27 06:56:38 +00:00
4 changed files with 8 additions and 10 deletions

No files matched your search

+1 -1
View File
@@ -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") ? "<mailto:[email protected]?subject=unsubscribe>, <https://newsletter.example/unsub>" : null,
"header:List-Unsubscribe:asURLs": o.from[1].includes("newsletter") ? ["mailto:[email protected]?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.
+2 -3
View File
@@ -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). */
+1 -1
View File
@@ -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",
+4 -5
View File
@@ -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 ? <HtmlBody html={rendered.html} bodyStyle={rendered.bodyStyle} themed={themed} forced={forced} onShowImages={showImages} onFollowLink={linkGuard} /> : <TextBody text={textRaw ?? ""} onFollowLink={linkGuard} />}
</div>
{attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />}
{unsubscribe && (
{unsubscribe?.length ? (
<div className="unsubscribe-row">
<span>{translate("This looks like a mailing list.")}</span>
<button className="btn btn-ghost btn-sm" onClick={() => void onUnsubscribe()}>{translate("Unsubscribe")}</button>
</div>
)}
) : null}
</>
)}
{addrMenu.node}