fix: open mailto links whose address contains a plus

The mailto parser decoded `+` as a space, as in HTML form encoding, so
`mailto:alice+news@partner.example` became "alice news@…", failed
validation and opened nothing; a `+` in a subject or body was turned
into a space as well. RFC 6068 only uses percent-encoding, so a `+` now
stays a plus, like the webmail's `parseMailtoUrl`.

App compose links (`bulwarkmobile://compose?…`) went through the same
parser after `URLSearchParams.toString()`, which writes spaces as `+`.
They are now re-encoded with percent-encoding, and the recipient is read
from the `to=` parameter only, which also stops it from being added
twice.
This commit is contained in:
Linus Rath
2026-09-24 18:43:43 +02:00
parent 06e89422bd
commit ee652d39df
4 changed files with 61 additions and 4 deletions
+32 -2
View File
@@ -26,7 +26,37 @@ describe('parseMailtoUrl', () => {
expect(parseMailtoUrl('')).toBeNull(); expect(parseMailtoUrl('')).toBeNull();
}); });
it('is case-insensitive on the scheme and decodes + as space', () => { it('is case-insensitive on the scheme', () => {
expect(parseMailtoUrl('MAILTO:a@b.co?subject=a+b')?.subject).toBe('a b'); expect(parseMailtoUrl('MAILTO:a@b.co')?.to).toEqual(['a@b.co']);
});
// RFC 6068 has no form-encoding: `+` is a literal plus, only %XX is decoded.
it('keeps a + in an address literal', () => {
expect(parseMailtoUrl('mailto:alice+news@partner.example')).toEqual({
to: ['alice+news@partner.example'],
cc: [],
bcc: [],
subject: undefined,
body: undefined,
});
expect(parseMailtoUrl('mailto:alice%2Bnews@partner.example')?.to).toEqual(['alice+news@partner.example']);
});
it('keeps a + in the subject and body literal and decodes %20 as the space', () => {
const parsed = parseMailtoUrl('mailto:a@b.co?subject=a+b&body=1+1%20%3D%202');
expect(parsed?.subject).toBe('a+b');
expect(parsed?.body).toBe('1+1 = 2');
expect(parseMailtoUrl('mailto:a@b.co?subject=a%2Bb')?.subject).toBe('a+b');
});
it('keeps + addresses among several recipients and in to/cc/bcc', () => {
const parsed = parseMailtoUrl(
'mailto:alice+news@partner.example,bob@partner.example'
+ '?to=carol+x@partner.example&cc=dave+y@partner.example,erin%2Bz%40partner.example'
+ '&bcc=frank+w@partner.example',
);
expect(parsed?.to).toEqual(['alice+news@partner.example', 'bob@partner.example', 'carol+x@partner.example']);
expect(parsed?.cc).toEqual(['dave+y@partner.example', 'erin+z@partner.example']);
expect(parsed?.bcc).toEqual(['frank+w@partner.example']);
}); });
}); });
+3 -1
View File
@@ -16,9 +16,11 @@ export function isValidEmail(value: string): boolean {
return EMAIL_RE.test(value.trim()); return EMAIL_RE.test(value.trim());
} }
// RFC 6068 only percent-encodes: a `+` is a literal plus (sub-addresses such
// as `alice+news@…`, "a+b" in a subject), not the form-encoding for a space.
function decode(value: string): string { function decode(value: string): string {
try { try {
return decodeURIComponent(value.replace(/\+/g, '%20')); return decodeURIComponent(value);
} catch { } catch {
return value; return value;
} }
+21
View File
@@ -56,6 +56,24 @@ describe('parseDeepLink', () => {
}); });
}); });
it('opens the composer for a mailto: with a sub-address', () => {
expect(parseDeepLink('mailto:alice+news@partner.example?subject=a+b')).toMatchObject({
kind: 'compose',
to: [{ email: 'alice+news@partner.example' }],
subject: 'a+b',
});
});
it('parses app compose links once per recipient, with form-encoded spaces', () => {
expect(parseDeepLink('bulwarkmobile://compose?to=alice%2Bnews@partner.example&subject=Hello+World&body=1%2B1')).toEqual({
kind: 'compose',
to: [{ email: 'alice+news@partner.example' }],
cc: [],
subject: 'Hello World',
body: '1+1',
});
});
it('rejects unknown links', () => { it('rejects unknown links', () => {
expect(parseDeepLink('bulwarkmobile://whatever')).toBeNull(); expect(parseDeepLink('bulwarkmobile://whatever')).toBeNull();
expect(parseDeepLink('garbage')).toBeNull(); expect(parseDeepLink('garbage')).toBeNull();
@@ -144,5 +162,8 @@ describe('shareToDeepLink', () => {
kind: 'compose', to: [{ email: 'a@b.co' }], cc: [], subject: undefined, kind: 'compose', to: [{ email: 'a@b.co' }], cc: [], subject: undefined,
}); });
expect(shareToDeepLink({ text: 'mailto:a@b.co?subject=x' })).toMatchObject({ kind: 'compose', subject: 'x' }); expect(shareToDeepLink({ text: 'mailto:a@b.co?subject=x' })).toMatchObject({ kind: 'compose', subject: 'x' });
expect(shareToDeepLink({ text: 'alice+news@partner.example' })).toEqual({
kind: 'compose', to: [{ email: 'alice+news@partner.example' }], cc: [], subject: undefined,
});
}); });
}); });
+5 -1
View File
@@ -102,7 +102,11 @@ export function parseDeepLink(url: string): DeepLink | null {
return { kind: 'settings', tab: kind ? decodeSegment(kind) : undefined }; return { kind: 'settings', tab: kind ? decodeSegment(kind) : undefined };
case 'compose': { case 'compose': {
const to = search.get('to'); const to = search.get('to');
const parsed = to ? parseMailtoUrl(`mailto:${to}?${search.toString()}`) : null; // URLSearchParams has already decoded the values; re-encode them for
// the mailto parser, which keeps `+` literal (toString() would write
// spaces as `+`). The recipients come from the `to=` param alone.
const query = Array.from(search, ([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&');
const parsed = to ? parseMailtoUrl(`mailto:?${query}`) : null;
return { return {
kind: 'compose', kind: 'compose',
to: toAddresses(parsed?.to ?? []), to: toAddresses(parsed?.to ?? []),