feat(tracking-pixels): New MessageStore extension cuts out tracking pixels you've *sent* so you don't trigger them

Summary:
- Remove thread_participants prop, we don't use them anywhere and the underscore-case is ugly.

- Move autolinker into extension, update autolinker to 0.18.1 for phone number support

- document message.coffee, add isFromMe()

- Add tracking pixel extension that removes pixels from mail you *send*. Maybe more features later.

Test Plan: Run 1 new test! (woo...)

Reviewers: evan

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D1787
This commit is contained in:
Ben Gotow 2015-07-23 10:57:13 -07:00
parent dc79fead2b
commit 4be05ff754
20 changed files with 288 additions and 69 deletions

View file

@ -45,6 +45,7 @@ Exports =
# Utils
Utils: Utils
DOMUtils: require '../src/dom-utils'
RegExpUtils: require '../src/regexp-utils'
MessageUtils: require '../src/flux/models/message-utils'
# Mixins
@ -58,6 +59,7 @@ Exports =
DraftCountStore: require '../src/flux/stores/draft-count-store'
DraftStoreExtension: require '../src/flux/stores/draft-store-extension'
MessageStore: require '../src/flux/stores/message-store'
MessageStoreExtension: require '../src/flux/stores/message-store-extension'
ContactStore: require '../src/flux/stores/contact-store'
MetadataStore: require '../src/flux/stores/metadata-store'
NamespaceStore: require '../src/flux/stores/namespace-store'

View file

@ -1,12 +1,16 @@
MessageList = require "./message-list"
MessageToolbarItems = require "./message-toolbar-items"
{ComponentRegistry,
MessageStore,
WorkspaceStore} = require 'nylas-exports'
SidebarThreadParticipants = require "./sidebar-thread-participants"
ThreadStarButton = require './thread-star-button'
ThreadArchiveButton = require './thread-archive-button'
AutolinkerExtension = require './plugins/autolinker-extension'
TrackingPixelsExtension = require './plugins/tracking-pixels-extension'
module.exports =
item: null # The DOM item the main React component renders into
@ -27,11 +31,16 @@ module.exports =
ComponentRegistry.register ThreadArchiveButton,
role: 'message:Toolbar'
MessageStore.registerExtension(AutolinkerExtension)
MessageStore.registerExtension(TrackingPixelsExtension)
deactivate: ->
ComponentRegistry.unregister MessageList
ComponentRegistry.unregister ThreadStarButton
ComponentRegistry.unregister ThreadArchiveButton
ComponentRegistry.unregister MessageToolbarItems
ComponentRegistry.unregister SidebarThreadParticipants
MessageStore.unregisterExtension(AutolinkerExtension)
MessageStore.unregisterExtension(TrackingPixelsExtension)
serialize: -> @state

View file

@ -8,13 +8,13 @@ MessageControls = require './message-controls'
{Utils,
Actions,
MessageUtils,
MessageStore,
QuotedHTMLParser,
ComponentRegistry,
FileDownloadStore} = require 'nylas-exports'
{RetinaImg,
InjectedComponentSet,
InjectedComponent} = require 'nylas-component-kit'
Autolinker = require 'autolinker'
TransparentPixel = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNikAQAACIAHF/uBd8AAAAASUVORK5CYII="
MessageBodyWidth = 740
@ -25,7 +25,6 @@ class MessageItem extends React.Component
@propTypes =
thread: React.PropTypes.object.isRequired
message: React.PropTypes.object.isRequired
thread_participants: React.PropTypes.arrayOf(React.PropTypes.object)
collapsed: React.PropTypes.bool
constructor: (@props) ->
@ -105,7 +104,6 @@ class MessageItem extends React.Component
from={@props.message.from}
subject={@props.message.subject}
onClick={@_onClickParticipants}
thread_participants={@props.thread_participants}
isDetailed={@state.detailedHeaders}
message_participants={@props.message.participants()} />
@ -166,10 +164,15 @@ class MessageItem extends React.Component
_formatBody: =>
return "" unless @props.message and @props.message.body
# Give each extension the message object to process the body, but don't
# allow them to modify anything but the body for the time being.
body = @props.message.body
# Apply the autolinker pass to make emails and links clickable
body = Autolinker.link(body, {twitter: false})
for extension in MessageStore.extensions()
continue unless extension.formatMessageBody
virtual = @props.message.clone()
virtual.body = body
extension.formatMessageBody(virtual)
body = virtual.body
# Find inline images and give them a calculated CSS height based on
# html width and height, when available. This means nothing changes size

View file

@ -297,7 +297,6 @@ class MessageList extends React.Component
_messageComponents: =>
appliedInitialScroll = false
threadParticipants = @_threadParticipants()
components = []
messages = @_messagesWithMinification(@state.messages)
@ -335,8 +334,7 @@ class MessageList extends React.Component
message={message}
className={className}
collapsed={collapsed}
isLastMsg={(messages.length - 1 is idx)}
thread_participants={threadParticipants} />
isLastMsg={(messages.length - 1 is idx)} />
components.push @_renderReplyArea()
@ -445,19 +443,6 @@ class MessageList extends React.Component
@setState(ready: true)
@_cacheScrollPos()
_threadParticipants: =>
# We calculate the list of participants instead of grabbing it from
# `@state.currentThread.participants` because it makes it easier to
# test, is a better source of ground truth, and saves us from more
# dependencies.
participants = {}
for msg in (@state.messages ? [])
contacts = msg.participants()
for contact in contacts
if contact? and contact.email?.length > 0
participants[contact.email] = contact
return _.values(participants)
_onResize: (event) =>
@_scrollToBottom() if @_wasAtBottom()
@_cacheScrollPos()

View file

@ -0,0 +1,10 @@
Autolinker = require 'autolinker'
{MessageStoreExtension} = require 'nylas-exports'
class AutolinkerExtension extends MessageStoreExtension
@formatMessageBody: (message) ->
# Apply the autolinker pass to make emails and links clickable
message.body = Autolinker.link(message.body, {twitter: false})
module.exports = AutolinkerExtension

View file

@ -0,0 +1,122 @@
{MessageStoreExtension, RegExpUtils} = require 'nylas-exports'
TrackingBlacklist = [{
name: 'Sidekick',
pattern: 't.signaux',
homepage: 'http://getsidekick.com'
}, {
name: 'Sidekick',
pattern: 't.senal',
homepage: 'http://getsidekick.com'
}, {
name: 'Sidekick',
pattern: 't.sidekickopen',
homepage: 'http://getsidekick.com'
}, {
name: 'Sidekick',
pattern: 't.sigopn',
homepage: 'http://getsidekick.com'
}, {
name: 'Banana Tag',
pattern: 'bl-1.com',
homepage: 'http://bananatag.com'
}, {
name: 'Boomerang',
pattern: 'mailstat.us/tr',
homepage: 'http://boomeranggmail.com'
}, {
name: 'Cirrus Inisght',
pattern: 'tracking.cirrusinsight.com',
homepage: 'http://cirrusinsight.com'
}, {
name: 'Yesware',
pattern: 'app.yesware.com',
homepage: 'http://yesware.com'
}, {
name: 'Yesware',
pattern: 't.yesware.com',
homepage: 'http://yesware.com'
}, {
name: 'Streak',
pattern: 'mailfoogae.appspot.com',
homepage: 'http://streak.com'
}, {
name: 'LaunchBit',
pattern: 'launchbit.com/taz-pixel',
homepage: 'http://launchbit.com'
}, {
name: 'MailChimp',
pattern: 'list-manage.com/track',
homepage: 'http://mailchimp.com'
}, {
name: 'Postmark',
pattern: 'cmail1.com/t',
homepage: 'http://postmarkapp.com'
}, {
name: 'iContact',
pattern: 'click.icptrack.com/icp/',
homepage: 'http://icontact.com'
}, {
name: 'Infusionsoft',
pattern: 'infusionsoft.com/app/emailOpened',
homepage: 'http://infusionsoft.com'
}, {
name: 'Intercom',
pattern: 'via.intercom.io/o',
homepage: 'http://intercom.io'
}, {
name: 'Mandrill',
pattern: 'mandrillapp.com/track',
homepage: 'http://mandrillapp.com'
}, {
name: 'Hubspot',
pattern: 't.hsms06.com',
homepage: 'http://hubspot.com'
}, {
name: 'RelateIQ',
pattern: 'app.relateiq.com/t.png',
homepage: 'http://relateiq.com'
}, {
name: 'RJ Metrics',
pattern: 'go.rjmetrics.com',
homepage: 'http://rjmetrics.com'
}, {
name: 'Mixpanel',
pattern: 'api.mixpanel.com/track',
homepage: 'http://mixpanel.com'
}, {
name: 'Front App',
pattern: 'web.frontapp.com/api',
homepage: 'http://frontapp.com'
}, {
name: 'Mailtrack.io',
pattern: 'mailtrack.io/trace',
homepage: 'http://mailtrack.io'
}, {
name: 'Salesloft',
pattern: 'sdr.salesloft.com/email_trackers',
homepage: 'http://salesloft.com'
}]
class TrackingPixelsExtension extends MessageStoreExtension
@formatMessageBody: (message) ->
return unless message.isFromMe()
regex = RegExpUtils.imageTagRegex()
body = message.body
spliceRegions = []
# Identify img tags that should be cut out
while (result = regex.exec(body)) isnt null
for item in TrackingBlacklist
if result[1].indexOf(item.pattern) > 0
spliceRegions.push(start: result.index, end: result.index + result[0].length)
continue
# Remove them all, from the end of the string to the start
spliceRegions.reverse().forEach ({start, end}) ->
body = body.substr(0, start) + body.substr(end)
message.body = body
module.exports = TrackingPixelsExtension

View file

@ -9,5 +9,6 @@
"atom": "*"
},
"dependencies": {
"autolinker": "0.18.1"
}
}

View file

@ -127,8 +127,7 @@ describe "MessageItem", ->
<MessageItem key={@message.id}
message={@message}
thread={@thread}
collapsed={collapsed}
thread_participants={@threadParticipants} />
collapsed={collapsed} />
)
# TODO: We currently don't support collapsed messages

View file

@ -202,10 +202,6 @@ describe "MessageList", ->
msgs = TestUtils.scryRenderedDOMComponentsWithClass(@messageList, "message-item-wrap collapsed")
expect(msgs.length).toBe 4
it "aggregates participants across all messages", ->
expect(@messageList._threadParticipants().length).toBe 4
expect(@messageList._threadParticipants()[0] instanceof Contact).toBe true
it "displays lists of participants on the page", ->
items = TestUtils.scryRenderedComponentsWithType(@messageList,
MessageParticipants)

View file

@ -39,21 +39,6 @@ big_test_message = (new Message).fromJSON({
many_thread_users = [user_1].concat(many_users)
thread_participants = [
(new Contact(user_1)),
(new Contact(user_2)),
(new Contact(user_3)),
(new Contact(user_4))
]
thread2_participants = [
(new Contact(user_1)),
(new Contact(user_2)),
(new Contact(user_3)),
(new Contact(user_4)),
(new Contact(user_5))
]
describe "MessageParticipants", ->
describe "when collapsed", ->
beforeEach ->
@ -61,7 +46,6 @@ describe "MessageParticipants", ->
<MessageParticipants to={test_message.to}
cc={test_message.cc}
from={test_message.from}
thread_participants={many_thread_users}
message_participants={test_message.participants()} />
)
@ -79,7 +63,6 @@ describe "MessageParticipants", ->
<MessageParticipants to={test_message.to}
cc={test_message.cc}
from={test_message.from}
thread_participants={many_thread_users}
isDetailed={true}
message_participants={test_message.participants()} />
)
@ -100,7 +83,6 @@ describe "MessageParticipants", ->
# <MessageParticipants to={big_test_message.to}
# cc={big_test_message.cc}
# from={big_test_message.from}
# thread_participants={many_thread_users}
# message_participants={big_test_message.participants()} />
# )
# expect(p1._isToEveryone()).toBe true
@ -110,7 +92,6 @@ describe "MessageParticipants", ->
# <MessageParticipants to={test_message.to}
# cc={test_message.cc}
# from={test_message.from}
# thread_participants={thread2_participants}
# message_participants={test_message.participants()} />
# )
# # this should be false because we don't count bccs
@ -121,7 +102,6 @@ describe "MessageParticipants", ->
# <MessageParticipants to={test_message.to}
# cc={test_message.cc}
# from={test_message.from}
# thread_participants={thread_participants}
# message_participants={test_message.participants()} />
# )
# # this should be false because we don't count bccs

View file

@ -0,0 +1,31 @@
TrackingPixelsExtension = require '../lib/plugins/tracking-pixels-extension'
{Message} = require 'nylas-exports'
testBody = """
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"><p>Hey Ben,</p><p>
I've noticed that we don't yet have an SLA in place with&nbsp;Nylas. Are you the right
person to be speaking with to make sure everything is set up on that end? If not,
could you please put me in touch with them, so that we can get you guys set up
correctly as soon as possible?</p><p>Thanks!</p><p>Gleb Polyakov</p><p>Head of
Business Development and Growth</p><img src="https://sdr.salesloft.com/email_trackers/8c8bea88-af43-4f66-bf78-a97ad73d7aec/open.gif" alt="" width="1" height="1">After Pixel
"""
testBodyProcessed = """
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"><p>Hey Ben,</p><p>
I've noticed that we don't yet have an SLA in place with&nbsp;Nylas. Are you the right
person to be speaking with to make sure everything is set up on that end? If not,
could you please put me in touch with them, so that we can get you guys set up
correctly as soon as possible?</p><p>Thanks!</p><p>Gleb Polyakov</p><p>Head of
Business Development and Growth</p>After Pixel
"""
describe "TrackingPixelsExtension", ->
it "should splice tracking pixels and only run on messages by the current user", ->
message = new Message(body: testBody)
spyOn(message, 'isFromMe').andCallFake -> false
TrackingPixelsExtension.formatMessageBody(message)
expect(message.body).toEqual(testBody)
message = new Message(body: testBody)
spyOn(message, 'isFromMe').andCallFake -> true
TrackingPixelsExtension.formatMessageBody(message)
expect(message.body).toEqual(testBodyProcessed)

View file

@ -17,7 +17,6 @@
"6to5-core": "^3.5",
"async": "^0.9",
"atom-keymap": "^5.1",
"autolinker": "0.15.2",
"bluebird": "^2.9",
"clear-cut": "0.4.0",
"coffee-react": "^2.0.0",

View file

@ -2,7 +2,7 @@ React = require 'react/addons'
classNames = require 'classnames'
_ = require 'underscore'
{CompositeDisposable} = require 'event-kit'
{Utils, Contact, ContactStore} = require 'nylas-exports'
{Utils, RegExpUtils, Contact, ContactStore} = require 'nylas-exports'
RetinaImg = require './retina-img'
Token = React.createClass
@ -308,7 +308,7 @@ TokenizingTextField = React.createClass
# If it looks like an email, and the last character entered was a
# space, then let's add the input value.
if Utils.emailRegex().test(val) and _.last(val) is " "
if RegExpUtils.emailRegex().test(val) and _.last(val) is " "
@_addInputValue(val[0...-1], skipNameLookup: true)
else
@_refreshCompletions(val)

View file

@ -186,9 +186,8 @@ class Message extends Model
file.namespaceId = @namespaceId
return @
# We calculate the list of participants instead of grabbing it from
# a parent because it is a better source of ground truth, and saves us
# from more dependencies.
# Public: Returns a set of uniqued message participants by combining the
# `to`, `cc`, and `from` fields.
participants: ->
participants = {}
contacts = _.union((@to ? []), (@cc ? []), (@from ? []))
@ -197,13 +196,14 @@ class Message extends Model
participants["#{(contact?.email ? "").toLowerCase().trim()} #{(contact?.name ? "").toLowerCase().trim()}"] = contact if contact?
return _.values(participants)
# Returns a hash with `to` and `cc` keys for authoring a new draft in response
# to this message. Takes `replyTo` and other important state into account.
# Public: Returns a hash with `to` and `cc` keys for authoring a new draft in
# "reply all" to this message. This method takes into account whether the
# message is from the current user, and also looks at the replyTo field.
participantsForReplyAll: ->
to = []
cc = []
if @from[0].email is NamespaceStore.current().emailAddress
if @isFromMe()
to = @to
cc = @cc
else
@ -218,11 +218,14 @@ class Message extends Model
{to, cc}
# Public: Returns a hash with `to` and `cc` keys for authoring a new draft in
# "reply" to this message. This method takes into account whether the
# message is from the current user, and also looks at the replyTo field.
participantsForReply: ->
to = []
cc = []
if @from[0].email is NamespaceStore.current().emailAddress
if @isFromMe()
to = @to
else if @replyTo.length
to = @replyTo
@ -235,6 +238,14 @@ class Message extends Model
fileIds: ->
_.map @files, (file) -> file.id
# Public: Returns true if this message is from the current user's email
# address. In the future, this method will take into account all of the
# user's email addresses and namespaces.
isFromMe: ->
@from[0].email is NamespaceStore.current().emailAddress
# Public: Returns a plaintext version of the message body using Chromium's
# DOMParser. Use with care.
plainTextBody: ->
if (@body ? "").trim().length is 0 then return ""
(new DOMParser()).parseFromString(@body, "text/html").body.innerText
@ -242,6 +253,9 @@ class Message extends Model
fromContact: ->
@from?[0] ? new Contact(name: 'Unknown', email: 'Unknown')
# Public: Returns the standard attribution line for this message,
# localized for the current user.
# ie "On Dec. 12th, 2015 at 4:00PM, Ben Gotow wrote:"
replyAttributionLine: ->
"On #{@formattedDate()}, #{@fromContact().messageName()} wrote:"

View file

@ -226,15 +226,6 @@ Utils =
toMatch = domains[0]
return _.every(domains, (domain) -> domain.length > 0 and toMatch is domain)
# It's important that the regex be wrapped in parens, otherwise
# javascript's RegExp::exec method won't find anything even when the
# regex matches!
#
# It's also imporant we return a fresh copy of the RegExp every time. A
# javascript regex is stateful and multiple functions using this method
# will cause unexpected behavior!
emailRegex: -> new RegExp(/([a-z.A-Z0-9%+_-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4})/g)
emailHasCommonDomain: (email="") ->
domain = _.last(email.toLowerCase().trim().split("@"))
return (Utils.commonDomains[domain] ? false)

View file

@ -2,6 +2,7 @@ Reflux = require 'reflux'
Actions = require '../actions'
Contact = require '../models/contact'
Utils = require '../models/utils'
RegExpUtils = require '../../regexp-utils'
DatabaseStore = require './database-store'
NamespaceStore = require './namespace-store'
_ = require 'underscore'
@ -87,7 +88,7 @@ class ContactStore
parseContactsInString: (contactString, {skipNameLookup}={}) =>
detected = []
emailRegex = new RegExp(Utils.emailRegex())
emailRegex = RegExpUtils.emailRegex()
while (match = emailRegex.exec(contactString))
email = match[0]
name = null

View file

@ -123,7 +123,7 @@ class DraftStore
###
# Public: Returns the extensions registered with the DraftStore.
extensions: (ext) =>
extensions: =>
@_extensions
# Public: Registers a new extension with the DraftStore. DraftStore extensions

View file

@ -0,0 +1,35 @@
###
Public: MessageStoreExtension is an abstract base class. To create MessageStoreExtension
that customize message viewing, you should subclass {MessageStoreExtension} and
implement the class methods your plugin needs.
To register your extension with the MessageStore, call {MessageStore::registerExtension}.
When your package is being unloaded, you *must* call the corresponding
{MessageStore::unregisterExtension} to unhook your extension.
```coffee
activate: ->
MessageStore.registerExtension(MyExtension)
...
deactivate: ->
MessageStore.unregisterExtension(MyExtension)
```
The MessageStoreExtension API does not currently expose any asynchronous or {Promise}-based APIs.
This will likely change in the future. If you have a use-case for a Message Store extension that
is not possible with the current API, please let us know.
Section: Stores
###
class MessageStoreExtension
###
Public: Transform the message body HTML provided in `body` and return HTML
that should be displayed for the message.
###
@formatMessageBody: (body) ->
return body
module.exports = MessageStoreExtension

View file

@ -37,6 +37,32 @@ MessageStore = Reflux.createStore
itemsLoading: ->
@_itemsLoading
###
Message Store Extensions
###
# Public: Returns the extensions registered with the MessageStore.
extensions: =>
@_extensions
# Public: Registers a new extension with the MessageStore. MessageStore extensions
# make it possible to customize message body parsing, and will do more in
# the future.
#
# - `ext` A {MessageStoreExtension} instance.
#
registerExtension: (ext) =>
@_extensions ?= []
@_extensions.push(ext)
# Public: Unregisters the extension provided from the MessageStore.
#
# - `ext` A {MessageStoreExtension} instance.
#
unregisterExtension: (ext) =>
@_extensions = _.without(@_extensions, ext)
########### PRIVATE ####################################################
_setStoreDefaults: ->

15
src/regexp-utils.coffee Normal file
View file

@ -0,0 +1,15 @@
RegExpUtils =
# It's important that the regex be wrapped in parens, otherwise
# javascript's RegExp::exec method won't find anything even when the
# regex matches!
#
# It's also imporant we return a fresh copy of the RegExp every time. A
# javascript regex is stateful and multiple functions using this method
# will cause unexpected behavior!
emailRegex: -> new RegExp(/([a-z.A-Z0-9%+_-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4})/g)
# https://regex101.com/r/zG7aW4/3
imageTagRegex: -> /<img\s+[^>]*src="([^"]*)"[^>]*>/g
module.exports = RegExpUtils