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 ?? []),