mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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.
This commit is contained in:
@@ -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(
|
||||
<UnsubscribeBanner
|
||||
listUnsubscribe={{
|
||||
mailto: 'mailto:boss@corp.example?to=hr@corp.example&subject=I%20resign&body=Effective%20today',
|
||||
preferred: 'mailto',
|
||||
}}
|
||||
senderEmail="news@evil.example"
|
||||
onSendMailtoUnsubscribe={send}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<UnsubscribeBanner
|
||||
listUnsubscribe={{ mailto: 'mailto:a@example.com,b@example.com', preferred: 'mailto' }}
|
||||
senderEmail="news@example.com"
|
||||
onSendMailtoUnsubscribe={vi.fn()}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
<div className="mt-2 space-y-0.5 text-xs text-muted-foreground break-all">
|
||||
<div className="font-medium text-foreground">{mailtoFields.to[0]}</div>
|
||||
{mailtoFields.subject && <div>{mailtoFields.subject}</div>}
|
||||
{mailtoFields.body && <div className="whitespace-pre-line line-clamp-4">{mailtoFields.body}</div>}
|
||||
</div>
|
||||
) : 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({
|
||||
<p className="text-sm text-foreground mb-2">
|
||||
{t('email_viewer.unsubscribe_banner.confirm_title')}
|
||||
</p>
|
||||
{mailtoDetails && <div className="mb-2 max-w-[320px]">{mailtoDetails}</div>}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleUnsubscribe}
|
||||
@@ -185,6 +195,7 @@ export function UnsubscribeBanner({
|
||||
? 'email_viewer.unsubscribe_banner.confirm_message_http'
|
||||
: 'email_viewer.unsubscribe_banner.confirm_message_mailto'
|
||||
)}
|
||||
details={mailtoDetails}
|
||||
confirmText={t('email_viewer.unsubscribe_banner.confirm_button')}
|
||||
cancelText={t('email_viewer.unsubscribe_banner.cancel')}
|
||||
variant="destructive"
|
||||
|
||||
@@ -13,6 +13,8 @@ interface ConfirmDialogProps {
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
/** Shown under the message, e.g. exactly what confirming will send. */
|
||||
details?: React.ReactNode;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: "default" | "destructive";
|
||||
@@ -24,6 +26,7 @@ export function ConfirmDialog({
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
confirmText,
|
||||
cancelText,
|
||||
variant = "default",
|
||||
@@ -85,6 +88,7 @@ export function ConfirmDialog({
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
{details}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user