mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +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 { pruneEmailSubmissions } from '../../api/submissions';
|
||||||
import { SubmissionQuotaError } from '../../api/jmap-result';
|
import { SubmissionQuotaError } from '../../api/jmap-result';
|
||||||
import { sendFailureMessage } from '../../lib/send-failure-message';
|
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 type { ListHeaders } from '../../lib/email-headers';
|
||||||
import { findReceivingIdentity } from '../../lib/email-headers';
|
import { findReceivingIdentity } from '../../lib/email-headers';
|
||||||
import { mailboxesOfAccount } from '../../lib/mailbox-tree';
|
import { mailboxesOfAccount } from '../../lib/mailbox-tree';
|
||||||
@@ -66,6 +68,12 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr
|
|||||||
const unsub = list.listUnsubscribe;
|
const unsub = list.listUnsubscribe;
|
||||||
const method = unsub?.preferred;
|
const method = unsub?.preferred;
|
||||||
const url = method === 'http' ? unsub?.http : unsub?.mailto;
|
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(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -76,7 +84,7 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [messageKey]);
|
}, [messageKey]);
|
||||||
|
|
||||||
if (hidden || !url || !method) return null;
|
if (hidden || !url || !method || (method === 'mailto' && !mailtoFields)) return null;
|
||||||
|
|
||||||
const dismiss = () => {
|
const dismiss = () => {
|
||||||
dismissed.add(messageKey);
|
dismissed.add(messageKey);
|
||||||
@@ -100,7 +108,7 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr
|
|||||||
await WebBrowser.openBrowserAsync(url);
|
await WebBrowser.openBrowserAsync(url);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const fields = parseMailtoUrl(url);
|
const fields = mailtoFields;
|
||||||
if (!fields) throw new Error('invalid mailto');
|
if (!fields) throw new Error('invalid mailto');
|
||||||
const identity = findReceivingIdentity(identities, email) ?? identities[0];
|
const identity = findReceivingIdentity(identities, email) ?? identities[0];
|
||||||
if (!identity) throw new Error(t('email_viewer.unsubscribe_banner.no_identity', 'No sending identity available'));
|
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(
|
await sendEmail(
|
||||||
{
|
{
|
||||||
from: [{ name: identity.name, email: identity.email }],
|
from: [{ name: identity.name, email: identity.email }],
|
||||||
to: fields.to.map((address) => ({ email: address })),
|
to: [{ email: fields.to[0] }],
|
||||||
cc: fields.cc?.map((address) => ({ email: address })),
|
|
||||||
subject: fields.subject ?? '',
|
subject: fields.subject ?? '',
|
||||||
textBody: fields.body ?? '',
|
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?'),
|
t('email_viewer.unsubscribe_banner.confirm_title', 'Unsubscribe from this sender?'),
|
||||||
method === 'http'
|
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_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.cancel', 'Cancel'), style: 'cancel' },
|
||||||
{ text: t('email_viewer.unsubscribe_banner.confirm_button', 'Confirm'), onPress: () => { void perform(); } },
|
{ text: t('email_viewer.unsubscribe_banner.confirm_button', 'Confirm'), onPress: () => { void perform(); } },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
parseUnsubscribeUrls, isValidUnsubscribeUrl, parseMailtoUrl, isOneClickUnsubscribe,
|
parseUnsubscribeUrls, isValidUnsubscribeUrl, parseMailtoUrl, isOneClickUnsubscribe,
|
||||||
|
parseUnsubscribeMailto, unsubscribeConfirmMessage,
|
||||||
} from '../unsubscribe';
|
} from '../unsubscribe';
|
||||||
|
|
||||||
describe('parseUnsubscribeUrls', () => {
|
describe('parseUnsubscribeUrls', () => {
|
||||||
@@ -46,3 +47,36 @@ describe('isOneClickUnsubscribe', () => {
|
|||||||
expect(isOneClickUnsubscribe(undefined, 'https://x/u')).toBe(false);
|
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;
|
if (to.length === 0 && cc.length === 0) return null;
|
||||||
return { to, cc: cc.length ? cc : undefined, subject, body };
|
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