mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: send a mailto unsubscribe to one address only and show what it sends
This commit is contained in:
@@ -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(); } },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user