fix: send a mailto unsubscribe from the own account and only to one decoded address

This commit is contained in:
Linus Rath
2026-10-29 04:36:56 +01:00
parent ba67205052
commit 918571c3c6
3 changed files with 65 additions and 13 deletions
+12 -11
View File
@@ -15,10 +15,10 @@ import { SubmissionQuotaError } from '../../api/jmap-result';
import { sendFailureMessage } from '../../lib/send-failure-message';
import {
isValidUnsubscribeUrl, parseUnsubscribeMailto, isOneClickUnsubscribe, unsubscribeConfirmMessage,
unsubscribeSendFolders,
} from '../../lib/unsubscribe';
import type { ListHeaders } from '../../lib/email-headers';
import { findReceivingIdentity } from '../../lib/email-headers';
import { mailboxesOfAccount } from '../../lib/mailbox-tree';
const DISMISSED_KEY = 'webmail:unsubscribe-dismissed:v1';
const dismissed = new Set<string>();
@@ -47,16 +47,18 @@ interface Props {
list: ListHeaders;
/** Dedupe key for dismissals (bare Message-ID, else the JMAP id). */
messageKey: string;
/** The message's JMAP account. A mailto unsubscribe still goes out from the user's own. */
jmapAccountId?: string;
}
/**
* List-Unsubscribe (RFC 2369 / RFC 8058) banner. An https one-click URL is
* POSTed directly; a plain http(s) URL opens in the in-app browser; a
* mailto: URL is sent through the account itself (handing it to the OS mail
* app goes nowhere for most users). Dismissals are remembered per Message-ID.
* mailto: URL is sent through the user's own account (handing it to the OS
* mail app goes nowhere for most users). Dismissals are remembered per
* Message-ID.
*/
export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Props) {
export function UnsubscribeBanner({ email, list, messageKey }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -110,13 +112,12 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr
} else {
const fields = mailtoFields;
if (!fields) throw new Error('invalid mailto');
// Sent from the user's own account with one of its identities, also
// for a message in a shared account (see unsubscribeSendFolders).
const identity = findReceivingIdentity(identities, email) ?? identities[0];
if (!identity) throw new Error(t('email_viewer.unsubscribe_banner.no_identity', 'No sending identity available'));
// Sent/Drafts of the account the mail is submitted from: the message's.
const scoped = mailboxesOfAccount(mailboxes, jmapAccountId);
const sent = scoped.find((m) => m.role === 'sent');
const drafts = scoped.find((m) => m.role === 'drafts');
if (!sent) throw new Error(t('email_composer.no_sent_folder', 'No Sent folder found'));
const { sentId, draftsId } = unsubscribeSendFolders(mailboxes);
if (!sentId) throw new Error(t('email_composer.no_sent_folder', 'No Sent folder found'));
await sendEmail(
{
from: [{ name: identity.name, email: identity.email }],
@@ -125,9 +126,9 @@ export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Pr
textBody: fields.body ?? '',
},
identity.id,
sent.originalId ?? sent.id,
sentId,
undefined,
{ draftsMailboxId: drafts ? (drafts.originalId ?? drafts.id) : undefined, accountId: jmapAccountId },
{ draftsMailboxId: draftsId },
);
void pruneEmailSubmissions();
}
+28 -1
View File
@@ -1,8 +1,9 @@
import { describe, it, expect } from 'vitest';
import {
parseUnsubscribeUrls, isValidUnsubscribeUrl, parseMailtoUrl, isOneClickUnsubscribe,
parseUnsubscribeMailto, unsubscribeConfirmMessage,
parseUnsubscribeMailto, unsubscribeConfirmMessage, unsubscribeSendFolders,
} from '../unsubscribe';
import type { Mailbox } from '../../api/types';
describe('parseUnsubscribeUrls', () => {
it('prefers http over mailto and validates both', () => {
@@ -56,6 +57,10 @@ describe('parseUnsubscribeMailto', () => {
it('refuses a list of addresses or no address in the address part', () => {
expect(parseUnsubscribeMailto('mailto:a@list.example,b@corp.example')).toBeNull();
// Encoded separators decode into a second recipient.
expect(parseUnsubscribeMailto('mailto:a%2Cb@x.com')).toBeNull();
expect(parseUnsubscribeMailto('mailto:a%3Bb@x.com')).toBeNull();
expect(parseUnsubscribeMailto('mailto:%22a%2Cb%22@x.com')).toBeNull();
expect(parseUnsubscribeMailto('mailto:?to=a@list.example')).toBeNull();
expect(parseUnsubscribeMailto('mailto:?cc=a@list.example')).toBeNull();
});
@@ -80,3 +85,25 @@ describe('unsubscribeConfirmMessage', () => {
expect(unsubscribeConfirmMessage('Intro', null)).toBe('Intro');
});
});
describe('unsubscribeSendFolders', () => {
const mb = (id: string, role: string, shared?: string): Mailbox => ({
id: shared ? `${shared}:${id}` : id,
name: role,
role,
totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0,
myRights: {} as Mailbox['myRights'],
...(shared ? { originalId: id, accountId: shared, isShared: true } : { accountId: 'own', isShared: false }),
});
// The identities the app holds are the own account's, so the mail is
// created there too, also for a newsletter that reached a team mailbox.
it("uses the own account's Sent and Drafts even when a shared account's come first", () => {
const folders = unsubscribeSendFolders([
mb('t-sent', 'sent', 'team'), mb('t-drafts', 'drafts', 'team'),
mb('sent', 'sent'), mb('drafts', 'drafts'),
]);
expect(folders).toEqual({ sentId: 'sent', draftsId: 'drafts' });
expect(unsubscribeSendFolders([mb('t-sent', 'sent', 'team')])).toEqual({ sentId: undefined, draftsId: undefined });
});
});
+25 -1
View File
@@ -1,6 +1,9 @@
// List-Unsubscribe (RFC 2369 / RFC 8058) and mailto: parsing. Port of the
// relevant parts of the webmail's `lib/validation.ts`.
import type { Mailbox } from '../api/types';
import { mailboxesOfAccount } from './mailbox-tree';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function isValidEmail(value: string): boolean {
@@ -151,7 +154,10 @@ export function parseUnsubscribeMailto(url: string): UnsubscribeMailto | null {
} catch {
to = addresses[0].trim();
}
if (!isValidEmail(to)) return null;
// One plain address after decoding too: `a%2Cb@x.example` is two
// recipients to the server, and a quote, bracket or separator has no place
// in an unsubscribe address.
if (!isValidEmail(to) || /[,;<>"()\\]/.test(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;
@@ -169,3 +175,21 @@ export function unsubscribeConfirmMessage(intro: string, fields: UnsubscribeMail
if (fields.body) lines.push('', fields.body);
return lines.join('\n');
}
/**
* Where a mailto unsubscribe is created and submitted: the user's own
* account, whose identities are the ones the app holds, with its Sent and
* Drafts (raw ids). Like the webmail, which sends it with the signed-in
* client and the identity that received the message. Creating it in a
* shared account's folders with an identity of the user's own account made
* the submission name an identity that account doesn't have.
*/
export function unsubscribeSendFolders(mailboxes: Mailbox[]): { sentId?: string; draftsId?: string } {
const own = mailboxesOfAccount(mailboxes, undefined);
const sent = own.find((m) => m.role === 'sent');
const drafts = own.find((m) => m.role === 'drafts');
return {
sentId: sent ? (sent.originalId ?? sent.id) : undefined,
draftsId: drafts ? (drafts.originalId ?? drafts.id) : undefined,
};
}