fix: send a mailto unsubscribe to one address only and show what it sends

This commit is contained in:
Linus Rath
2026-10-29 03:02:33 +01:00
parent ef9561f16b
commit 942f45179c
3 changed files with 102 additions and 6 deletions
+16 -6
View File
@@ -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(); } },
+34
View File
@@ -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');
});
});
+52
View File
@@ -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');
}