From 918571c3c6baaf9e34a36ae1fbb91bb673c09f29 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:36:56 +0100 Subject: [PATCH] fix: send a mailto unsubscribe from the own account and only to one decoded address --- src/components/email/UnsubscribeBanner.tsx | 23 +++++++++-------- src/lib/__tests__/unsubscribe.test.ts | 29 +++++++++++++++++++++- src/lib/unsubscribe.ts | 26 ++++++++++++++++++- 3 files changed, 65 insertions(+), 13 deletions(-) diff --git a/src/components/email/UnsubscribeBanner.tsx b/src/components/email/UnsubscribeBanner.tsx index 75e7d244..a41205ac 100644 --- a/src/components/email/UnsubscribeBanner.tsx +++ b/src/components/email/UnsubscribeBanner.tsx @@ -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(); @@ -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(); } diff --git a/src/lib/__tests__/unsubscribe.test.ts b/src/lib/__tests__/unsubscribe.test.ts index 0b57a0cc..1746157b 100644 --- a/src/lib/__tests__/unsubscribe.test.ts +++ b/src/lib/__tests__/unsubscribe.test.ts @@ -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 }); + }); +}); diff --git a/src/lib/unsubscribe.ts b/src/lib/unsubscribe.ts index 01c1ddc9..923d8f54 100644 --- a/src/lib/unsubscribe.ts +++ b/src/lib/unsubscribe.ts @@ -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, + }; +}