feat(new-plugins): add open tracking and link tracking plugins

Summary:
Adds two (very similar) plugins - Open Tracking and Link Tracking.
Both can be enabled via a button in the composer. Open tracking
inserts a tracking pixel right before send, and link tracking replaces
all links with tracked redirects. Both plugins use the new Metadata
service to store their open/click counts, and have backend servers to
respectively serve the pixel image or handle the redirects. Requests
also trigger a metadata update to increment the open/click counters.

Test Plan: Manual for now

Reviewers: evan, bengotow, drew

Reviewed By: bengotow

Differential Revision: https://phab.nylas.com/D2583
This commit is contained in:
Drew Regitsky 2016-02-19 12:30:24 -08:00 committed by Juan Tejada
parent 8a42cefd40
commit fe7a894e51
24 changed files with 617 additions and 6 deletions

View file

@ -23,5 +23,5 @@
"default": true,
"composer": true
},
"license": "MIT"
"license": "GPL-3.0"
}

View file

@ -10,7 +10,7 @@
"description": "Extends the contact card in the sidebar to show public repos of the people you email.",
"icon": "./icon.png",
"license": "MIT",
"license": "GPL-3.0",
"engines": {
"nylas": ">=0.3.0 <0.5.0"
},

View file

@ -0,0 +1,4 @@
## Open Tracking
Adds tracking pixels to messages and tracks whether they have been opened.

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 B

View file

@ -0,0 +1,59 @@
import {DraftStore, React, Actions, NylasAPI, DatabaseStore, Message, Rx} from 'nylas-exports'
import {RetinaImg} from 'nylas-component-kit'
import plugin from '../package.json'
const PLUGIN_ID = plugin.appId;
export default class LinkTrackingButton extends React.Component {
static displayName = 'LinkTrackingButton';
static propTypes = {
draftClientId: React.PropTypes.string.isRequired,
};
constructor(props) {
super(props);
this.state = {enabled: false};
}
componentDidMount() {
const query = DatabaseStore.findBy(Message, {clientId: this.props.draftClientId});
this._subscription = Rx.Observable.fromQuery(query).subscribe(this.setStateFromDraft)
}
componentWillUnmount() {
this._subscription.dispose();
}
setStateFromDraft =(draft)=> {
if (!draft) return;
const metadata = draft.metadataForPluginId(PLUGIN_ID);
this.setState({enabled: metadata ? metadata.tracked : false});
};
_onClick=()=> {
const currentlyEnabled = this.state.enabled;
// write metadata into the draft to indicate tracked state
DraftStore.sessionForClientId(this.props.draftClientId)
.then(session => session.draft())
.then(draft => {
return NylasAPI.authPlugin(PLUGIN_ID, plugin.title, draft.accountId).then(() => {
Actions.setMetadata(draft, PLUGIN_ID, currentlyEnabled ? null : {tracked: true});
});
});
};
render() {
return (
<button
title="Link Tracking"
className={`btn btn-toolbar ${this.state.enabled ? "btn-action" : ""}`}
onClick={this._onClick}>
<RetinaImg
url="nylas://link-tracking/assets/linktracking-icon@2x.png"
mode={RetinaImg.Mode.ContentIsMask} />
</button>
)
}
}

View file

@ -0,0 +1,46 @@
import {ComposerExtension, Actions, QuotedHTMLTransformer} from 'nylas-exports';
import plugin from '../package.json'
import uuid from 'node-uuid';
const LINK_REGEX = (/(<a\s.*?href\s*?=\s*?")([^"]*)("[^>]*>)|(<a\s.*?href\s*?=\s*?')([^']*)('[^>]*>)/g);
const PLUGIN_ID = plugin.appId;
const PLUGIN_URL = "n1-link-tracking.herokuapp.com";
class DraftBody {
constructor(draft) {this._body = draft.body}
get unquoted() {return QuotedHTMLTransformer.removeQuotedHTML(this._body);}
set unquoted(text) {this._body = QuotedHTMLTransformer.appendQuotedHTML(text, this._body);}
get body() {return this._body}
}
export default class LinkTrackingComposerExtension extends ComposerExtension {
static finalizeSessionBeforeSending({session}) {
const draft = session.draft();
// grab message metadata, if any
const metadata = draft.metadataForPluginId(PLUGIN_ID);
if (metadata) {
const draftBody = new DraftBody(draft);
const links = [];
const messageUid = uuid.v4().replace(/-/g, "");
// loop through all <a href> elements, replace with redirect links and save mappings
draftBody.unquoted = draftBody.unquoted.replace(LINK_REGEX, (match, prefix, url, suffix) => {
const encoded = encodeURIComponent(url);
const redirectUrl = `http://${PLUGIN_URL}/${draft.accountId}/${messageUid}/${links.length}?redirect=${encoded}`;
links.push({url: url, click_count: 0, click_data: []});
return prefix + redirectUrl + suffix;
});
// save the draft
session.changes.add({body: draftBody.body});
session.changes.commit();
// save the link info to draft metadata
metadata.uid = messageUid;
metadata.links = links;
Actions.setMetadata(draft, PLUGIN_ID, metadata);
}
}
}

View file

@ -0,0 +1,58 @@
import {React} from 'nylas-exports'
import {RetinaImg} from 'nylas-component-kit'
import plugin from '../package.json'
const sum = (array, extractFn) => array.reduce( (a, b) => a + extractFn(b), 0 );
export default class LinkTrackingIcon extends React.Component {
static displayName = 'LinkTrackingIcon';
static propTypes = {
thread: React.PropTypes.object.isRequired,
};
constructor(props) {
super(props);
this.state = this._getStateFromThread(props.thread);
}
componentWillReceiveProps(newProps) {
this.setState(this._getStateFromThread(newProps.thread));
}
_getStateFromThread(thread) {
const messages = thread.metadata;
// Pull a list of metadata for all messages
const metadataObjs = messages.map(msg => msg.metadataForPluginId(plugin.appId)).filter(meta => meta);
if (metadataObjs.length) {
// If there's metadata, return the total number of link clicks in the most recent metadata
const mostRecentMetadata = metadataObjs.pop();
return {
clicks: sum(mostRecentMetadata.links || [], link => link.click_count || 0),
};
}
return {clicks: null};
}
_renderIcon = () => {
return this.state.clicks == null ? "" : this._getIcon(this.state.clicks);
};
_getIcon(clicks) {
return (<span>
<RetinaImg
className={clicks > 0 ? "clicked" : ""}
url="nylas://link-tracking/assets/linktracking-icon@2x.png"
mode={RetinaImg.Mode.ContentIsMask} />
<span className="link-click-count">{clicks > 0 ? clicks : ""}</span>
</span>)
}
render() {
return (<div className="link-tracking-icon">
{this._renderIcon()}
</div>)
}
}

View file

@ -0,0 +1,47 @@
import {React} from 'nylas-exports'
import plugin from '../package.json'
export default class LinkTrackingPanel extends React.Component {
static displayName = 'LinkTrackingPanel';
static propTypes = {
message: React.PropTypes.object.isRequired,
};
constructor(props) {
super(props);
this.state = this._getStateFromMessage(props.message)
}
componentWillReceiveProps(newProps) {
this.setState(this._getStateFromMessage(newProps.message));
}
_getStateFromMessage(message) {
const metadata = message.metadataForPluginId(plugin.appId);
return metadata ? {links: metadata.links} : {};
}
_renderContents() {
return this.state.links.map(link => {
return (<tr className="link-info">
<td className="link-url">{link.url}</td>
<td className="link-count">{link.click_count + " clicks"}</td>
</tr>)
})
}
render() {
if (this.state.links) {
return (<div className="link-tracking-panel">
<h4>Link Tracking Enabled</h4>
<table>
<tbody>
{this._renderContents()}
</tbody>
</table>
</div>);
}
return <div></div>;
}
}

View file

@ -0,0 +1,61 @@
import {ComponentRegistry, DatabaseStore, Message, ExtensionRegistry, Actions} from 'nylas-exports';
import LinkTrackingButton from './link-tracking-button';
import LinkTrackingIcon from './link-tracking-icon';
import LinkTrackingComposerExtension from './link-tracking-composer-extension';
import LinkTrackingPanel from './link-tracking-panel';
import plugin from '../package.json'
import request from 'request';
const post = Promise.promisify(request.post, {multiArgs: true});
const PLUGIN_ID = plugin.appId;
const PLUGIN_URL = "n1-link-tracking.herokuapp.com";
function afterDraftSend({draftClientId}) {
// only run this handler in the main window
if (!NylasEnv.isMainWindow()) return;
// query for the message
DatabaseStore.findBy(Message, {clientId: draftClientId}).then((message) => {
// grab message metadata, if any
const metadata = message.metadataForPluginId(PLUGIN_ID);
// get the uid from the metadata, if present
if (metadata) {
const uid = metadata.uid;
// post the uid and message id pair to the plugin server
const data = {uid: uid, message_id: message.id};
const serverUrl = `http://${PLUGIN_URL}/register-message`;
return post({
url: serverUrl,
body: JSON.stringify(data),
}).then( ([response, responseBody]) => {
if (response.statusCode !== 200) {
throw new Error();
}
return responseBody;
}).catch(error => {
NylasEnv.showErrorDialog("There was a problem contacting the Link Tracking server! This message will not have link tracking");
Promise.reject(error);
});
}
});
}
export function activate() {
ComponentRegistry.register(LinkTrackingButton, {role: 'Composer:ActionButton'});
ComponentRegistry.register(LinkTrackingIcon, {role: 'ThreadListIcon'});
ComponentRegistry.register(LinkTrackingPanel, {role: 'message:BodyHeader'});
ExtensionRegistry.Composer.register(LinkTrackingComposerExtension);
this._unlistenSendDraftSuccess = Actions.sendDraftSuccess.listen(afterDraftSend);
}
export function serialize() {}
export function deactivate() {
ComponentRegistry.unregister(LinkTrackingButton);
ComponentRegistry.unregister(LinkTrackingIcon);
ComponentRegistry.unregister(LinkTrackingPanel);
ExtensionRegistry.Composer.unregister(LinkTrackingComposerExtension);
this._unlistenSendDraftSuccess()
}

View file

@ -0,0 +1,25 @@
{
"name": "link-tracking",
"main": "./lib/main",
"version": "0.1.0",
"appId":"ybqmsi39th16ka60dcxz1in9",
"title": "Link Tracking",
"description": "Tracks whether links in an email have been clicked by recipients",
"icon": "./icon.png",
"isOptional": true,
"repository": {
"type": "git",
"url": ""
},
"engines": {
"nylas": ">=0.3.46"
},
"windowTypes": {
"default": true,
"composer": true
},
"dependencies": {},
"license": "GPL-3.0"
}

View file

@ -0,0 +1,47 @@
@import "ui-variables";
@import "ui-mixins";
.link-tracking-icon img.content-mask {
background-color: #AAA;
vertical-align: text-bottom;
}
.link-tracking-icon img.content-mask.clicked {
background-color: #CCC;
}
.link-tracking-icon .link-click-count {
display: inline-block;
position: relative;
left: -16px;
text-align: center;
color: #3187e1;
font-size: 12px;
font-weight: bold;
}
.link-tracking-icon {
width: 16px;
margin-right: 4px;
}
.link-tracking-panel {
background: #DDF6FF;
border: 1px solid #ACD;
padding: 5px;
border-radius: 5px;
}
.link-tracking-panel h4{
text-align: center;
margin-top: 0;
}
.link-tracking-panel table{
width: 100%;
}
.link-tracking-panel td {
border-bottom: 1px solid #D5EAF5;
border-top: 1px solid #D5EAF5;
padding: 0 10px;
text-align: left;
}

View file

@ -0,0 +1,4 @@
## Open Tracking
Adds tracking pixels to messages and tracks whether they have been opened.

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,62 @@
import {ComponentRegistry, ExtensionRegistry, DatabaseStore, Message, Actions} from 'nylas-exports';
import OpenTrackingButton from './open-tracking-button';
import OpenTrackingIcon from './open-tracking-icon';
import OpenTrackingComposerExtension from './open-tracking-composer-extension';
import plugin from '../package.json'
import request from 'request';
const post = Promise.promisify(request.post, {multiArgs: true});
const PLUGIN_ID = plugin.appId;
const PLUGIN_URL = "n1-open-tracking.herokuapp.com";
function afterDraftSend({draftClientId}) {
// only run this handler in the main window
if (!NylasEnv.isMainWindow()) return;
// query for the message
DatabaseStore.findBy(Message, {clientId: draftClientId}).then((message) => {
// grab message metadata, if any
const metadata = message.metadataForPluginId(PLUGIN_ID);
// get the uid from the metadata, if present
if (metadata) {
const uid = metadata.uid;
// set metadata against the message
Actions.setMetadata(message, PLUGIN_ID, {open_count: 0, open_data: []});
// post the uid and message id pair to the plugin server
const data = {uid: uid, message_id: message.id, thread_id: 1};
const serverUrl = `http://${PLUGIN_URL}/register-message`;
return post({
url: serverUrl,
body: JSON.stringify(data),
}).then(([response, responseBody]) => {
if (response.statusCode !== 200) {
throw new Error();
}
return responseBody;
}).catch(error => {
NylasEnv.showErrorDialog("There was a problem contacting the Open Tracking server! This message will not have open tracking :(");
Promise.reject(error);
});
}
});
}
export function activate() {
ComponentRegistry.register(OpenTrackingButton, {role: 'Composer:ActionButton'});
ComponentRegistry.register(OpenTrackingIcon, {role: 'ThreadListIcon'});
ExtensionRegistry.Composer.register(OpenTrackingComposerExtension);
this._unlistenSendDraftSuccess = Actions.sendDraftSuccess.listen(afterDraftSend);
}
export function serialize() {}
export function deactivate() {
ComponentRegistry.unregister(OpenTrackingButton);
ComponentRegistry.unregister(OpenTrackingIcon);
ExtensionRegistry.Composer.unregister(OpenTrackingComposerExtension);
this._unlistenSendDraftSuccess()
}

View file

@ -0,0 +1,55 @@
import {DraftStore, React, Actions, NylasAPI, DatabaseStore, Message, Rx} from 'nylas-exports'
import {RetinaImg} from 'nylas-component-kit'
import plugin from '../package.json'
const PLUGIN_ID = plugin.appId;
export default class OpenTrackingButton extends React.Component {
static displayName = 'OpenTrackingButton';
static propTypes = {
draftClientId: React.PropTypes.string.isRequired,
};
constructor(props) {
super(props);
this.state = {enabled: false};
}
componentDidMount() {
const query = DatabaseStore.findBy(Message, {clientId: this.props.draftClientId});
this._subscription = Rx.Observable.fromQuery(query).subscribe(this.setStateFromDraft)
}
componentWillUnmount() {
this._subscription.dispose();
}
setStateFromDraft = (draft)=> {
if (!draft) return;
const metadata = draft.metadataForPluginId(PLUGIN_ID);
this.setState({enabled: metadata ? metadata.tracked : false});
};
_onClick=()=> {
const currentlyEnabled = this.state.enabled;
// write metadata into the draft to indicate tracked state
DraftStore.sessionForClientId(this.props.draftClientId)
.then(session => session.draft())
.then(draft => {
return NylasAPI.authPlugin(PLUGIN_ID, plugin.title, draft.accountId).then(() => {
Actions.setMetadata(draft, PLUGIN_ID, currentlyEnabled ? null : {tracked: true});
});
});
};
render() {
return (<button className={`btn btn-toolbar ${this.state.enabled ? "btn-action" : ""}`}
onClick={this._onClick} title="Open Tracking">
<RetinaImg url="nylas://open-tracking/assets/envelope-open-icon@2x.png"
mode={RetinaImg.Mode.ContentIsMask} />
</button>)
}
}

View file

@ -0,0 +1,41 @@
import {ComposerExtension, Actions, QuotedHTMLTransformer} from 'nylas-exports';
import plugin from '../package.json'
import uuid from 'node-uuid';
const PLUGIN_ID = plugin.appId;
const PLUGIN_URL = "n1-open-tracking.herokuapp.com";
class DraftBody {
constructor(draft) {this._body = draft.body}
get unquoted() {return QuotedHTMLTransformer.removeQuotedHTML(this._body);}
set unquoted(text) {this._body = QuotedHTMLTransformer.appendQuotedHTML(text, this._body);}
get body() {return this._body}
}
export default class OpenTrackingComposerExtension extends ComposerExtension {
static finalizeSessionBeforeSending({session}) {
const draft = session.draft();
// grab message metadata, if any
const metadata = draft.metadataForPluginId(PLUGIN_ID);
if (metadata) {
// generate a UID
const uid = uuid.v4().replace(/-/g, "");
// insert a tracking pixel <img> into the message
const serverUrl = `http://${PLUGIN_URL}/${draft.accountId}/${uid}`;
const img = `<img width="0" height="0" style="border:0; width:0; height:0;" src="${serverUrl}">`;
const draftBody = new DraftBody(draft);
draftBody.unquoted = draftBody.unquoted + "<br>" + img;
// save the draft
session.changes.add({body: draftBody.body});
session.changes.commit();
// save the uid to draft metadata
metadata.uid = uid;
Actions.setMetadata(draft, PLUGIN_ID, metadata);
}
}
}

View file

@ -0,0 +1,52 @@
import {React} from 'nylas-exports'
import {RetinaImg} from 'nylas-component-kit'
import plugin from '../package.json'
export default class OpenTrackingIcon extends React.Component {
static displayName = 'OpenTrackingIcon';
static propTypes = {
thread: React.PropTypes.object.isRequired,
};
constructor(props) {
super(props);
this.state = this._getStateFromThread(props.thread)
}
componentWillReceiveProps(newProps) {
this.setState(this._getStateFromThread(newProps.thread));
}
_getStateFromThread(thread) {
const messages = thread.metadata;
const metadataObjs = messages.map(msg => msg.metadataForPluginId(plugin.appId)).filter(meta => meta);
return {opened: metadataObjs.length ? metadataObjs.every(m => m.open_count > 0) : null};
}
_renderIcon = () => {
if (this.state.opened == null) {
return <span />;
} else if (this.state.opened) {
return (
<RetinaImg
url="nylas://open-tracking/assets/envelope-open-icon@2x.png"
mode={RetinaImg.Mode.ContentIsMask} />
);
}
return (
<RetinaImg
className="unopened"
url="nylas://open-tracking/assets/envelope-closed-icon@2x.png"
mode={RetinaImg.Mode.ContentIsMask} />
);
};
render() {
return (
<div className="open-tracking-icon">
{this._renderIcon()}
</div>
);
}
}

View file

@ -0,0 +1,25 @@
{
"name": "open-tracking",
"main": "./lib/main",
"version": "0.1.0",
"appId":"9tm0s8yzzkaahi5nql34iw8wu",
"title": "Open Tracking",
"description": "Tracks whether email messages have been opened by recipients",
"icon": "./icon.png",
"isOptional": true,
"repository": {
"type": "git",
"url": ""
},
"engines": {
"nylas": ">=0.3.46"
},
"windowTypes": {
"default": true,
"composer": true
},
"dependencies": {},
"license": "GPL-3.0"
}

View file

@ -0,0 +1,24 @@
@import "ui-variables";
@import "ui-mixins";
.open-tracking-icon img.content-mask {
background-color: #AAA;
vertical-align: text-bottom;
}
.open-tracking-icon img.content-mask.unopened {
background-color: #C00;
}
.open-tracking-icon .open-count {
display: inline-block;
position: relative;
left: -16px;
text-align: center;
color: #3187e1;
font-size: 12px;
font-weight: bold;
}
.open-tracking-icon {
width: 16px;
margin-right: 4px;
}

View file

@ -15,6 +15,6 @@
"engines": {
"nylas": ">=0.3.0 <0.5.0"
},
"dependencies": [],
"license": "MIT"
"dependencies": {},
"license": "GPL-3.0"
}

View file

@ -23,5 +23,5 @@
"default": true,
"composer": true
},
"license": "MIT"
"license": "GPL-3.0"
}

View file

@ -75,6 +75,7 @@ class Package
@metadata ?= Package.loadMetadata(@path)
@bundledPackage = Package.isBundledPackagePath(@path)
@name = @metadata?.name ? path.basename(@path)
@pluginAppId = @metadata.appId ? null
@displayName = @metadata?.displayName || @name
ModuleCache.add(@path, @metadata)
@reset()
@ -82,7 +83,7 @@ class Package
# TODO FIXME: Use a unique pluginID instead of just the "name"
# This needs to be included here to prevent a circular dependency error
pluginId: -> return @name
pluginId: -> return @pluginAppId ? @name
###
Section: Event Subscription