Fix: send mailto unsubscribe ourselves instead of via the OS handler

The List-Unsubscribe action for mailto: links created a hidden anchor,
clicked it and reported success. That hands the mailto: URL to the OS
default mail handler - for a webmail user that opens the wrong program
or nothing at all, and the unsubscribe message is never sent, while the
banner still claims it was.

The confirm flow now parses the mailto: URL (address, subject, body -
percent-decoded manually since RFC 6068 does not use plus-encoding) and
sends the message through the account's own JMAP client, preferring the
identity that received the newsletter so the list can match the
subscriber. In unified views the send is routed to the email's owning
account. Success is only reported once the server accepted the message.

The mobile confirm dialog reused the success strings as its question
text; it gets proper confirm_message strings in all 22 locales, and
success_mailto now says what actually happened.
This commit is contained in:
dealerweb
2026-07-08 15:41:46 +02:00
committed by Linus Rath
parent 22418c17cf
commit 94af4725b6
26 changed files with 185 additions and 31 deletions
+31
View File
@@ -5,6 +5,7 @@ import {
getEmailValidationError,
isValidUnsubscribeUrl,
parseUnsubscribeUrls,
parseMailtoUrl,
} from '../validation';
describe('validation', () => {
@@ -359,3 +360,33 @@ describe('validation', () => {
});
});
});
describe('parseMailtoUrl', () => {
it('parses address, subject and body', () => {
const r = parseMailtoUrl('mailto:list@example.com?subject=Unsubscribe%20123&body=Please%20remove');
expect(r).toEqual({ to: ['list@example.com'], subject: 'Unsubscribe 123', body: 'Please remove' });
});
it('keeps a literal plus (RFC 6068 uses percent-encoding only)', () => {
const r = parseMailtoUrl('mailto:owner+unsub@example.com?subject=a+b');
expect(r?.to).toEqual(['owner+unsub@example.com']);
expect(r?.subject).toBe('a+b');
});
it('supports multiple recipients and the to param', () => {
const r = parseMailtoUrl('mailto:a@example.com,b@example.com?to=c@example.com');
expect(r?.to).toEqual(['a@example.com', 'b@example.com', 'c@example.com']);
});
it('returns null without a valid recipient', () => {
expect(parseMailtoUrl('mailto:?subject=x')).toBeNull();
expect(parseMailtoUrl('mailto:not-an-address')).toBeNull();
expect(parseMailtoUrl('https://example.com/unsub')).toBeNull();
});
it('survives malformed percent-encoding', () => {
const r = parseMailtoUrl('mailto:list@example.com?subject=%E0%A4%A');
expect(r?.to).toEqual(['list@example.com']);
expect(r?.subject).toBe('%E0%A4%A');
});
});
+44
View File
@@ -121,3 +121,47 @@ export function parseUnsubscribeUrls(header: string): {
return { http, mailto, preferred };
}
/**
* Parse a mailto: URL into its parts so the client can send the message
* itself. Query values are percent-decoded manually rather than via
* URLSearchParams because RFC 6068 uses %-encoding only - a literal "+"
* in a subject or address must stay a plus, not become a space.
* @param url - mailto: URL, e.g. "mailto:a@b.c?subject=Unsubscribe%20123"
* @returns Recipients plus optional subject/body, or null without a valid recipient
*/
export function parseMailtoUrl(url: string): { to: string[]; subject?: string; body?: string } | null {
if (!url?.startsWith('mailto:')) return null;
const rest = url.slice(7);
const queryIndex = rest.indexOf('?');
const addressPart = queryIndex === -1 ? rest : rest.slice(0, queryIndex);
const query = queryIndex === -1 ? '' : rest.slice(queryIndex + 1);
const decode = (value: string): string => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const to = addressPart
.split(',')
.map(a => decode(a).trim())
.filter(a => isValidEmail(a));
let subject: string | undefined;
let body: string | undefined;
for (const pair of query.split('&')) {
const eq = pair.indexOf('=');
if (eq === -1) continue;
const key = pair.slice(0, eq).toLowerCase();
const value = decode(pair.slice(eq + 1));
if (key === 'subject') subject = value;
else if (key === 'body') body = value;
else if (key === 'to' && isValidEmail(value.trim())) to.push(value.trim());
}
return to.length > 0 ? { to, subject, body } : null;
}