From ee652d39df49aa7a53d8413332a774d52ee5b2a1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:41:51 +0200 Subject: [PATCH] fix: open mailto links whose address contains a plus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/lib/__tests__/mailto.test.ts | 34 ++++++++++++++++++++++-- src/lib/mailto.ts | 4 ++- src/navigation/__tests__/linking.test.ts | 21 +++++++++++++++ src/navigation/linking.ts | 6 ++++- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/lib/__tests__/mailto.test.ts b/src/lib/__tests__/mailto.test.ts index 2e6667b8..da36162e 100644 --- a/src/lib/__tests__/mailto.test.ts +++ b/src/lib/__tests__/mailto.test.ts @@ -26,7 +26,37 @@ describe('parseMailtoUrl', () => { expect(parseMailtoUrl('')).toBeNull(); }); - it('is case-insensitive on the scheme and decodes + as space', () => { - expect(parseMailtoUrl('MAILTO:a@b.co?subject=a+b')?.subject).toBe('a b'); + it('is case-insensitive on the scheme', () => { + 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']); }); }); diff --git a/src/lib/mailto.ts b/src/lib/mailto.ts index f7378a7e..f4239675 100644 --- a/src/lib/mailto.ts +++ b/src/lib/mailto.ts @@ -16,9 +16,11 @@ export function isValidEmail(value: string): boolean { 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 { try { - return decodeURIComponent(value.replace(/\+/g, '%20')); + return decodeURIComponent(value); } catch { return value; } diff --git a/src/navigation/__tests__/linking.test.ts b/src/navigation/__tests__/linking.test.ts index 455d0daf..469cfe36 100644 --- a/src/navigation/__tests__/linking.test.ts +++ b/src/navigation/__tests__/linking.test.ts @@ -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', () => { expect(parseDeepLink('bulwarkmobile://whatever')).toBeNull(); expect(parseDeepLink('garbage')).toBeNull(); @@ -144,5 +162,8 @@ describe('shareToDeepLink', () => { 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: 'alice+news@partner.example' })).toEqual({ + kind: 'compose', to: [{ email: 'alice+news@partner.example' }], cc: [], subject: undefined, + }); }); }); diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts index 98e45602..5390a31e 100644 --- a/src/navigation/linking.ts +++ b/src/navigation/linking.ts @@ -102,7 +102,11 @@ export function parseDeepLink(url: string): DeepLink | null { return { kind: 'settings', tab: kind ? decodeSegment(kind) : undefined }; case 'compose': { 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 { kind: 'compose', to: toAddresses(parsed?.to ?? []),