fix(mailto): Use paste logic to parse fields in mailto links, support poorly encoded URLs

Summary:
Fixes T1927

- Allow email addresses to contain `_`
- Centralize logic for parsing string into Contact objects into ContactStore
- Always decode and then encode mailto links to ensure spaces, special characters are properly encoded and that the URL is valid
- Add specs for mailto:// behavior

Test Plan: Run wonderful new tests covering mailto://

Reviewers: evan

Reviewed By: evan

Maniphest Tasks: T1927

Differential Revision: https://phab.nylas.com/D1657
This commit is contained in:
Ben Gotow 2015-06-18 11:58:07 -07:00
parent 9b6117afdb
commit 0e96dd3372
6 changed files with 134 additions and 46 deletions

View file

@ -1,9 +1,7 @@
React = require 'react'
_ = require 'underscore'
{Contact,
Utils,
ContactStore} = require 'nylas-exports'
{Contact, ContactStore} = require 'nylas-exports'
{TokenizingTextField, Menu} = require 'nylas-component-kit'
class ParticipantsTextField extends React.Component
@ -85,32 +83,7 @@ class ParticipantsTextField extends React.Component
# parentheses, look for a preceding name, if one exists.
if _.isString(values)
detected = []
while (match = Utils.emailRegex.exec(values))
email = match[0]
name = null
hasLeadingParen = values[match.index-1] in ['(','<']
hasTrailingParen = values[match.index+email.length] in [')','>']
if hasLeadingParen and hasTrailingParen
nameStart = 0
for char in ['>', ')', ',', '\n', '\r']
i = values.lastIndexOf(char, match.index)
nameStart = i+1 if i+1 > nameStart
name = values.substr(nameStart, match.index - 1 - nameStart).trim()
if not name or name.length is 0
# Look to see if we can find a name for this email address in the ContactStore.
# Otherwise, just populate the name with the email address.
existing = ContactStore.searchContacts(email, {limit:1})[0]
if existing and existing.name
name = existing.name
else
name = email
detected.push(new Contact({email, name}))
values = detected
values = ContactStore.parseContactsInString(values)
# Safety check: remove anything from the incoming values that isn't
# a Contact. We should never receive anything else in the values array.

View file

@ -9,6 +9,7 @@ TaskQueue = require '../../src/flux/stores/task-queue'
SendDraftTask = require '../../src/flux/tasks/send-draft'
DestroyDraftTask = require '../../src/flux/tasks/destroy-draft'
Actions = require '../../src/flux/actions'
Utils = require '../../src/flux/models/utils'
_ = require 'underscore'
fakeThread = null
@ -600,3 +601,81 @@ describe "DraftStore", ->
it "Calls cleanup on the session", ->
expect(@draftCleanup).toHaveBeenCalled
describe "mailto handling", ->
it "should correctly instantiate drafts for a wide range of mailto URLs", ->
received = null
spyOn(DatabaseStore, 'persistModel').andCallFake (draft) ->
received = draft
Promise.resolve()
links = [
'mailto:'
'mailto://bengotow@gmail.com'
'mailto:bengotow@gmail.com'
'mailto:?subject=%1z2a', # fails uriDecode
'mailto:?subject=%52z2a', # passes uriDecode
'mailto:?subject=Martha Stewart',
'mailto:?subject=Martha Stewart&cc=cc@nylas.com',
'mailto:bengotow@gmail.com?subject=Martha%20Stewart&cc=cc@nylas.com&bcc=bcc@nylas.com',
'mailto:bengotow@gmail.com?subject=Martha%20Stewart&cc=cc@nylas.com&bcc=Ben <bcc@nylas.com>',
'mailto:Ben Gotow <bengotow@gmail.com>,Shawn <shawn@nylas.com>?subject=Yes this is really valid'
'mailto:Ben%20Gotow%20<bengotow@gmail.com>,Shawn%20<shawn@nylas.com>?subject=Yes%20this%20is%20really%20valid'
'mailto:Reply <d+AORGpRdj0KXKUPBE1LoI0a30F10Ahj3wu3olS-aDk5_7K5Wu6WqqqG8t1HxxhlZ4KEEw3WmrSdtobgUq57SkwsYAH6tG57IrNqcQR0K6XaqLM2nGNZ22D2k@docs.google.com>?subject=Nilas%20Message%20to%20Customers',
]
expected = [
new Message(),
new Message(
to: [new Contact(name: 'bengotow@gmail.com', email: 'bengotow@gmail.com')]
),
new Message(
to: [new Contact(name: 'bengotow@gmail.com', email: 'bengotow@gmail.com')]
),
new Message(
subject: '%1z2a'
),
new Message(
subject: 'Rz2a'
),
new Message(
subject: 'Martha Stewart'
),
new Message(
cc: [new Contact(name: 'cc@nylas.com', email: 'cc@nylas.com')],
subject: 'Martha Stewart'
),
new Message(
to: [new Contact(name: 'bengotow@gmail.com', email: 'bengotow@gmail.com')],
cc: [new Contact(name: 'cc@nylas.com', email: 'cc@nylas.com')],
bcc: [new Contact(name: 'bcc@nylas.com', email: 'bcc@nylas.com')],
subject: 'Martha Stewart'
),
new Message(
to: [new Contact(name: 'bengotow@gmail.com', email: 'bengotow@gmail.com')],
cc: [new Contact(name: 'cc@nylas.com', email: 'cc@nylas.com')],
bcc: [new Contact(name: 'Ben', email: 'bcc@nylas.com')],
subject: 'Martha Stewart'
),
new Message(
to: [new Contact(name: 'Ben Gotow', email: 'bengotow@gmail.com'), new Contact(name: 'Shawn', email: 'shawn@nylas.com')],
subject: 'Yes this is really valid'
),
new Message(
to: [new Contact(name: 'Ben Gotow', email: 'bengotow@gmail.com'), new Contact(name: 'Shawn', email: 'shawn@nylas.com')],
subject: 'Yes this is really valid'
),
new Message(
to: [new Contact(name: 'Reply', email: 'd+AORGpRdj0KXKUPBE1LoI0a30F10Ahj3wu3olS-aDk5_7K5Wu6WqqqG8t1HxxhlZ4KEEw3WmrSdtobgUq57SkwsYAH6tG57IrNqcQR0K6XaqLM2nGNZ22D2k@docs.google.com')],
subject: 'Nilas Message to Customers'
)
]
for link, idx in links
DraftStore._onHandleMailtoLink(link)
expectedDraft = expected[idx]
expect(received['subject']).toEqual(expectedDraft['subject'])
for attr in ['to', 'cc', 'bcc', 'subject']
for contact, jdx in received[attr]
expectedContact = expectedDraft[attr][jdx]
expect(contact.email).toEqual(expectedContact.email)
expect(contact.name).toEqual(expectedContact.name)

View file

@ -302,7 +302,7 @@ Utils =
toMatch = domains[0]
return _.every(domains, (domain) -> domain.length > 0 and toMatch is domain)
emailRegex: /[a-z.A-Z0-9%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}/g
emailRegex: /[a-z.A-Z0-9%+_-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}/g
emailHasCommonDomain: (email="") ->
domain = _.last(email.toLowerCase().trim().split("@"))

View file

@ -1,6 +1,7 @@
Reflux = require 'reflux'
Actions = require '../actions'
Contact = require '../models/contact'
Utils = require '../models/utils'
DatabaseStore = require './database-store'
NamespaceStore = require './namespace-store'
_ = require 'underscore'
@ -81,6 +82,34 @@ class ContactStore
matches
parseContactsInString: (str) =>
detected = []
while (match = Utils.emailRegex.exec(str))
email = match[0]
name = null
hasLeadingParen = str[match.index-1] in ['(','<']
hasTrailingParen = str[match.index+email.length] in [')','>']
if hasLeadingParen and hasTrailingParen
nameStart = 0
for char in ['>', ')', ',', '\n', '\r']
i = str.lastIndexOf(char, match.index)
nameStart = i+1 if i+1 > nameStart
name = str.substr(nameStart, match.index - 1 - nameStart).trim()
if not name or name.length is 0
# Look to see if we can find a name for this email address in the ContactStore.
# Otherwise, just populate the name with the email address.
existing = @searchContacts(email, {limit:1})[0]
if existing and existing.name
name = existing.name
else
name = email
detected.push(new Contact({email, name}))
detected
__refreshCache: =>
new Promise (resolve, reject) =>
DatabaseStore.findAll(Contact)

View file

@ -352,11 +352,13 @@ class DraftStore
namespace = NamespaceStore.current()
return unless namespace
url = require 'url'
qs = require 'querystring'
parts = url.parse(urlString)
query = qs.parse(parts.query)
query.to = "#{parts.auth}@#{parts.host}"
try
urlString = decodeURI(urlString)
[whole, to, query] = /mailto:[//]?([^\?]*)[\?]?(.*)/.exec(urlString)
query = require('querystring').parse(query)
query.to = to
draft = new Message
body: query.body || ''
@ -367,13 +369,9 @@ class DraftStore
pristine: true
namespaceId: namespace.id
contactForEmail = (email) ->
match = ContactStore.searchContacts(email, 1)
return match[0] if match[0]
return new Contact({email})
for attr in ['to', 'cc', 'bcc']
draft[attr] = query[attr]?.split(',').map(contactForEmail) || []
if query[attr]
draft[attr] = ContactStore.parseContactsInString(query[attr])
DatabaseStore.persistModel(draft).then =>
DatabaseStore.localIdForModel(draft).then(@_onPopoutDraftLocalId)

View file

@ -130,11 +130,20 @@ class WindowEventHandler
openLink: ({href, target, currentTarget}) ->
if not href
href = target?.getAttribute('href') or currentTarget?.getAttribute('href')
if href?
schema = url.parse(href).protocol
if schema? and schema in ['http:', 'https:', 'mailto:', 'tel:']
shell.openExternal(href)
false
return unless href
schema = url.parse(href).protocol
return unless schema
if schema is 'mailto:'
# We sometimes get mailto URIs that are not escaped properly, or have been only partially escaped.
# (T1927) Be sure to escape them once, and completely, before we try to open them. This logic
# *might* apply to http/https as well but it's unclear.
shell.openExternal(encodeURI(decodeURI(href)))
else if schema in ['http:', 'https:', 'tel:']
shell.openExternal(href)
return
eachTabIndexedElement: (callback) ->
for element in $('[tabindex]')