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 }; +}