From f0aa0eedab5cded457fdf5eda19836225e654429 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:41:14 +0200 Subject: [PATCH] fix: show what a mailto unsubscribe sends and limit it to one recipient A newsletter's List-Unsubscribe `mailto:` was sent from the user's own account as written: every `to=` field became a recipient, and subject and body were the sender's choice. The confirmation said only "Unsubscribe from this sender?", so one click could mail "I resign" to someone's boss and HR. The unsubscribe now goes to the single address in the URL (a list of addresses is refused and `to=` is ignored), the subject is kept on one line and both subject and body are capped. Both confirmations show the recipient, subject and body before anything is sent. --- .../__tests__/unsubscribe-banner.test.tsx | 52 +++++++++++++++++++ components/email/unsubscribe-banner.tsx | 21 ++++++-- components/ui/confirm-dialog.tsx | 4 ++ lib/__tests__/validation.test.ts | 29 +++++++++++ lib/validation.ts | 33 ++++++++++++ 5 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 components/email/__tests__/unsubscribe-banner.test.tsx diff --git a/components/email/__tests__/unsubscribe-banner.test.tsx b/components/email/__tests__/unsubscribe-banner.test.tsx new file mode 100644 index 000000000..6ca90a263 --- /dev/null +++ b/components/email/__tests__/unsubscribe-banner.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); +vi.mock('@/hooks/use-media-query', () => ({ + useIsDesktop: () => true, +})); + +import { UnsubscribeBanner } from '../unsubscribe-banner'; + +// A List-Unsubscribe mailto: is written by the sender. One click used to +// send whatever it named - several hidden recipients, any subject and body - +// from the user's account, behind a dialog that showed none of it. +describe('UnsubscribeBanner mailto', () => { + it('shows where the message goes and sends it to that one address only', async () => { + const send = vi.fn().mockResolvedValue(undefined); + render( + {}} + />, + ); + + fireEvent.click(screen.getByText('email_viewer.unsubscribe_banner.button')); + expect(screen.getByText('boss@corp.example')).toBeTruthy(); + expect(screen.getByText('I resign')).toBeTruthy(); + expect(screen.getByText('Effective today')).toBeTruthy(); + + fireEvent.click(screen.getByText('email_viewer.unsubscribe_banner.confirm_button')); + await waitFor(() => expect(send).toHaveBeenCalledTimes(1)); + expect(send.mock.calls[0][0]).toEqual({ to: ['boss@corp.example'], subject: 'I resign', body: 'Effective today' }); + }); + + it('offers nothing for a mailto: naming several recipients', () => { + const { container } = render( + {}} + />, + ); + expect(container.textContent).toBe(''); + }); +}); diff --git a/components/email/unsubscribe-banner.tsx b/components/email/unsubscribe-banner.tsx index d4a64949c..361332080 100644 --- a/components/email/unsubscribe-banner.tsx +++ b/components/email/unsubscribe-banner.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useEffect } from 'react'; import { Loader2, CheckCircle, AlertCircle } from '@/components/icons'; import { useTranslations } from 'next-intl'; -import { isValidUnsubscribeUrl, parseMailtoUrl } from '@/lib/validation'; +import { isValidUnsubscribeUrl, parseUnsubscribeMailto } from '@/lib/validation'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useIsDesktop } from '@/hooks/use-media-query'; @@ -39,6 +39,16 @@ export function UnsubscribeBanner({ const unsubUrl = unsubMethod === 'http' ? listUnsubscribe.http : listUnsubscribe.mailto; + // The sender wrote this URL: the confirmation shows exactly what will be + // sent, and where, before the user's account sends it. + const mailtoFields = unsubMethod === 'mailto' && unsubUrl ? parseUnsubscribeMailto(unsubUrl) : null; + const mailtoDetails = mailtoFields ? ( +
+
{mailtoFields.to[0]}
+ {mailtoFields.subject &&
{mailtoFields.subject}
} + {mailtoFields.body &&
{mailtoFields.body}
} +
+ ) : null; useEffect(() => { if (!showConfirm) return; @@ -57,7 +67,7 @@ export function UnsubscribeBanner({ }; }, [showConfirm]); - if (!unsubUrl || !unsubMethod) { + if (!unsubUrl || !unsubMethod || (unsubMethod === 'mailto' && !mailtoFields)) { return null; } @@ -83,14 +93,13 @@ export function UnsubscribeBanner({ // once the server accepted it. The previous hidden-link click handed // the mailto: to the OS mail handler and claimed success even though // nothing was ever sent. - const fields = parseMailtoUrl(unsubUrl); - if (!fields) { + if (!mailtoFields) { setError(true); setProcessing(false); setShowConfirm(false); return; } - await onSendMailtoUnsubscribe(fields); + await onSendMailtoUnsubscribe(mailtoFields); setSuccess(true); setProcessing(false); @@ -152,6 +161,7 @@ export function UnsubscribeBanner({

{t('email_viewer.unsubscribe_banner.confirm_title')}

+ {mailtoDetails &&
{mailtoDetails}
}
diff --git a/lib/__tests__/validation.test.ts b/lib/__tests__/validation.test.ts index e096a37c9..97576e141 100644 --- a/lib/__tests__/validation.test.ts +++ b/lib/__tests__/validation.test.ts @@ -6,6 +6,7 @@ import { isValidUnsubscribeUrl, parseUnsubscribeUrls, parseMailtoUrl, + parseUnsubscribeMailto, } from '../validation'; describe('validation', () => { @@ -390,3 +391,31 @@ describe('parseMailtoUrl', () => { expect(r?.subject).toBe('%E0%A4%A'); }); }); + +describe('parseUnsubscribeMailto', () => { + it('keeps the one recipient, subject and body a list needs', () => { + expect(parseUnsubscribeMailto('mailto:list-request@example.com?subject=unsubscribe%20123')).toEqual({ + to: ['list-request@example.com'], subject: 'unsubscribe 123', body: undefined, + }); + }); + + it('ignores extra recipients smuggled in through to=', () => { + const r = parseUnsubscribeMailto('mailto:boss@corp.example?to=hr@corp.example&to=press@news.example&subject=I%20resign'); + expect(r?.to).toEqual(['boss@corp.example']); + }); + + it('refuses a list of addresses', () => { + expect(parseUnsubscribeMailto('mailto:a@example.com,b@example.com')).toBeNull(); + }); + + it('refuses an address part that is not an address, even with a valid to=', () => { + expect(parseUnsubscribeMailto('mailto:nobody?to=victim@example.com')).toBeNull(); + expect(parseUnsubscribeMailto('mailto:?to=victim@example.com')).toBeNull(); + }); + + it('keeps the subject on one line and caps subject and body', () => { + const r = parseUnsubscribeMailto(`mailto:l@example.com?subject=a%0D%0Ab&body=${'x'.repeat(2000)}`); + expect(r?.subject).toBe('a b'); + expect(r?.body).toHaveLength(500); + }); +}); diff --git a/lib/validation.ts b/lib/validation.ts index 0c2a5c770..2d988d2d2 100644 --- a/lib/validation.ts +++ b/lib/validation.ts @@ -165,3 +165,36 @@ export function parseMailtoUrl(url: string): { to: string[]; subject?: string; b return to.length > 0 ? { to, subject, body } : null; } + +const UNSUBSCRIBE_SUBJECT_MAX = 200; +const UNSUBSCRIBE_BODY_MAX = 500; + +/** + * Parse a List-Unsubscribe mailto: URL for a one-click send from the + * user's own account. The sender of the newsletter wrote this URL, so it is + * held to what an unsubscribe request needs: exactly one recipient, taken + * from the address part (`to=` query fields are ignored, and 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): { to: [string]; subject?: string; body?: string } | 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 }; +}