mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
+3
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? []),
|
||||
|
||||
Reference in New Issue
Block a user