From 942f45179c6746f2a533b1cebb5c4ab4abc65df3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:02:33 +0100 Subject: [PATCH] fix: send a mailto unsubscribe to one address only and show what it sends --- src/components/email/UnsubscribeBanner.tsx | 22 ++++++--- src/lib/__tests__/unsubscribe.test.ts | 34 ++++++++++++++ src/lib/unsubscribe.ts | 52 ++++++++++++++++++++++ 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/src/components/email/UnsubscribeBanner.tsx b/src/components/email/UnsubscribeBanner.tsx index 8b6464a7..75e7d244 100644 --- a/src/components/email/UnsubscribeBanner.tsx +++ b/src/components/email/UnsubscribeBanner.tsx @@ -13,7 +13,9 @@ import { sendEmail } from '../../api/email'; import { pruneEmailSubmissions } from '../../api/submissions'; import { SubmissionQuotaError } from '../../api/jmap-result'; import { sendFailureMessage } from '../../lib/send-failure-message'; -import { isValidUnsubscribeUrl, parseMailtoUrl, isOneClickUnsubscribe } from '../../lib/unsubscribe'; +import { + isValidUnsubscribeUrl, parseUnsubscribeMailto, isOneClickUnsubscribe, unsubscribeConfirmMessage, +} from '../../lib/unsubscribe'; import type { ListHeaders } from '../../lib/email-headers'; import { findReceivingIdentity } from '../../lib/email-headers'; import { mailboxesOfAccount } from '../../lib/mailbox-tree'; @@ -66,6 +68,12 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr const unsub = list.listUnsubscribe; const method = unsub?.preferred; const url = method === 'http' ? unsub?.http : unsub?.mailto; + // The sender wrote this URL: only one recipient and a short subject and + // body are sent, and the confirmation shows all of them first. + const mailtoFields = React.useMemo( + () => (method === 'mailto' && url ? parseUnsubscribeMailto(url) : null), + [method, url], + ); React.useEffect(() => { let cancelled = false; @@ -76,7 +84,7 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr return () => { cancelled = true; }; }, [messageKey]); - if (hidden || !url || !method) return null; + if (hidden || !url || !method || (method === 'mailto' && !mailtoFields)) return null; const dismiss = () => { dismissed.add(messageKey); @@ -100,7 +108,7 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr await WebBrowser.openBrowserAsync(url); } } else { - const fields = parseMailtoUrl(url); + const fields = mailtoFields; if (!fields) throw new Error('invalid mailto'); const identity = findReceivingIdentity(identities, email) ?? identities[0]; if (!identity) throw new Error(t('email_viewer.unsubscribe_banner.no_identity', 'No sending identity available')); @@ -112,8 +120,7 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr await sendEmail( { from: [{ name: identity.name, email: identity.email }], - to: fields.to.map((address) => ({ email: address })), - cc: fields.cc?.map((address) => ({ email: address })), + to: [{ email: fields.to[0] }], subject: fields.subject ?? '', textBody: fields.body ?? '', }, @@ -141,7 +148,10 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr t('email_viewer.unsubscribe_banner.confirm_title', 'Unsubscribe from this sender?'), method === 'http' ? t('email_viewer.unsubscribe_banner.confirm_message_http', 'The unsubscribe page will open in a new tab.') - : t('email_viewer.unsubscribe_banner.confirm_message_mailto', 'An unsubscribe email will be sent to the sender.'), + : unsubscribeConfirmMessage( + t('email_viewer.unsubscribe_banner.confirm_message_mailto', 'An unsubscribe email will be sent to the sender.'), + mailtoFields, + ), [ { text: t('email_viewer.unsubscribe_banner.cancel', 'Cancel'), style: 'cancel' }, { text: t('email_viewer.unsubscribe_banner.confirm_button', 'Confirm'), onPress: () => { void perform(); } }, diff --git a/src/lib/__tests__/unsubscribe.test.ts b/src/lib/__tests__/unsubscribe.test.ts index 7baf2fef..0b57a0cc 100644 --- a/src/lib/__tests__/unsubscribe.test.ts +++ b/src/lib/__tests__/unsubscribe.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { parseUnsubscribeUrls, isValidUnsubscribeUrl, parseMailtoUrl, isOneClickUnsubscribe, + parseUnsubscribeMailto, unsubscribeConfirmMessage, } from '../unsubscribe'; describe('parseUnsubscribeUrls', () => { @@ -46,3 +47,36 @@ describe('isOneClickUnsubscribe', () => { expect(isOneClickUnsubscribe(undefined, 'https://x/u')).toBe(false); }); }); + +describe('parseUnsubscribeMailto', () => { + it('sends to the one address in the URL and ignores to= and cc= fields', () => { + expect(parseUnsubscribeMailto('mailto:unsub@list.example?to=victim@corp.example&cc=boss@corp.example&subject=stop')) + .toEqual({ to: ['unsub@list.example'], subject: 'stop', body: undefined }); + }); + + it('refuses a list of addresses or no address in the address part', () => { + expect(parseUnsubscribeMailto('mailto:a@list.example,b@corp.example')).toBeNull(); + expect(parseUnsubscribeMailto('mailto:?to=a@list.example')).toBeNull(); + expect(parseUnsubscribeMailto('mailto:?cc=a@list.example')).toBeNull(); + }); + + it('keeps the subject on one line and caps subject and body', () => { + const long = 'x'.repeat(2000); + const r = parseUnsubscribeMailto(`mailto:u@list.example?subject=a%0D%0ABcc:%20x@y.example%20${long}&body=${long}`); + expect(r?.subject).not.toMatch(/[\r\n]/); + expect(r?.subject?.length).toBe(200); + expect(r?.body?.length).toBe(500); + }); +}); + +describe('unsubscribeConfirmMessage', () => { + it('shows the address, subject and body that will be sent', () => { + const msg = unsubscribeConfirmMessage('An unsubscribe email will be sent.', { + to: ['unsub@list.example'], subject: 'stop', body: 'remove me', + }); + expect(msg).toContain('unsub@list.example'); + expect(msg).toContain('stop'); + expect(msg).toContain('remove me'); + expect(unsubscribeConfirmMessage('Intro', null)).toBe('Intro'); + }); +}); diff --git a/src/lib/unsubscribe.ts b/src/lib/unsubscribe.ts index a2b2fb16..01c1ddc9 100644 --- a/src/lib/unsubscribe.ts +++ b/src/lib/unsubscribe.ts @@ -117,3 +117,55 @@ export function parseMailtoUrl(url: string): MailtoFields | null { if (to.length === 0 && cc.length === 0) return null; return { to, cc: cc.length ? cc : undefined, subject, body }; } + +const UNSUBSCRIBE_SUBJECT_MAX = 200; +const UNSUBSCRIBE_BODY_MAX = 500; + +export interface UnsubscribeMailto { + to: [string]; + subject?: string; + body?: string; +} + +/** + * Parse a List-Unsubscribe mailto: URL for a one-tap send from the user's + * own account (port of the webmail's parseUnsubscribeMailto, f0aa0eeda). + * The newsletter's sender wrote this URL, so it is held to what an + * unsubscribe request needs: exactly one recipient, taken from the address + * part (`to=` and `cc=` query fields are ignored, a list of addresses is + * refused), a single-line subject and a short body. The confirmation shows + * all three before anything is sent. + */ +export function parseUnsubscribeMailto(url: string): UnsubscribeMailto | null { + const parsed = parseMailtoUrl(url); + if (!parsed) return null; + + const rest = url.slice(7); + const queryIndex = rest.indexOf('?'); + const addressPart = queryIndex === -1 ? rest : rest.slice(0, queryIndex); + const addresses = addressPart.split(',').filter((a) => a.trim() !== ''); + if (addresses.length !== 1) return null; + let to: string; + try { + to = decodeURIComponent(addresses[0]).trim(); + } catch { + to = addresses[0].trim(); + } + if (!isValidEmail(to)) return null; + + const subject = parsed.subject?.replace(/[\r\n]+/g, ' ').trim().slice(0, UNSUBSCRIBE_SUBJECT_MAX) || undefined; + const body = parsed.body?.slice(0, UNSUBSCRIBE_BODY_MAX) || undefined; + return { to: [to], subject, body }; +} + +/** + * The mailto confirmation text: the explanation, then the address, subject + * and body that will be sent, so nothing leaves the account unseen. + */ +export function unsubscribeConfirmMessage(intro: string, fields: UnsubscribeMailto | null): string { + if (!fields) return intro; + const lines = [intro, '', fields.to[0]]; + if (fields.subject) lines.push(fields.subject); + if (fields.body) lines.push('', fields.body); + return lines.join('\n'); +}