Merge remote-tracking branch 'k2/master'

This commit is contained in:
Evan Morikawa 2017-02-16 13:20:20 -08:00
commit 84e2c75ce9
486 changed files with 32695 additions and 0 deletions

8
.arcconfig Normal file
View file

@ -0,0 +1,8 @@
{
"project_id" : "N1",
"conduit_uri" : "https://phab.nylas.com/",
"load" : [
"arclib"
],
"lint.engine": "ArcanistConfigurationDrivenLintEngine"
}

17
.arclint Normal file
View file

@ -0,0 +1,17 @@
{
"linters": {
"coffeescript-linter": {
"type": "script-and-regex",
"script-and-regex.script": "script/grunt coffeelint:target --target",
"script-and-regex.regex": "/^ ((?P<error>✖)|(?P<warning>⚠)) *line (?P<line>\\d+) +(?P<message>.*)$/m",
"include": "{\\.(e?coffee|cjsx)}"
},
"eslint-regex-based": {
"type": "script-and-regex",
"include": ["(\\.jsx?$)", "(\\.es6$)"],
"exclude": ["(src\\/K2)", "(node_modules)"],
"script-and-regex.script": "sh -c '([ -e ./node_modules/.bin/eslint ]) && (./node_modules/.bin/eslint -f compact \"$0\" || true)'",
"script-and-regex.regex": "/^(?P<file>.*): line (?P<line>[0-9]*), col (?P<char>[0-9]*), (?P<warning>Warning|Error) - (?P<message>.*?)(\\((?P<code>[a-z-]+)\\))?$/m"
}
}
}

10
.babelrc Normal file
View file

@ -0,0 +1,10 @@
{
"presets": [
"electron",
"react"
],
"plugins": [
"transform-async-generator-functions"
],
"sourceMaps": "inline"
}

27
.dockerignore Normal file
View file

@ -0,0 +1,27 @@
n1_cloud_dist
.git
.arcconfig
.arclint
arclib
*.swp
*~
.DS_Store
node_modules
**/node_modules
dump.rdb
*npm-debug.log
storage/
lerna-debug.log
newrelic_agent.log
# Vim temp files
*.swp
*.swo
# Elastic Beanstalk Files
.elasticbeanstalk/*
!.elasticbeanstalk/*.cfg.yml
!.elasticbeanstalk/*.global.yml
/packages/local-sync/spec-saved-state.json

View file

@ -0,0 +1,9 @@
# This lets you log in via `eb ssh` and access the docker daemon.
# If we don't add the ec2-user to the docker group, then calls to docker
# (like `docker ps`) will fail with `Cannot connect to the Docker daemon`
#
# See: https://blog.cloudinvaders.com/connect-to-docker-daemon-on-aws-beanstalk-ec2-instance/
commands:
0_add_docker_group_to_ec2_user:
command: gpasswd -a ec2-user docker
test: groups ec2-user | grep -qv docker

74
.eslintrc Normal file
View file

@ -0,0 +1,74 @@
{
"parser": "babel-eslint",
"extends": "airbnb",
"globals": {
"NylasEnv": false,
"$n": false,
"waitsForPromise": false,
"advanceClock": false,
"TEST_ACCOUNT_ID": false,
"TEST_ACCOUNT_NAME": false,
"TEST_ACCOUNT_EMAIL": false,
"TEST_ACCOUNT_ALIAS_EMAIL": false
},
"env": {
"browser": true,
"node": true,
"jasmine": true
},
"rules": {
"arrow-body-style": "off",
"arrow-parens": "off",
"class-methods-use-this": "off",
"prefer-arrow-callback": ["error", {"allowNamedFunctions": true}],
"eqeqeq": ["error", "smart"],
"id-length": "off",
"object-curly-spacing": "off",
"max-len": "off",
"new-cap": ["error", {"capIsNew": false}],
"newline-per-chained-call": "off",
"no-bitwise": "off",
"no-lonely-if": "off",
"no-console": "off",
"no-continue": "off",
"no-constant-condition": "off",
"no-loop-func": "off",
"no-plusplus": "off",
"no-shadow": "error",
"no-underscore-dangle": "off",
"object-shorthand": "off",
"quotes": "off",
"quote-props": ["error", "consistent-as-needed", { "keywords": true }],
"no-param-reassign": ["error", { "props": false }],
"semi": "off",
"no-mixed-operators": "off",
"import/extensions": ["error", "never", { "json": "always" }],
"import/no-unresolved": ["error", {"ignore": ["nylas-exports", "nylas-component-kit", "electron", "nylas-store", "react-dom/server", "nylas-observables", "windows-shortcuts", "moment-round", "better-sqlite3", "chrono-node", "event-kit", "enzyme"]}],
"import/no-extraneous-dependencies": "off",
"import/newline-after-import": "off",
"import/prefer-default-export": "off",
"react/no-multi-comp": "off",
"react/no-find-dom-node": "off",
"react/no-string-refs": "off",
"react/no-unused-prop-types": "off",
"react/forbid-prop-types": "off",
"jsx-a11y/no-static-element-interactions": "off",
"react/prop-types": ["error", {"ignore": ["children"]}],
"react/sort-comp": "error",
"no-restricted-syntax": [
"error", "ForInStatement", "LabeledStatement", "WithStatement"
],
"comma-dangle": ["error", {
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "ignore"
}],
"no-useless-return": "off"
},
"settings": {
"import/core-modules": [ "nylas-exports", "nylas-component-kit", "electron", "nylas-store", "nylas-observables" ],
"import/resolver": {"node": {"extensions": [".es6", ".jsx", ".coffee", ".json", ".cjsx", ".js"]}}
}
}

21
.gitignore vendored
View file

@ -44,3 +44,24 @@ internal_packages/thread-sharing
internal_packages/local-sync internal_packages/local-sync
/dist /dist
/dump.rdb /dump.rdb
n1_cloud_dist
*.swp
*~
.DS_Store
node_modules
dump.rdb
*npm-debug.log
storage/
lerna-debug.log
newrelic_agent.log
# Vim temp files
*.swp
*.swo
# Elastic Beanstalk Files
.elasticbeanstalk/*
!.elasticbeanstalk/*.cfg.yml
!.elasticbeanstalk/*.global.yml
/packages/local-sync/spec-saved-state.json

5
.tern-project Normal file
View file

@ -0,0 +1,5 @@
{
"plugins": {
"node": {}
}
}

28
Dockerfile Normal file
View file

@ -0,0 +1,28 @@
# This Dockerfile builds a production-ready image of K2 to be used across all
# services. See the Dockerfile documentation here:
# https://docs.docker.com/engine/reference/builder/
# Use the latest Node 6 base docker image
# https://github.com/nodejs/docker-node
FROM node:6
# Copy everything (excluding what's in .dockerignore) into an empty dir
COPY . /home
WORKDIR /home
RUN npm install --production
# This will do an `npm install` for each of our modules and then link them all
# together. See more about Lerna here: https://github.com/lerna/lerna We have
# to run this separately from npm postinstall due to permission issues.
RUN node_modules/.bin/lerna bootstrap
# This uses babel to compile any es6 to stock js for plain node
RUN npm run build-n1-cloud
# External services run on port 80. Expose it.
EXPOSE 5100
# We use a start-aws command that automatically spawns the correct process
# based on environment variables (which changes instance to instance)
CMD ./node_modules/pm2/bin/pm2 start --no-daemon ./pm2-prod-${AWS_SERVICE_NAME}.yml

30
README.md Normal file
View file

@ -0,0 +1,30 @@
# K2 - Local Sync Engine & Cloud Services for Nylas Mail
This is a collection of all sync and cloud components required to run N1.
1. [**Cloud API**](https://github.com/nylas/K2/tree/master/packages/cloud-api): The cloud-based auth and metadata APIs for N1
1. [**Cloud Core**](https://github.com/nylas/K2/tree/master/packages/cloud-core): Shared code used in all remote cloud services
1. [**Cloud Workers**](https://github.com/nylas/K2/tree/master/packages/cloud-workers): Cloud workers for services like send later
1. [**Isomorphic Core**](https://github.com/nylas/K2/tree/master/packages/isomorphic-core): Shared code across local client and cloud servers
1. [**Local Sync**](https://github.com/nylas/K2/tree/master/packages/local-sync): The local mailsync engine integreated in Nylas Mail
See `/packages` for the separate pieces. Each folder in `/packages` is
designed to be its own stand-alone repository. They are all bundled here
for the ease of source control management.
# Initial Setup for All Local & Cloud Services:
## New Computer (Mac):
1. Install [Homebrew](http://brew.sh/)
1. Install [NVM](https://github.com/creationix/nvm) `brew install nvm`
1. Install Node 6 via NVM: `nvm install 6`
1. Install Redis locally `brew install redis`
## New Computer (Linux - Debian/Ubuntu):
1. Install Node 6+ via NodeSource (trusted):
1. `curl -sL https://deb.nodesource.com/setup_6.x | sudo -E bash -`
1. `sudo apt-get install -y nodejs`
1. Install Redis locally `sudo apt-get install -y redis-server redis-tools`
benefit of letting us use subdomains.

View file

@ -0,0 +1 @@
{"__symbol_cache_version__":11}

View file

@ -0,0 +1,3 @@
<?php
phutil_register_library('customlib', __FILE__);

View file

@ -0,0 +1,14 @@
<?php
/**
* This file is automatically generated. Use 'arc liberate' to rebuild it.
*
* @generated
* @phutil-library-version 2
*/
phutil_register_library_map(array(
'__library_version__' => 2,
'class' => array(),
'function' => array(),
'xmap' => array(),
));

4
lerna.json Normal file
View file

@ -0,0 +1,4 @@
{
"lerna": "2.0.0-beta.30",
"version": "0.0.1"
}

View file

@ -0,0 +1,11 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
const {sequelize} = queryInterface;
console.log("querying db");
await sequelize.query("ALTER TABLE metadata ADD COLUMN `expiration` DATETIME");
},
down: async (queryInterface, Sequelize) => {
const {sequelize} = queryInterface;
await sequelize.query("ALTER TABLE metadata DROP COLUMN `expiration`");
},
}

53
package.json Normal file
View file

@ -0,0 +1,53 @@
{
"name": "k2",
"version": "0.0.2",
"description": "The local sync engine for Nylas Mail",
"main": "",
"dependencies": {
"babel-cli": "6.22.2",
"babel-core": "6.20.0",
"babel-plugin-transform-async-generator-functions": "6.17.0",
"babel-preset-electron": "^0.37.8",
"babel-preset-react": "6.16.0",
"fs-extra": "1.0.0",
"glob": "7.1.1",
"lerna": "2.0.0-beta.30",
"pm2": "^2.4.0",
"sequelize": "^3.30.1",
"sequelize-cli": "^2.5.1",
"umzug": "1.11.0"
},
"devDependencies": {
"babel-eslint": "7.1.0",
"eslint": "3.10.2",
"eslint_d": "4.2.0",
"eslint-config-airbnb": "13.0.0",
"eslint-plugin-import": "2.2.0",
"eslint-plugin-jsx-a11y": "2.2.3",
"eslint-plugin-react": "6.7.1"
},
"scripts": {
"start": "pm2 stop all; pm2 delete all; pm2 start ./pm2-dev.yml --no-daemon",
"debug-cloud-api": "pm2 stop all; pm2 delete all; pm2 start ./pm2-debug-cloud-api.yml --no-daemon",
"stop": "pm2 stop all; pm2 delete all",
"build-n1-cloud": "node scripts/build-n1-cloud.js",
"restart": "pm2 restart all",
"postinstall": "lerna bootstrap",
"upgrade-db": "babel-node scripts/migrate-db.es6 up",
"downgrade-db": "babel-node scripts/migrate-db.es6 down"
},
"repository": {
"type": "git",
"url": "git+https://github.com/nylas/K2.git"
},
"author": "Nylas",
"license": "proprietary",
"bugs": {
"url": "https://github.com/nylas/K2/issues"
},
"homepage": "https://github.com/nylas/K2#readme",
"engines": {
"node": "6.9.1",
"npm": "3.10.8"
}
}

View file

@ -0,0 +1,19 @@
/* eslint global-require: 0 */
module.exports = {
Provider: {
Gmail: 'gmail',
IMAP: 'imap',
},
Imap: require('imap'),
Errors: require('./src/errors'),
IMAPErrors: require('./src/imap-errors'),
loadModels: require('./src/load-models'),
AuthHelpers: require('./src/auth-helpers'),
PromiseUtils: require('./src/promise-utils'),
DatabaseTypes: require('./src/database-types'),
IMAPConnection: require('./src/imap-connection'),
SendmailClient: require('./src/sendmail-client'),
DeltaStreamBuilder: require('./src/delta-stream-builder'),
HookTransactionLog: require('./src/hook-transaction-log'),
HookIncrementVersionOnSave: require('./src/hook-increment-version-on-save'),
}

View file

@ -0,0 +1,20 @@
{
"name": "isomorphic-core",
"version": "0.0.1",
"description": "Packages use isomorphically on n1-cloud and local-sync",
"main": "index.js",
"dependencies": {
"imap": "github:jstejada/node-imap#fix-parse-body-list",
"imap-provider-settings": "github:nylas/imap-provider-settings",
"joi": "8.4.2",
"nodemailer": "2.5.0",
"promise-props": "1.0.0",
"promise.prototype.finally": "1.0.1",
"rx": "4.1.0",
"sequelize": "3.28.0",
"underscore": "1.8.3",
"xoauth2": "1.2.0"
},
"author": "Nylas",
"license": "ISC"
}

View file

@ -0,0 +1,183 @@
const _ = require('underscore')
const Joi = require('joi');
const IMAPErrors = require('./imap-errors')
const IMAPConnection = require('./imap-connection')
const imapSmtpSettings = Joi.object().keys({
imap_host: [Joi.string().ip().required(), Joi.string().hostname().required()],
imap_port: Joi.number().integer().required(),
imap_username: Joi.string().required(),
imap_password: Joi.string().required(),
smtp_host: [Joi.string().ip().required(), Joi.string().hostname().required()],
smtp_port: Joi.number().integer().required(),
smtp_username: Joi.string().required(),
smtp_password: Joi.string().required(),
smtp_custom_config: Joi.object(),
ssl_required: Joi.boolean().required(),
}).required();
const resolvedGmailSettings = Joi.object().keys({
xoauth2: Joi.string().required(),
expiry_date: Joi.number().integer().required(),
}).required();
const office365Settings = Joi.object().keys({
name: Joi.string().required(),
type: Joi.string().valid('office365').required(),
email: Joi.string().required(),
password: Joi.string().required(),
username: Joi.string().required(),
}).required();
const USER_ERRORS = {
AUTH_500: "Please contact support@nylas.com. An unforeseen error has occurred.",
IMAP_AUTH: "Incorrect username or password",
IMAP_RETRY: "We were unable to reach your mail provider. Please try again.",
IMAP_CERT: "We couldn't make a secure connection to your mail provider. Please contact support@nylas.com.",
}
const SUPPORTED_PROVIDERS = new Set(
['gmail', 'office365', 'imap', 'icloud', 'yahoo', 'fastmail']
);
function credentialsForProvider({provider, settings, email}) {
if (provider === "gmail") {
const connectionSettings = {
imap_username: email,
imap_host: 'imap.gmail.com',
imap_port: 993,
smtp_username: email,
smtp_host: 'smtp.gmail.com',
smtp_port: 465,
ssl_required: true,
}
const connectionCredentials = {
xoauth2: settings.xoauth2,
expiry_date: settings.expiry_date,
}
return {connectionSettings, connectionCredentials}
} else if (provider === "office365") {
const connectionSettings = {
imap_host: 'outlook.office365.com',
imap_port: 993,
ssl_required: true,
smtp_custom_config: {
host: 'smtp.office365.com',
port: 587,
secure: false,
tls: {ciphers: 'SSLv3'},
},
}
const connectionCredentials = {
imap_username: email,
imap_password: settings.password,
smtp_username: email,
smtp_password: settings.password,
}
return {connectionSettings, connectionCredentials}
} else if (SUPPORTED_PROVIDERS.has(provider)) {
const connectionSettings = _.pick(settings, [
'imap_host', 'imap_port',
'smtp_host', 'smtp_port',
'ssl_required', 'smtp_custom_config',
]);
const connectionCredentials = _.pick(settings, [
'imap_username', 'imap_password',
'smtp_username', 'smtp_password',
]);
return {connectionSettings, connectionCredentials}
}
throw new Error(`Invalid provider: ${provider}`)
}
module.exports = {
SUPPORTED_PROVIDERS,
imapAuthRouteConfig() {
return {
description: 'Authenticates a new account.',
tags: ['accounts'],
auth: false,
validate: {
payload: {
email: Joi.string().email().required(),
name: Joi.string().required(),
provider: Joi.string().valid(...SUPPORTED_PROVIDERS).required(),
settings: Joi.alternatives().try(imapSmtpSettings, office365Settings, resolvedGmailSettings),
},
},
}
},
imapAuthHandler(upsertAccount) {
const MAX_RETRIES = 2
const authHandler = (request, reply, retryNum = 0) => {
const dbStub = {};
const connectionChecks = [];
const {email, provider, name} = request.payload;
const {connectionSettings, connectionCredentials} = credentialsForProvider(request.payload)
connectionChecks.push(IMAPConnection.connect({
settings: Object.assign({}, connectionSettings, connectionCredentials),
logger: request.logger,
db: dbStub,
}));
Promise.all(connectionChecks).then((conns) => {
for (const conn of conns) {
if (conn) { conn.end(); }
}
const accountParams = {
name: name,
provider: provider,
emailAddress: email,
connectionSettings: connectionSettings,
}
return upsertAccount(accountParams, connectionCredentials)
})
.then(({account, token}) => {
const response = account.toJSON();
response.account_token = token.value;
reply(JSON.stringify(response));
return
})
.catch((err) => {
const logger = request.logger.child({
account_name: name,
account_provider: provider,
account_email: email,
connection_settings: connectionSettings,
error: err,
error_message: err.message,
})
if (err instanceof IMAPErrors.IMAPAuthenticationError) {
logger.error({err}, 'Encountered authentication error while attempting to authenticate')
reply({message: USER_ERRORS.IMAP_AUTH, type: "api_error"}).code(401);
return
}
if (err instanceof IMAPErrors.IMAPCertificateError) {
logger.error({err}, 'Encountered certificate error while attempting to authenticate')
reply({message: USER_ERRORS.IMAP_CERT, type: "api_error"}).code(401);
return
}
if (err instanceof IMAPErrors.RetryableError) {
if (retryNum < MAX_RETRIES) {
setTimeout(() => {
request.logger.info(`IMAP Timeout. Retry #${retryNum + 1}`)
authHandler(request, reply, retryNum + 1)
}, 100)
return
}
logger.error({err}, 'Encountered retryable error while attempting to authenticate')
reply({message: USER_ERRORS.IMAP_RETRY, type: "api_error"}).code(408);
return
}
logger.error({err}, 'Encountered unknown error while attempting to authenticate')
reply({message: USER_ERRORS.AUTH_500, type: "api_error"}).code(500);
return
})
}
return authHandler
},
}

View file

@ -0,0 +1,45 @@
const Sequelize = require('sequelize');
module.exports = {
JSONColumn(fieldName, options = {}) {
return Object.assign(options, {
type: Sequelize.TEXT,
get() {
const val = this.getDataValue(fieldName);
if (!val) {
const {defaultValue} = options
return defaultValue ? Object.assign({}, defaultValue) : {};
}
return JSON.parse(val);
},
set(val) {
this.setDataValue(fieldName, JSON.stringify(val));
},
defaultValue: undefined,
})
},
JSONArrayColumn(fieldName, options = {}) {
return Object.assign(options, {
type: Sequelize.TEXT,
get() {
const val = this.getDataValue(fieldName);
if (!val) {
const {defaultValue} = options
return defaultValue || [];
}
const arr = JSON.parse(val)
if (!Array.isArray(arr)) {
throw new Error('JSONArrayType should be an array')
}
return JSON.parse(val);
},
set(val) {
if (!Array.isArray(val)) {
throw new Error('JSONArrayType should be an array')
}
this.setDataValue(fieldName, JSON.stringify(val));
},
defaultValue: undefined,
})
},
}

View file

@ -0,0 +1,123 @@
const _ = require('underscore');
const Rx = require('rx')
const stream = require('stream');
const DELTA_CONNECTION_TIMEOUT_MS = 15 * 60000;
const OBSERVABLE_TIMEOUT_MS = DELTA_CONNECTION_TIMEOUT_MS - (1 * 60000);
/**
* A Transaction references objects that changed. This finds and inflates
* those objects.
*
* Resolves to an array of transactions with their `attributes` set to be
* the inflated model they reference.
*/
function inflateTransactions(db, accountId, transactions = [], sourceName) {
const transactionJSONs = transactions.map((t) => (t.toJSON ? t.toJSON() : t))
transactionJSONs.forEach((t) => {
t.cursor = t.id;
t.accountId = accountId;
});
const byModel = _.groupBy(transactionJSONs, "object");
const byObjectIds = _.groupBy(transactionJSONs, "objectId");
return Promise.all(Object.keys(byModel).map((modelName) => {
const modelIds = byModel[modelName].filter(t => t.event !== 'delete').map(t => t.objectId);
const modelConstructorName = modelName.charAt(0).toUpperCase() + modelName.slice(1);
const ModelKlass = db[modelConstructorName]
let includes = [];
if (ModelKlass.requiredAssociationsForJSON) {
includes = ModelKlass.requiredAssociationsForJSON(db)
}
return ModelKlass.findAll({
where: {id: modelIds},
include: includes,
}).then((models) => {
const remaining = _.difference(modelIds, models.map(m => `${m.id}`))
if (remaining.length !== 0) {
const badTrans = byModel[modelName].filter(t =>
remaining.includes(t.objectId))
console.error(`While inflating ${sourceName} transactions, we couldn't find models for some ${modelName} IDs`, remaining, badTrans)
}
for (const model of models) {
const transactionsForModel = byObjectIds[model.id];
for (const t of transactionsForModel) {
t.attributes = model.toJSON();
}
}
});
})).then(() => transactionJSONs)
}
function stringifyTransactions(db, accountId, transactions = [], sourceName) {
return inflateTransactions(db, accountId, transactions, sourceName)
.then((transactionJSONs) => {
return `${transactionJSONs.map(JSON.stringify).join("\n")}\n`;
});
}
function transactionsSinceCursor(db, cursor, accountId) {
return db.Transaction.streamAll({where: { id: {$gt: cursor}, accountId }});
}
module.exports = {
DELTA_CONNECTION_TIMEOUT_MS: DELTA_CONNECTION_TIMEOUT_MS,
buildAPIStream(request, {databasePromise, cursor, accountId, deltasSource}) {
return databasePromise.then((db) => {
const source = Rx.Observable.merge(
transactionsSinceCursor(db, cursor, accountId).flatMap((ts) =>
stringifyTransactions(db, accountId, ts, "initial")),
deltasSource.flatMap((t) =>
stringifyTransactions(db, accountId, [t], "new")),
Rx.Observable.interval(1000).map(() => "\n")
).timeout(OBSERVABLE_TIMEOUT_MS);
const outputStream = stream.Readable();
outputStream._read = () => { return };
const disposable = source.subscribe((str) => outputStream.push(str))
// See the following for why we need to set up the listeners on the raw
// stream.
// http://stackoverflow.com/questions/26221000/detecting-when-a-long-request-has-ended-in-nodejs-express
// https://github.com/hapijs/discuss/issues/322#issuecomment-235999544
//
// Hapi's disconnect event only fires on error or unexpected aborts: https://hapijs.com/api#response-events
request.raw.req.on('error', (error) => {
request.logger.error({error}, 'Delta connection stream errored')
disposable.dispose()
})
request.raw.req.on('close', () => {
request.logger.info('Delta connection stream was closed')
disposable.dispose()
})
request.raw.req.on('end', () => {
request.logger.info('Delta connection stream ended')
disposable.dispose()
})
request.on("disconnect", () => {
request.logger.info('Delta connection request was disconnected')
disposable.dispose()
});
return outputStream;
});
},
buildDeltaObservable({db, cursor, accountId, deltasSource}) {
return Rx.Observable.merge(
transactionsSinceCursor(db, cursor, accountId).flatMap((ts) =>
inflateTransactions(db, accountId, ts, "initial")),
deltasSource.flatMap((t) =>
inflateTransactions(db, accountId, [t], "new"))
)
},
buildCursor({databasePromise}) {
return databasePromise.then(({Transaction}) => {
return Transaction.findOne({order: [['id', 'DESC']]}).then((t) => {
return t ? t.id : 0;
});
});
},
}

View file

@ -0,0 +1,23 @@
class NylasError extends Error {
toJSON() {
const json = super.toJSON() || {}
Object.getOwnPropertyNames(this).forEach((key) => {
json[key] = this[key];
});
return json
}
}
class APIError extends NylasError {
constructor(message, statusCode, data) {
super(message);
this.statusCode = statusCode;
this.data = data;
}
}
module.exports = {
NylasError,
APIError,
}

View file

@ -0,0 +1,20 @@
const _ = require('underscore');
module.exports = (db) => {
for (const modelName of Object.keys(db)) {
const model = db[modelName];
const allIgnoredFields = (changedFields) => {
return _.isEqual(changedFields, ['syncState']);
}
model.beforeCreate('increment-version-c', (instance) => {
instance.version = 1;
});
model.beforeUpdate('increment-version-u', (instance) => {
if (!allIgnoredFields(Object.keys(instance._changed))) {
instance.version = instance.version ? instance.version + 1 : 1;
}
});
}
}

View file

@ -0,0 +1,64 @@
const _ = require('underscore')
module.exports = (db, sequelize, {only, onCreatedTransaction} = {}) => {
if (!db.Transaction) {
throw new Error("Cannot enable transaction logging, there is no Transaction model class in this database.")
}
const isTransaction = ($modelOptions) => {
return $modelOptions.name.singular === "transaction"
}
const allIgnoredFields = (changedFields) => {
return _.isEqual(changedFields, ['updatedAt', 'version'])
}
const transactionLogger = (event) => {
return ({dataValues, _changed, $modelOptions}) => {
let name = $modelOptions.name.singular;
if (name === 'metadatum') {
name = 'metadata';
}
if (name === 'reference') {
return;
}
if (name === 'message' && dataValues.isDraft) {
// TODO: when draft syncing support added, remove this and force
// transactions for all drafts in db to sync to app
return;
}
if ((only && !only.includes(name)) || isTransaction($modelOptions)) {
return;
}
const changedFields = Object.keys(_changed)
if (event !== 'delete' && (changedFields.length === 0 || allIgnoredFields(changedFields))) {
return;
}
const accountId = db.accountId ? db.accountId : dataValues.accountId;
if (!accountId) {
throw new Error("Assertion failure: Cannot create a transaction - could not resolve accountId.")
}
const transactionData = Object.assign({event}, {
object: name,
objectId: dataValues.id,
accountId: accountId,
changedFields: changedFields,
});
db.Transaction.create(transactionData).then(onCreatedTransaction)
}
}
sequelize.addHook("afterCreate", transactionLogger("create"))
sequelize.addHook("afterUpdate", transactionLogger("modify"))
// NOTE: Hooking UPSERT requires Sequelize 4.x. We're
// on version 3 right now, but leaving this here for when we upgrade.
sequelize.addHook("afterUpsert", transactionLogger("modify"))
sequelize.addHook("afterDestroy", transactionLogger("delete"))
}

View file

@ -0,0 +1,237 @@
const _ = require('underscore');
const {
RetryableError,
IMAPConnectionNotReadyError,
} = require('./imap-errors');
/*
IMAPBox uses Proxy to wrap the "box" exposed by node-imap. It provides higher-level
primitives, but you can still call through to properties / methods of the node-imap
box, ala `imapbox.uidvalidity`
*/
class IMAPBox {
constructor(imapConn, box) {
this._conn = imapConn
this._box = box
return new Proxy(this, {
get(obj, prop) {
const val = (prop in obj) ? obj[prop] : obj._box[prop];
if (_.isFunction(val)) {
const myBox = obj._box.name;
const openBox = obj._conn.getOpenBoxName()
if (myBox !== openBox) {
return () => {
throw new RetryableError(`IMAPBox::${prop} - Mailbox is no longer selected on the IMAPConnection (${myBox} != ${openBox}).`);
}
}
}
return val;
},
})
}
/**
* @param {array|string} range - can be a single message identifier,
* a message identifier range (e.g. '2504:2507' or '*' or '2504:*'),
* an array of message identifiers, or an array of message identifier ranges.
* @param {Object} options
* @param {function} forEachMessageCallback - function to be called with each
* message as it comes in
* @return {Promise} that will feed each message as it becomes ready
*/
fetchEach(range, options, forEachMessageCallback) {
if (!options) {
throw new Error("IMAPBox.fetch now requires an options object.")
}
if (range.length === 0) {
return Promise.resolve()
}
return this._conn._createConnectionPromise((resolve, reject) => {
const f = this._conn._imap.fetch(range, options);
f.on('message', (imapMessage) => {
const parts = {};
let headers = null;
let attributes = null;
imapMessage.on('attributes', (attrs) => {
attributes = attrs;
});
imapMessage.on('body', (stream, info) => {
const chunks = [];
stream.on('data', (chunk) => {
chunks.push(chunk);
});
stream.once('end', () => {
const full = Buffer.concat(chunks);
if (info.which === 'HEADER') {
headers = full;
} else {
parts[info.which] = full;
}
});
});
imapMessage.once('end', () => {
// attributes is an object containing ascii strings, but parts and
// headers are undecoded binary Buffers (since the data for mime
// parts cannot be decoded to strings without looking up charset data
// in metadata, and this function's job is only to fetch the raw data)
forEachMessageCallback({attributes, headers, parts});
});
})
f.once('error', reject);
f.once('end', resolve);
});
}
/**
* @return {Promise} that resolves to requested message
*/
fetchMessage(uid) {
if (!uid) {
throw new Error("IMAPConnection.fetchMessage requires a message uid.")
}
return new Promise((resolve, reject) => {
let message;
this.fetchEach([uid], {bodies: ['HEADER', 'TEXT']}, (msg) => { message = msg; })
.then(() => resolve(message))
.catch((err) => reject(err))
})
}
fetchMessageStream(uid, {fetchOptions, onFetchComplete} = {}) {
if (!uid) {
throw new Error("IMAPConnection.fetchStream requires a message uid.")
}
if (!fetchOptions) {
throw new Error("IMAPConnection.fetchStream requires an options object.")
}
return this._conn._createConnectionPromise((resolve, reject) => {
const f = this._conn._imap.fetch(uid, fetchOptions);
f.on('message', (imapMessage) => {
imapMessage.on('body', (stream) => {
resolve(stream)
})
})
f.once('error', reject)
f.once('end', onFetchComplete || (() => {}));
})
}
/**
* @param {array|string} range - can be a single message identifier,
* a message identifier range (e.g. '2504:2507' or '*' or '2504:*'),
* an array of message identifiers, or an array of message identifier ranges.
* @return {Promise} that resolves to a map of uid -> attributes for every
* message in the range
*/
fetchUIDAttributes(range, fetchOptions = {}) {
return this._conn._createConnectionPromise((resolve, reject) => {
const attributesByUID = {};
const f = this._conn._imap.fetch(range, fetchOptions);
f.on('message', (msg) => {
msg.on('attributes', (attrs) => {
attributesByUID[attrs.uid] = attrs;
})
});
f.once('error', reject);
f.once('end', () => resolve(attributesByUID));
});
}
addFlags(range, flags) {
if (!this._conn._imap) {
throw new IMAPConnectionNotReadyError(`IMAPBox::addFlags`)
}
return this._conn._createConnectionPromise((resolve, reject) => {
return this._conn._imap.addFlagsAsync(range, flags)
.then((...args) => resolve(...args))
.catch((...args) => reject(...args))
})
}
delFlags(range, flags) {
if (!this._conn._imap) {
throw new IMAPConnectionNotReadyError(`IMAPBox::delFlags`)
}
return this._conn._createConnectionPromise((resolve, reject) => {
return this._conn._imap.delFlagsAsync(range, flags)
.then((...args) => resolve(...args))
.catch((...args) => reject(...args))
})
}
moveFromBox(range, folderName) {
if (!this._conn._imap) {
throw new IMAPConnectionNotReadyError(`IMAPBox::moveFromBox`)
}
return this._conn._createConnectionPromise((resolve, reject) => {
return this._conn._imap.moveAsync(range, folderName)
.then((...args) => resolve(...args))
.catch((...args) => reject(...args))
})
}
setLabels(range, labels) {
if (!this._conn._imap) {
throw new IMAPConnectionNotReadyError(`IMAPBox::moveFromBox`)
}
return this._conn._createConnectionPromise((resolve, reject) => {
return this._conn._imap.setLabelsAsync(range, labels)
.then((...args) => resolve(...args))
.catch((...args) => reject(...args))
})
}
removeLabels(range, labels) {
if (!this._conn._imap) {
throw new IMAPConnectionNotReadyError(`IMAPBox::moveFromBox`)
}
return this._conn._createConnectionPromise((resolve, reject) => {
return this._conn._imap.delLabelsAsync(range, labels)
.then((...args) => resolve(...args))
.catch((...args) => reject(...args))
})
}
append(rawMime, options) {
if (!this._conn._imap) {
throw new IMAPConnectionNotReadyError(`IMAPBox::append`)
}
return this._conn._createConnectionPromise((resolve, reject) => {
return this._conn._imap.appendAsync(rawMime, options)
.then((...args) => resolve(...args))
.catch((...args) => reject(...args))
})
}
search(criteria) {
if (!this._conn._imap) {
throw new IMAPConnectionNotReadyError(`IMAPBox::search`)
}
return this._conn._createConnectionPromise((resolve, reject) => {
return this._conn._imap.searchAsync(criteria)
.then((...args) => resolve(...args))
.catch((...args) => reject(...args))
})
}
closeBox({expunge = true} = {}) {
if (!this._conn._imap) {
throw new IMAPConnectionNotReadyError(`IMAPBox::closeBox`)
}
return this._conn._createConnectionPromise((resolve, reject) => {
return this._conn._imap.closeBoxAsync(expunge)
.then((...args) => resolve(...args))
.catch((...args) => reject(...args))
})
}
}
module.exports = IMAPBox;

View file

@ -0,0 +1,397 @@
import Imap from 'imap';
import _ from 'underscore';
import xoauth2 from 'xoauth2';
import EventEmitter from 'events';
import CommonProviderSettings from 'imap-provider-settings';
import PromiseUtils from './promise-utils';
import IMAPBox from './imap-box';
import {
convertImapError,
IMAPConnectionTimeoutError,
IMAPConnectionNotReadyError,
IMAPConnectionEndedError,
} from './imap-errors';
const MAJOR_IMAP_PROVIDER_HOSTS = Object.keys(CommonProviderSettings).reduce(
(hostnameSet, key) => {
hostnameSet.add(CommonProviderSettings[key].imap_host);
return hostnameSet;
}, new Set())
const Capabilities = {
Gmail: 'X-GM-EXT-1',
Quota: 'QUOTA',
UIDPlus: 'UIDPLUS',
Condstore: 'CONDSTORE',
Search: 'ESEARCH',
Sort: 'SORT',
}
const ONE_HOUR_SECS = 60 * 60;
const SOCKET_TIMEOUT_MS = 30 * 1000;
const AUTH_TIMEOUT_MS = 30 * 1000;
class IMAPConnection extends EventEmitter {
static DefaultSocketTimeout = SOCKET_TIMEOUT_MS;
static connect(...args) {
return new IMAPConnection(...args).connect()
}
constructor({db, account, settings, logger} = {}) {
super();
if (!(settings instanceof Object)) {
throw new Error("IMAPConnection: Must be instantiated with `settings`")
}
if (!logger) {
throw new Error("IMAPConnection: Must be instantiated with `logger`")
}
this._logger = logger;
this._db = db;
this._account = account;
this._queue = [];
this._currentOperation = null;
this._settings = settings;
this._imap = null;
this._connectPromise = null;
this._isOpeningBox = false;
}
static generateXOAuth2Token(username, accessToken) {
// See https://developers.google.com/gmail/xoauth2_protocol
// for more details.
const s = `user=${username}\x01auth=Bearer ${accessToken}\x01\x01`
return new Buffer(s).toString('base64');
}
get account() {
return this._account
}
get logger() {
return this._logger
}
connect() {
if (!this._connectPromise) {
this._connectPromise = this._resolveIMAPSettings().then((settings) => {
this.resolvedSettings = settings
return this._buildUnderlyingConnection(settings)
});
}
return this._connectPromise;
}
_resolveIMAPSettings() {
const settings = {
host: this._settings.imap_host,
port: this._settings.imap_port,
user: this._settings.imap_username,
password: this._settings.imap_password,
tls: this._settings.ssl_required,
socketTimeout: this._settings.socketTimeout || SOCKET_TIMEOUT_MS,
authTimeout: this._settings.authTimeout || AUTH_TIMEOUT_MS,
}
if (!MAJOR_IMAP_PROVIDER_HOSTS.has(settings.host)) {
settings.tlsOptions = { rejectUnauthorized: false };
}
if (process.env.NYLAS_DEBUG) {
settings.debug = console.log;
}
// This account uses XOAuth2, and we have the client_id + refresh token
if (this._settings.refresh_token) {
const xoauthFields = ['client_id', 'client_secret', 'imap_username', 'refresh_token'];
if (Object.keys(_.pick(this._settings, xoauthFields)).length !== 4) {
return Promise.reject(new Error(`IMAPConnection: Expected ${xoauthFields.join(',')} when given refresh_token`))
}
return new Promise((resolve, reject) => {
xoauth2.createXOAuth2Generator({
clientId: this._settings.client_id,
clientSecret: this._settings.client_secret,
user: this._settings.imap_username,
refreshToken: this._settings.refresh_token,
}).getToken((err, token) => {
if (err) { return reject(err) }
delete settings.password;
settings.xoauth2 = token;
settings.expiry_date = Math.floor(Date.now() / 1000) + ONE_HOUR_SECS;
return resolve(settings);
});
});
}
// This account uses XOAuth2, and we have a token given to us by the
// backend, which has the client secret.
if (this._settings.xoauth2) {
delete settings.password;
settings.xoauth2 = this._settings.xoauth2;
settings.expiry_date = this._settings.expiry_date;
}
return Promise.resolve(settings);
}
_buildUnderlyingConnection(settings) {
return new Promise((resolve, reject) => {
this._imap = PromiseUtils.promisifyAll(new Imap(settings));
const socketTimeout = setTimeout(() => {
reject(new IMAPConnectionTimeoutError('Socket timed out'))
}, SOCKET_TIMEOUT_MS)
// Emitted when new mail arrives in the currently open mailbox.
let lastMailEventBox = null;
this._imap.on('mail', () => {
// Fix https://github.com/mscdex/node-imap/issues/585
if (this._isOpeningBox) { return }
if (!this._imap) { return }
if (lastMailEventBox === null || lastMailEventBox === this._imap._box.name) {
// Fix https://github.com/mscdex/node-imap/issues/445
this.emit('mail');
}
lastMailEventBox = this._imap._box.name
});
// Emitted if the UID validity value for the currently open mailbox
// changes during the current session.
this._imap.on('uidvalidity', () => this.emit('uidvalidity'))
// Emitted when message metadata (e.g. flags) changes externally.
this._imap.on('update', () => this.emit('update'))
this._imap.once('ready', () => {
clearTimeout(socketTimeout)
resolve(this)
});
this._imap.once('error', (err) => {
clearTimeout(socketTimeout)
this.end();
reject(convertImapError(err));
});
this._imap.once('end', () => {
clearTimeout(socketTimeout)
this._logger.debug('Underlying IMAP Connection ended');
this._connectPromise = null;
this._imap = null;
});
this._imap.on('alert', (msg) => {
this._logger.info({imap_server_msg: msg}, `IMAP server message`)
});
this._imap.connect();
});
}
end() {
if (this._imap) {
this._imap.end();
this._imap = null;
}
this._queue = [];
this._connectPromise = null;
}
serverSupports(capability) {
if (!this._imap) {
throw new IMAPConnectionNotReadyError(`IMAPConnection::serverSupports`)
}
return this._imap.serverSupports(capability);
}
/**
* @return {Promise} that resolves to instance of IMAPBox
*/
openBox(folderName, {readOnly = false, refetchBoxInfo = false} = {}) {
if (!folderName) {
throw new Error('IMAPConnection::openBox - You must provide a folder name')
}
if (!this._imap) {
throw new IMAPConnectionNotReadyError(`IMAPConnection::openBox`)
}
if (!refetchBoxInfo && folderName === this.getOpenBoxName()) {
return Promise.resolve(new IMAPBox(this, this._imap._box));
}
this._isOpeningBox = true
return this._createConnectionPromise((resolve, reject) => {
return this._imap.openBoxAsync(folderName, readOnly)
.then((box) => {
this._isOpeningBox = false
resolve(new IMAPBox(this, box))
})
.catch((...args) => reject(...args))
})
}
getLatestBoxStatus(folderName) {
if (!folderName) {
throw new Error('IMAPConnection::getLatestBoxStatus - You must provide a folder name')
}
if (folderName === this.getOpenBoxName()) {
// If the box is already open, we need to re-issue a SELECT in order to
// get the latest stats from the box (e.g. latest uidnext, etc)
return this.openBox(folderName, {refetchBoxInfo: true})
}
return this._createConnectionPromise((resolve, reject) => {
return this._imap.statusAsync(folderName)
.then((...args) => resolve(...args))
.catch((...args) => reject(...args))
})
}
getBoxes() {
if (!this._imap) {
throw new IMAPConnectionNotReadyError(`IMAPConnection::getBoxes`)
}
return this._createConnectionPromise((resolve, reject) => {
return this._imap.getBoxesAsync()
.then((...args) => resolve(...args))
.catch((...args) => reject(...args))
})
}
addBox(folderName) {
if (!this._imap) {
throw new IMAPConnectionNotReadyError(`IMAPConnection::addBox`)
}
return this._createConnectionPromise((resolve, reject) => {
return this._imap.addBoxAsync(folderName)
.then((...args) => resolve(...args))
.catch((...args) => reject(...args))
})
}
renameBox(oldFolderName, newFolderName) {
if (!this._imap) {
throw new IMAPConnectionNotReadyError(`IMAPConnection::renameBox`)
}
return this._createConnectionPromise((resolve, reject) => {
return this._imap.renameBoxAsync(oldFolderName, newFolderName)
.then((...args) => resolve(...args))
.catch((...args) => reject(...args))
})
}
delBox(folderName) {
if (!this._imap) {
throw new IMAPConnectionNotReadyError(`IMAPConnection::delBox`)
}
return this._createConnectionPromise((resolve, reject) => {
return this._imap.delBoxAsync(folderName)
.then((...args) => resolve(...args))
.catch((...args) => reject(...args))
})
}
getOpenBoxName() {
return (this._imap && this._imap._box) ? this._imap._box.name : null;
}
runOperation(operation) {
if (!this._imap) {
throw new IMAPConnectionNotReadyError(`IMAPConnection::runOperation`)
}
return new Promise((resolve, reject) => {
this._queue.push({operation, resolve, reject});
if (this._imap.state === 'authenticated' && !this._currentOperation) {
this._processNextOperation();
}
});
}
/*
Equivalent to new Promise, but allows you to easily create promises
which are also rejected when the IMAP connection closes, ends or times out.
This is important because node-imap sometimes just hangs the current
fetch / action forever after emitting an `end` event, or doesn't actually
timeout the socket.
*/
_createConnectionPromise(callback) {
if (!this._imap) {
throw new IMAPConnectionNotReadyError(`IMAPConnection::_createConnectionPromise`)
}
let onEnded = null;
let onErrored = null;
return new Promise((resolve, reject) => {
const socketTimeout = setTimeout(() => {
reject(new IMAPConnectionTimeoutError('Socket timed out'))
}, SOCKET_TIMEOUT_MS)
onEnded = () => {
clearTimeout(socketTimeout)
reject(new IMAPConnectionEndedError());
};
onErrored = (error) => {
clearTimeout(socketTimeout)
this.end()
reject(convertImapError(error));
};
this._imap.once('error', onErrored);
this._imap.once('end', onEnded);
const cbResolve = (...args) => {
clearTimeout(socketTimeout)
resolve(...args)
}
return callback(cbResolve, reject)
})
.finally(() => {
if (this._imap) {
this._imap.removeListener('error', onErrored);
this._imap.removeListener('end', onEnded);
}
});
}
_processNextOperation() {
if (this._currentOperation) {
return;
}
this._currentOperation = this._queue.shift();
if (!this._currentOperation) {
this.emit('queue-empty');
return;
}
const {operation, resolve, reject} = this._currentOperation;
const resultPromise = operation.run(this._db, this);
if (resultPromise.constructor.name !== "Promise") {
reject(new Error(`Expected ${operation.constructor.name} to return promise.`))
}
resultPromise.then((maybeResult) => {
this._currentOperation = null;
// this._logger.info({
// operation_type: operation.constructor.name,
// operation_description: operation.description(),
// }, `Finished sync operation`)
resolve(maybeResult);
this._processNextOperation();
})
.catch((err) => {
this._currentOperation = null;
this._logger.error({
error: err,
operation_type: operation.constructor.name,
operation_description: operation.description(),
}, `IMAPConnection - operation errored`)
reject(err);
})
}
}
IMAPConnection.Capabilities = Capabilities;
module.exports = IMAPConnection

View file

@ -0,0 +1,136 @@
const {NylasError} = require('./errors')
/**
* An abstract base class that can be used to indicate IMAPErrors that may
* fix themselves when retried
*/
class RetryableError extends NylasError { }
/**
* IMAPErrors that originate from NodeIMAP. See `convertImapError` for
* documentation on underlying causes
*/
class IMAPSocketError extends RetryableError { }
class IMAPConnectionTimeoutError extends RetryableError { }
class IMAPAuthenticationTimeoutError extends RetryableError { }
class IMAPProtocolError extends NylasError { }
class IMAPAuthenticationError extends NylasError { }
class IMAPTransientAuthenticationError extends RetryableError { }
class IMAPConnectionNotReadyError extends RetryableError {
constructor(funcName) {
super(`${funcName} - You must call connect() first.`);
}
}
class IMAPConnectionEndedError extends NylasError {
constructor(msg = "The IMAP Connection was ended.") {
super(msg);
}
}
/**
* Certificate validation failures may correct themselves over long spans
* of time, but not over the short spans of time in which it'd make sense
* for us to retry.
*/
class IMAPCertificateError extends NylasError { }
/**
* IMAPErrors may come from:
*
* 1. Underlying IMAP provider (Fastmail, Yahoo, etc)
* 2. Node IMAP
* 3. K2 code
*
* NodeIMAP puts a `source` attribute on `Error` objects to indicate where
* a particular error came from. See https://github.com/mscdex/node-imap/blob/master/lib/Connection.js
*
* These may have the following values:
*
* - "socket-timeout": Created by NodeIMAP when `config.socketTimeout`
* expires on the base Node `net.Socket` and socket.on('timeout') fires
* Message: 'Socket timed out while talking to server'
*
* - "timeout": Created by NodeIMAP when `config.connTimeout` has been
* reached when trying to connect the socket.
* Message: 'Timed out while connecting to server'
*
* - "socket": Created by Node's `net.Socket` on error. See:
* https://nodejs.org/api/net.html#net_event_error_1
* Message: Various from `net.Socket`
*
* - "protocol": Created by NodeIMAP when `bad` or `no` types come back
* from the IMAP protocol.
* Message: Various from underlying IMAP protocol
*
* - "authentication": Created by underlying IMAP connection or NodeIMAP
* in a few scenarios.
* Message: Various from underlying IMAP connection
* OR: No supported authentication method(s) available. Unable to login.
* OR: Logging in is disabled on this server
*
* - "timeout-auth": Created by NodeIMAP when `config.authTimeout` has
* been reached when trying to authenticate
* Message: 'Timed out while authenticating with server'
*
*/
function convertImapError(imapError) {
let error;
if (imapError.message.toLowerCase().includes('try again')) {
error = new RetryableError(imapError)
error.source = imapError.source
return error
}
if (imapError.message.includes('System Error')) {
// System Errors encountered in the wild so far have been retryable.
error = new RetryableError(imapError)
error.source = imapError.source
return error
}
if (imapError.message.includes('User is authenticated but not connected')) {
// We need to treat this type of error as retryable
// See https://github.com/mscdex/node-imap/issues/523 for more details
error = new IMAPSocketError(imapError)
error.source = imapError.source
return error
}
switch (imapError.source) {
case "socket-timeout":
error = new IMAPConnectionTimeoutError(imapError); break;
case "timeout":
error = new IMAPConnectionTimeoutError(imapError); break;
case "socket":
if (imapError.code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE") {
error = new IMAPCertificateError(imapError);
} else {
error = new IMAPSocketError(imapError);
}
break;
case "protocol":
error = new IMAPProtocolError(imapError); break;
case "authentication":
error = new IMAPAuthenticationError(imapError); break;
case "timeout-auth":
error = new IMAPAuthenticationTimeoutError(imapError); break;
default:
return error
}
error.source = imapError.source
return error
}
module.exports = {
convertImapError,
RetryableError,
IMAPSocketError,
IMAPConnectionTimeoutError,
IMAPAuthenticationTimeoutError,
IMAPProtocolError,
IMAPAuthenticationError,
IMAPTransientAuthenticationError,
IMAPConnectionNotReadyError,
IMAPConnectionEndedError,
IMAPCertificateError,
};

View file

@ -0,0 +1,33 @@
const fs = require('fs');
const path = require('path');
function loadModels(Sequelize, sequelize, {loadShared = true, modelDirs = [], schema} = {}) {
if (loadShared) {
modelDirs.unshift(path.join(__dirname, 'models'))
}
const db = {};
for (const modelsDir of modelDirs) {
for (const filename of fs.readdirSync(modelsDir)) {
if (filename.endsWith('.js') || filename.endsWith('.es6')) {
let model = sequelize.import(path.join(modelsDir, filename));
if (schema) {
model = model.schema(schema);
}
db[model.name[0].toUpperCase() + model.name.substr(1)] = model;
}
}
}
Object.keys(db).forEach((modelName) => {
if ("associate" in db[modelName]) {
db[modelName].associate(db);
}
});
return db;
}
module.exports = loadModels

View file

@ -0,0 +1,34 @@
/* eslint no-unused-vars: 0 */
module.exports = {
up: function up(queryInterface, Sequelize) {
return queryInterface.createTable('Users', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER,
},
first_name: {
type: Sequelize.STRING,
},
last_name: {
type: Sequelize.STRING,
},
bio: {
type: Sequelize.TEXT,
},
createdAt: {
allowNull: false,
type: Sequelize.DATE,
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE,
},
});
},
down: function down(queryInterface, Sequelize) {
return queryInterface.dropTable('Users');
},
};

View file

@ -0,0 +1,21 @@
module.exports = (sequelize, Sequelize) => {
const AccountToken = sequelize.define('accountToken', {
value: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
},
}, {
classMethods: {
associate: ({Account}) => {
AccountToken.belongsTo(Account, {
onDelete: "CASCADE",
foreignKey: {
allowNull: false,
},
});
},
},
});
return AccountToken;
};

View file

@ -0,0 +1,148 @@
const crypto = require('crypto');
const {JSONColumn, JSONArrayColumn} = require('../database-types');
const {SUPPORTED_PROVIDERS} = require('../auth-helpers');
const {DB_ENCRYPTION_ALGORITHM, DB_ENCRYPTION_PASSWORD} = process.env;
module.exports = (sequelize, Sequelize) => {
const Account = sequelize.define('account', {
id: { type: Sequelize.STRING(65), primaryKey: true },
name: Sequelize.STRING,
provider: Sequelize.STRING,
emailAddress: Sequelize.STRING,
connectionSettings: JSONColumn('connectionSettings'),
connectionCredentials: Sequelize.TEXT,
syncPolicy: JSONColumn('syncPolicy'),
syncError: JSONColumn('syncError'),
firstSyncCompletion: {
type: Sequelize.STRING(14),
allowNull: true,
defaultValue: null,
},
lastSyncCompletions: JSONArrayColumn('lastSyncCompletions'),
}, {
indexes: [
{
unique: true,
fields: ['id'],
},
],
classMethods: {
associate(data = {}) {
Account.hasMany(data.AccountToken, {as: 'tokens', onDelete: 'cascade', hooks: true})
},
upsertWithCredentials(accountParams, credentials) {
if (!accountParams || !credentials || !accountParams.emailAddress) {
throw new Error("Need to pass accountParams and credentials to upsertWithCredentials")
}
const idString = `${accountParams.emailAddress}${JSON.stringify(accountParams.connectionSettings)}`;
const id = crypto.createHash('sha256').update(idString, 'utf8').digest('hex')
return Account.findById(id).then((existing) => {
const account = existing || Account.build(Object.assign({id}, accountParams))
// always update with the latest credentials
account.setCredentials(credentials);
return account.save().then((saved) => {
return sequelize.models.accountToken.create({accountId: saved.id}).then((token) => {
return Promise.resolve({account: saved, token: token})
})
});
});
},
},
instanceMethods: {
toJSON() {
return {
id: this.id,
name: this.name,
object: 'account',
organization_unit: (this.provider === 'gmail') ? 'label' : 'folder',
provider: this.provider,
email_address: this.emailAddress,
connection_settings: this.connectionSettings,
sync_policy: this.syncPolicy,
sync_error: this.syncError,
first_sync_completion: this.firstSyncCompletion / 1,
last_sync_completions: this.lastSyncCompletions,
created_at: this.createdAt,
}
},
errored() {
return this.syncError != null;
},
setCredentials(json) {
if (!(json instanceof Object)) {
throw new Error("Call setCredentials with JSON!")
}
if (DB_ENCRYPTION_ALGORITHM && DB_ENCRYPTION_PASSWORD) {
const cipher = crypto.createCipher(DB_ENCRYPTION_ALGORITHM, DB_ENCRYPTION_PASSWORD)
let crypted = cipher.update(JSON.stringify(json), 'utf8', 'hex')
crypted += cipher.final('hex');
this.connectionCredentials = crypted;
} else {
this.connectionCredentials = JSON.stringify(json);
}
},
decryptedCredentials() {
let dec = null;
if (DB_ENCRYPTION_ALGORITHM && DB_ENCRYPTION_PASSWORD) {
const decipher = crypto.createDecipher(DB_ENCRYPTION_ALGORITHM, DB_ENCRYPTION_PASSWORD)
dec = decipher.update(this.connectionCredentials, 'hex', 'utf8')
dec += decipher.final('utf8');
} else {
dec = this.connectionCredentials;
}
try {
return JSON.parse(dec);
} catch (err) {
return null;
}
},
bearerToken(xoauth2) {
// We have to unpack the access token from the entire XOAuth2
// token because it is re-packed during the SMTP connection login.
// https://github.com/nodemailer/smtp-connection/blob/master/lib/smtp-connection.js#L1418
const bearer = "Bearer ";
const decoded = atob(xoauth2);
const tokenIndex = decoded.indexOf(bearer) + bearer.length;
return decoded.substring(tokenIndex, decoded.length - 2);
},
smtpConfig() {
const {smtp_host, smtp_port, ssl_required} = this.connectionSettings;
let config = {}
if (this.connectionSettings.smtp_custom_config) {
config = this.connectionSettings.smtp_custom_config
} else {
config = {
host: smtp_host,
port: smtp_port,
secure: ssl_required,
}
}
if (this.provider === 'gmail') {
const {xoauth2} = this.decryptedCredentials();
const {imap_username} = this.connectionSettings;
const token = this.bearerToken(xoauth2);
config.auth = { user: imap_username, xoauth2: token }
} else if (SUPPORTED_PROVIDERS.has(this.provider)) {
const {smtp_username, smtp_password} = this.decryptedCredentials();
config.auth = { user: smtp_username, pass: smtp_password}
} else {
throw new Error(`${this.provider} not yet supported`)
}
return config;
},
},
});
return Account;
};

View file

@ -0,0 +1,25 @@
const {JSONArrayColumn} = require('../database-types');
module.exports = (sequelize, Sequelize) => {
return sequelize.define('transaction', {
event: Sequelize.STRING,
object: Sequelize.STRING,
objectId: Sequelize.STRING,
accountId: Sequelize.STRING,
changedFields: JSONArrayColumn('changedFields'),
}, {
indexes: [
{ fields: ['accountId'] },
],
instanceMethods: {
toJSON: function toJSON() {
return {
id: `${this.id}`,
event: this.event,
object: this.object,
objectId: `${this.objectId}`,
}
},
},
});
}

View file

@ -0,0 +1,56 @@
/* eslint no-restricted-syntax: 0 */
require('promise.prototype.finally')
const props = require('promise-props');
const _ = require('underscore')
global.Promise.prototype.thenReturn = function thenReturn(value) {
return this.then(function then() { return Promise.resolve(value); })
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function each(iterable, iterator) {
return Promise.resolve(iterable).then((array) => {
return new Promise((resolve, reject) => {
Array.from(array).reduce((prevPromise, item, idx, len) => (
prevPromise.then(() => Promise.resolve(iterator(item, idx, len)))
), Promise.resolve())
.then(() => resolve(iterable))
.catch((err) => reject(err))
})
})
}
function promisify(nodeFn) {
return function wrapper(...fnArgs) {
return new Promise((resolve, reject) => {
nodeFn.call(this, ...fnArgs, (err, ...results) => {
if (err) {
reject(err)
return
}
resolve(...results)
});
})
}
}
function promisifyAll(obj) {
for (const key in obj) {
if (!key.endsWith('Async') && _.isFunction(obj[key])) {
obj[`${key}Async`] = promisify(obj[key])
}
}
return obj
}
module.exports = {
each,
sleep,
promisify,
promisifyAll,
props: props,
}

View file

@ -0,0 +1,132 @@
/* eslint no-useless-escape: 0 */
const fs = require('fs');
const nodemailer = require('nodemailer');
const mailcomposer = require('mailcomposer');
const {APIError} = require('./errors')
const MAX_RETRIES = 1;
const formatParticipants = (participants) => {
return participants.map(p => `${p.name} <${p.email}>`).join(',');
}
class SendmailClient {
constructor(account, logger) {
this._transporter = nodemailer.createTransport(account.smtpConfig());
this._logger = logger;
}
async _send(msgData) {
let error;
let results;
// disable nodemailer's automatic X-Mailer header
msgData.xMailer = false;
for (let i = 0; i <= MAX_RETRIES; i++) {
try {
results = await this._transporter.sendMail(msgData);
} catch (err) {
// TODO: shouldn't retry on permanent errors like Invalid login
// TODO: should also wait between retries :(
// Keep retrying for MAX_RETRIES
error = err;
this._logger.error(err);
}
if (!results) {
continue;
}
const {rejected, pending} = results;
if ((rejected && rejected.length > 0) || (pending && pending.length > 0)) {
// At least one recipient was rejected by the server,
// but at least one recipient got it. Don't retry; throw an
// error so that we fail to client.
throw new APIError('Sending to at least one recipient failed', 402, {results});
}
return
}
this._logger.error('Max sending retries reached');
// TODO: figure out how to parse different errors, like in cloud-core
// https://github.com/nylas/cloud-core/blob/production/sync-engine/inbox/sendmail/smtp/postel.py#L354
if (error.message.startsWith("Invalid login: 535-5.7.8 Username and Password not accepted.")) {
throw new APIError('Sending failed - Invalid login', 401, {originalError: error})
}
if (error.message.includes("getaddrinfo ENOTFOUND")) {
throw new APIError('Sending failed - Network Error', 401, {originalError: error})
}
if (error.message.includes("connect ETIMEDOUT")) {
throw new APIError('Sending failed - Network Error', 401, {originalError: error})
}
NylasEnv.reportError(error)
throw new APIError('Sending failed', 500, {originalError: error});
}
_getSendPayload(message) {
const msgData = {};
for (const field of ['from', 'to', 'cc', 'bcc']) {
if (message[field]) {
msgData[field] = formatParticipants(message[field])
}
}
msgData.date = message.date;
msgData.subject = message.subject;
msgData.html = message.body;
msgData.messageId = message.headerMessageId;
msgData.attachments = []
for (const upload of message.uploads) {
msgData.attachments.push({
filename: upload.filename,
content: fs.createReadStream(upload.targetPath),
cid: upload.inline ? upload.id : null,
})
}
if (message.replyTo) {
msgData.replyTo = formatParticipants(message.replyTo);
}
msgData.inReplyTo = message.inReplyTo;
msgData.references = message.references;
// message.headers is usually unset, but in the case that we do add
// headers elsewhere, we don't want to override them here
msgData.headers = message.headers || {};
msgData.headers['User-Agent'] = `NylasMailer-K2`
return msgData;
}
async buildMime(message) {
const payload = this._getSendPayload(message)
const builder = mailcomposer(payload)
const mimeNode = await (new Promise((resolve, reject) => {
builder.build((error, result) => (
error ? reject(error) : resolve(result)
))
}));
return mimeNode.toString('ascii')
}
async send(message) {
if (message.isSent) {
throw new Error(`Cannot send message ${message.id}, it has already been sent`);
}
const payload = this._getSendPayload(message)
await this._send(payload);
}
async sendCustom(customMessage, recipients) {
const envelope = {};
for (const field of Object.keys(recipients)) {
envelope[field] = recipients[field].map(r => r.email);
}
envelope.from = customMessage.from.map(c => c.email)
const raw = await this.buildMime(customMessage);
await this._send({raw, envelope});
}
}
module.exports = SendmailClient;

View file

@ -0,0 +1,12 @@
# Nylas Mail
This repo contains proprietary Nylas plugins and other extensions to N1
It is included as a submodule of the open source N1 repo at
`pro/nylas`
From the root of N1, run `script/grunt add-nylas-build-resources` to manually
copy the files from this repo into the appropriate places within N1.
That script is run as part of the N1 `build` task. Machines that have access
this repo will automatically include the proprietary plugins.

View file

@ -0,0 +1,70 @@
# Building N1 with Continuous Integration
script/grunt ci
N1 is designed to be built into a production app for Mac, Windows, and Linux.
Only Nylas core team members currently have access to produce a production
build.
Production builds are code-signed with a Nylas, Inc. certificate and include a
handful of other proprietary assets such as custom fonts and sounds.
We currently use [Travis](https://travis-ci.org/nylas/nylas-mail) to build
on Mac & Windows and AppVeyor to build on Windows.
A build can be run from a local machines by Jenkins or manually; however,
several environment variables must be setup.:
**ALL ENVIRONMENT VARIABLES ARE ENCRYPTED**
They exist in an encrypted file that only Travis can read in
`build/resources/certs/set_env.sh`
**IMPORTANT** Do NOT remove the `2>/dev/null 1>/dev/null` in the
`before_install` scripts. If any of commands fail we don't want to leak
sensitive data in the output.
That file must be decrypted and `source`d before the environment variables can
use.
If not building on Travis, the environment variables must be manually decrypted
via gpg and sourced
We use [Travis encryption](https://docs.travis-ci.com/user/encrypting-files/)
and AppVeyor encryption to store the certificates, keys, and passwords
To login to GitHub and clone the Nylas submodule with private assets you need
to clone recursively (or `git submodule init; git submodule update`) with a
valid SSH key or login username and password.
We have a CI GitHub account: https://github.com/nylas-deploy-scripts
The password for that account is stored in the environment variable:
- `GITHUB_CI_ACCOUNT_PASSWORD`
For signing builds on Mac only when the certificates are already in the
Keychain (not Travis):
- `XCODE_KEYCHAIN` - The name of the Mac keychain that contains the
certificates and private key.
- `XCODE_KEYCHAIN_PASSWORD` - Th password to that keychain.
- `KEYCHAIN_ACCESS` - Alternatively, the `XCODE_KEYCHAIN` and
`XCODE_KEYCHAIN_PASSWORD` in a single colon-separated string.
Alternatively, on Travis we decrypt the actual certificate files and create a
temporary keychain. To do this we need the password to the private key. That's
stored in:
- `APPLE_CODESIGN_KEY_PASSWORD`
For signing builds on Windows only:
- `CERTIFICATE_FILE` - The Windows certificate
- `CERTIFICATE_PASSWORD` - The password for the private key on the cert
To download Electron:
- `NYLAS_GITHUB_OAUTH_TOKEN` - The OAuth token to use for GitHub API requests. See
https://github.com/atom/grunt-download-electron
To upload built artifacts to S3:
- `AWS_ACCESS_KEY_ID`
- `AWS_SECRET_ACCESS_KEY`
To notify when builds are done:
- `NYLAS_INTERNAL_HOOK_URL` - Nylas internal Slack token and url

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,17 @@
import {Rx, Message, DatabaseStore} from 'nylas-exports';
export default class ActivityDataSource {
buildObservable({openTrackingId, linkTrackingId, messageLimit}) {
const query = DatabaseStore
.findAll(Message)
.order(Message.attributes.date.descending())
.where(Message.attributes.pluginMetadata.contains(openTrackingId, linkTrackingId))
.limit(messageLimit);
this.observable = Rx.Observable.fromQuery(query);
return this.observable;
}
subscribe(callback) {
return this.observable.subscribe(callback);
}
}

View file

@ -0,0 +1,11 @@
import Reflux from 'reflux';
const ActivityListActions = Reflux.createActions([
"resetSeen",
]);
for (const key of Object.keys(ActivityListActions)) {
ActivityListActions[key].sync = true;
}
export default ActivityListActions;

View file

@ -0,0 +1,70 @@
import React from 'react';
import {Actions, ReactDOM} from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit';
import ActivityList from './activity-list';
import ActivityListStore from './activity-list-store';
class ActivityListButton extends React.Component {
static displayName = 'ActivityListButton';
constructor() {
super();
this.state = this._getStateFromStores();
}
componentDidMount() {
this._unsub = ActivityListStore.listen(this._onDataChanged);
}
componentWillUnmount() {
this._unsub();
}
onClick = () => {
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
Actions.openPopover(
<ActivityList />,
{originRect: buttonRect, direction: 'down'}
);
}
_onDataChanged = () => {
this.setState(this._getStateFromStores());
}
_getStateFromStores() {
return {
unreadCount: ActivityListStore.unreadCount(),
}
}
render() {
let unreadCountClass = "unread-count";
let iconClass = "activity-toolbar-icon";
if (this.state.unreadCount) {
unreadCountClass += " active";
iconClass += " unread";
}
return (
<div
tabIndex={-1}
className="toolbar-activity"
title="View activity"
onClick={this.onClick}
>
<div className={unreadCountClass}>
{this.state.unreadCount}
</div>
<RetinaImg
name="icon-toolbar-activity.png"
className={iconClass}
mode={RetinaImg.Mode.ContentIsMask}
/>
</div>
);
}
}
export default ActivityListButton;

View file

@ -0,0 +1,21 @@
import React from 'react';
import {RetinaImg} from 'nylas-component-kit';
const ActivityListEmptyState = function ActivityListEmptyState() {
return (
<div className="empty">
<RetinaImg
className="logo"
name="activity-list-empty.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
<div className="text">
Enable read receipts <RetinaImg name="icon-activity-mailopen.png" mode={RetinaImg.Mode.ContentDark} /> or
link tracking <RetinaImg name="icon-activity-linkopen.png" mode={RetinaImg.Mode.ContentDark} /> to
see notifications here.
</div>
</div>
);
}
export default ActivityListEmptyState;

View file

@ -0,0 +1,141 @@
import React from 'react';
import {DisclosureTriangle,
Flexbox,
RetinaImg} from 'nylas-component-kit';
import {DateUtils} from 'nylas-exports';
import ActivityListStore from './activity-list-store';
import {pluginFor} from './plugin-helpers';
class ActivityListItemContainer extends React.Component {
static displayName = 'ActivityListItemContainer';
static propTypes = {
group: React.PropTypes.array,
};
constructor(props) {
super(props);
this.state = {
collapsed: true,
};
}
_onClick(threadId) {
ActivityListStore.focusThread(threadId);
}
_onCollapseToggled = (event) => {
event.stopPropagation();
this.setState({collapsed: !this.state.collapsed});
}
_getText() {
const text = {
recipient: "Someone",
title: "(No Subject)",
date: new Date(0),
};
const lastAction = this.props.group[0];
if (this.props.group.length === 1 && lastAction.recipient) {
text.recipient = lastAction.recipient.displayName();
} else if (this.props.group.length > 1 && lastAction.recipient) {
const people = [];
for (const action of this.props.group) {
if (!people.includes(action.recipient)) {
people.push(action.recipient);
}
}
if (people.length === 1) text.recipient = people[0].displayName();
else if (people.length === 2) text.recipient = `${people[0].displayName()} and 1 other`;
else text.recipient = `${people[0].displayName()} and ${people.length - 1} others`;
}
if (lastAction.title) text.title = lastAction.title;
text.date.setUTCSeconds(lastAction.timestamp);
return text;
}
renderActivityContainer() {
if (this.props.group.length === 1) return null;
const actions = [];
for (const action of this.props.group) {
const date = new Date(0);
date.setUTCSeconds(action.timestamp);
actions.push(
<div
key={`${action.messageId}-${action.timestamp}`}
className="activity-list-toggle-item"
>
<Flexbox direction="row">
<div className="action-message">
{action.recipient ? action.recipient.displayName() : "Someone"}
</div>
<div className="spacer" />
<div className="timestamp">
{DateUtils.shortTimeString(date)}
</div>
</Flexbox>
</div>
);
}
return (
<div
key={`activity-toggle-container`}
className={`activity-toggle-container ${this.state.collapsed ? "hidden" : ""}`}
>
{actions}
</div>
);
}
render() {
const lastAction = this.props.group[0];
let className = "activity-list-item";
if (!ActivityListStore.hasBeenViewed(lastAction)) className += " unread";
const text = this._getText();
let disclosureTriangle = (<div style={{width: "7px"}} />);
if (this.props.group.length > 1) {
disclosureTriangle = (
<DisclosureTriangle
visible
collapsed={this.state.collapsed}
onCollapseToggled={this._onCollapseToggled}
/>
);
}
return (
<div onClick={() => { this._onClick(lastAction.threadId) }}>
<Flexbox direction="column" className={className}>
<Flexbox
direction="row"
>
<div className="activity-icon-container">
<RetinaImg
className="activity-icon"
name={pluginFor(lastAction.pluginId).iconName}
mode={RetinaImg.Mode.ContentPreserve}
/>
</div>
{disclosureTriangle}
<div className="action-message">
{text.recipient} {pluginFor(lastAction.pluginId).predicate}:
</div>
<div className="spacer" />
<div className="timestamp">
{DateUtils.shortTimeString(text.date)}
</div>
</Flexbox>
<div className="title">
{text.title}
</div>
</Flexbox>
{this.renderActivityContainer()}
</div>
);
}
}
export default ActivityListItemContainer;

View file

@ -0,0 +1,208 @@
import NylasStore from 'nylas-store';
import {
Actions,
Thread,
DatabaseStore,
NativeNotifications,
FocusedPerspectiveStore,
} from 'nylas-exports';
import ActivityListActions from './activity-list-actions';
import ActivityDataSource from './activity-data-source';
import {pluginFor} from './plugin-helpers';
class ActivityListStore extends NylasStore {
activate() {
this._getActivity();
this.listenTo(ActivityListActions.resetSeen, this._onResetSeen);
this.listenTo(FocusedPerspectiveStore, this._updateActivity);
}
actions() {
return this._actions;
}
unreadCount() {
if (this._unreadCount < 1000) {
return this._unreadCount;
} else if (!this._unreadCount) {
return null;
}
return "999+";
}
hasBeenViewed(action) {
if (!NylasEnv.savedState.activityListViewed) return false;
return action.timestamp < NylasEnv.savedState.activityListViewed;
}
focusThread(threadId) {
NylasEnv.displayWindow()
Actions.closePopover()
DatabaseStore.find(Thread, threadId).then((thread) => {
if (!thread) {
NylasEnv.reportError(new Error(`ActivityListStore::focusThread: Can't find thread`, {threadId}))
NylasEnv.showErrorDialog(`Can't find the selected thread in your mailbox`)
return;
}
Actions.ensureCategoryIsFocused('sent', thread.accountId);
Actions.setFocus({collection: 'thread', item: thread});
});
}
getRecipient(recipientEmail, recipients) {
if (recipientEmail) {
for (const recipient of recipients) {
if (recipientEmail === recipient.email) {
return recipient;
}
}
} else if (recipients.length === 1) {
return recipients[0];
}
return null;
}
_dataSource() {
return new ActivityDataSource();
}
_onResetSeen() {
NylasEnv.savedState.activityListViewed = Date.now() / 1000;
this._unreadCount = 0;
this.trigger();
}
_getActivity() {
const dataSource = this._dataSource();
this._subscription = dataSource.buildObservable({
openTrackingId: NylasEnv.packages.pluginIdFor('open-tracking'),
linkTrackingId: NylasEnv.packages.pluginIdFor('link-tracking'),
messageLimit: 500,
}).subscribe((messages) => {
this._messages = messages;
this._updateActivity();
});
}
_updateActivity() {
this._actions = this._messages ? this._getActions(this._messages) : [];
this.trigger();
}
_getActions(messages) {
let actions = [];
this._notifications = [];
this._unreadCount = 0;
const sidebarAccountIds = FocusedPerspectiveStore.sidebarAccountIds();
for (const message of messages) {
if (sidebarAccountIds.length > 1 || message.accountId === sidebarAccountIds[0]) {
const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking')
const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking')
if (message.metadataForPluginId(openTrackingId) ||
message.metadataForPluginId(linkTrackingId)) {
actions = actions.concat(this._openActionsForMessage(message));
actions = actions.concat(this._linkActionsForMessage(message));
}
}
}
if (!this._lastNotified) this._lastNotified = {};
for (const notification of this._notifications) {
const lastNotified = this._lastNotified[notification.threadId];
const {notificationInterval} = pluginFor(notification.pluginId);
if (!lastNotified || lastNotified < Date.now() - notificationInterval) {
NativeNotifications.displayNotification(notification.data);
this._lastNotified[notification.threadId] = Date.now();
}
}
const d = new Date();
this._lastChecked = d.getTime() / 1000;
actions = actions.sort((a, b) => b.timestamp - a.timestamp);
// For performance reasons, only display the last 100 actions
if (actions.length > 100) {
actions.length = 100;
}
return actions;
}
_openActionsForMessage(message) {
const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking')
const openMetadata = message.metadataForPluginId(openTrackingId);
const recipients = message.to.concat(message.cc, message.bcc);
const actions = [];
if (openMetadata) {
if (openMetadata.open_count > 0) {
for (const open of openMetadata.open_data) {
const recipient = this.getRecipient(open.recipient, recipients);
if (open.timestamp > this._lastChecked) {
this._notifications.push({
pluginId: openTrackingId,
threadId: message.threadId,
data: {
title: "New open",
subtitle: `${recipient ? recipient.displayName() : "Someone"} just opened ${message.subject}`,
canReply: false,
tag: "message-open",
onActivate: () => {
this.focusThread(message.threadId);
},
},
});
}
if (!this.hasBeenViewed(open)) this._unreadCount += 1;
actions.push({
messageId: message.id,
threadId: message.threadId,
title: message.subject,
recipient: recipient,
pluginId: openTrackingId,
timestamp: open.timestamp,
});
}
}
}
return actions;
}
_linkActionsForMessage(message) {
const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking')
const linkMetadata = message.metadataForPluginId(linkTrackingId)
const recipients = message.to.concat(message.cc, message.bcc);
const actions = [];
if (linkMetadata && linkMetadata.links) {
for (const link of linkMetadata.links) {
for (const click of link.click_data) {
const recipient = this.getRecipient(click.recipient, recipients);
if (click.timestamp > this._lastChecked) {
this._notifications.push({
pluginId: linkTrackingId,
threadId: message.threadId,
data: {
title: "New click",
subtitle: `${recipient ? recipient.displayName() : "Someone"} just clicked ${link.url}.`,
canReply: false,
tag: "link-open",
onActivate: () => {
this.focusThread(message.threadId);
},
},
});
}
if (!this.hasBeenViewed(click)) this._unreadCount += 1;
actions.push({
messageId: message.id,
threadId: message.threadId,
title: link.url,
recipient: recipient,
pluginId: linkTrackingId,
timestamp: click.timestamp,
});
}
}
}
return actions;
}
}
export default new ActivityListStore();

View file

@ -0,0 +1,100 @@
import React from 'react';
import classnames from 'classnames';
import {Flexbox,
ScrollRegion} from 'nylas-component-kit';
import ActivityListStore from './activity-list-store';
import ActivityListActions from './activity-list-actions';
import ActivityListItemContainer from './activity-list-item-container';
import ActivityListEmptyState from './activity-list-empty-state';
class ActivityList extends React.Component {
static displayName = 'ActivityList';
constructor() {
super();
this.state = this._getStateFromStores();
}
componentDidMount() {
this._unsub = ActivityListStore.listen(this._onDataChanged);
}
componentWillUnmount() {
ActivityListActions.resetSeen();
this._unsub();
}
_onDataChanged = () => {
this.setState(this._getStateFromStores());
}
_getStateFromStores() {
const actions = ActivityListStore.actions();
return {
actions: actions,
empty: actions instanceof Array && actions.length === 0,
collapsedToggles: this.state ? this.state.collapsedToggles : {},
}
}
_groupActions(actions) {
const groupedActions = [];
for (const action of actions) {
if (groupedActions.length > 0) {
const currentGroup = groupedActions[groupedActions.length - 1];
if (action.messageId === currentGroup[0].messageId &&
action.pluginId === currentGroup[0].pluginId) {
groupedActions[groupedActions.length - 1].push(action);
} else {
groupedActions.push([action]);
}
} else {
groupedActions.push([action]);
}
}
return groupedActions;
}
renderActions() {
if (this.state.empty) {
return (
<ActivityListEmptyState />
)
}
const groupedActions = this._groupActions(this.state.actions);
return groupedActions.map((group) => {
return (
<ActivityListItemContainer
key={`${group[0].messageId}-${group[0].timestamp}`}
group={group}
/>
);
});
}
render() {
if (!this.state.actions) return null;
const classes = classnames({
"activity-list-container": true,
"empty": this.state.empty,
})
return (
<Flexbox
direction="column"
height="none"
className={classes}
tabIndex="-1"
>
<ScrollRegion style={{height: "100%"}}>
{this.renderActions()}
</ScrollRegion>
</Flexbox>
);
}
}
export default ActivityList;

View file

@ -0,0 +1,21 @@
import {ComponentRegistry, WorkspaceStore} from 'nylas-exports';
import {HasTutorialTip} from 'nylas-component-kit';
import ActivityListButton from './activity-list-button';
import ActivityListStore from './activity-list-store';
const ActivityListButtonWithTutorialTip = HasTutorialTip(ActivityListButton, {
title: "Open and link tracking",
instructions: "If you've enabled link tracking or read receipts, those events will appear here!",
});
export function activate() {
ComponentRegistry.register(ActivityListButtonWithTutorialTip, {
location: WorkspaceStore.Location.RootSidebar.Toolbar,
});
ActivityListStore.activate();
}
export function deactivate() {
ComponentRegistry.unregister(ActivityListButtonWithTutorialTip);
}

View file

@ -0,0 +1,22 @@
export function pluginFor(id) {
const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking')
const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking')
if (id === openTrackingId) {
return {
name: "open",
predicate: "opened",
iconName: "icon-activity-mailopen.png",
notificationInterval: 600000, // 10 minutes in ms
}
}
if (id === linkTrackingId) {
return {
name: "link",
predicate: "clicked",
iconName: "icon-activity-linkopen.png",
notificationInterval: 10000, // 10 seconds in ms
}
}
return undefined
}

View file

@ -0,0 +1,18 @@
export default class TestDataSource {
buildObservable() {
return this;
}
manuallyTrigger = (messages = []) => {
this.onNext(messages);
}
subscribe(onNext) {
this.onNext = onNext;
this.manuallyTrigger();
const dispose = () => {
this._unsub();
}
return {dispose};
}
}

View file

@ -0,0 +1,21 @@
{
"name": "activity-list",
"main": "./lib/main",
"version": "0.1.0",
"repository": {
"type": "git",
"url": ""
},
"engines": {
"nylas": "*"
},
"isOptional": true,
"title":"Activity List",
"icon":"./assets/icon.png",
"description": "Get notifications for open and link tracking activity.",
"supportedEnvs": ["development", "staging", "production"],
"license": "GPL-3.0"
}

View file

@ -0,0 +1,195 @@
import React from 'react';
import ReactTestUtils from 'react-addons-test-utils';
import {
Thread,
Actions,
Contact,
Message,
DatabaseStore,
FocusedPerspectiveStore,
} from 'nylas-exports';
import ActivityList from '../lib/activity-list';
import ActivityListStore from '../lib/activity-list-store';
import TestDataSource from '../lib/test-data-source';
const OPEN_TRACKING_ID = 'open-tracking-id'
const LINK_TRACKING_ID = 'link-tracking-id'
const messages = [
new Message({
accountId: "0000000000000000000000000",
bcc: [],
cc: [],
snippet: "Testing.",
subject: "Open me!",
threadId: "0000000000000000000000000",
to: [new Contact({
name: "Jackie Luo",
email: "jackie@nylas.com",
})],
}),
new Message({
accountId: "0000000000000000000000000",
bcc: [new Contact({
name: "Ben Gotow",
email: "ben@nylas.com",
})],
cc: [],
snippet: "Hey! I am in town for the week...",
subject: "Coffee?",
threadId: "0000000000000000000000000",
to: [new Contact({
name: "Jackie Luo",
email: "jackie@nylas.com",
})],
}),
new Message({
accountId: "0000000000000000000000000",
bcc: [],
cc: [new Contact({
name: "Evan Morikawa",
email: "evan@nylas.com",
})],
snippet: "Here's the latest deals!",
subject: "Newsletter",
threadId: "0000000000000000000000000",
to: [new Contact({
name: "Juan Tejada",
email: "juan@nylas.com",
})],
}),
];
let pluginValue = {
open_count: 1,
open_data: [{
timestamp: 1461361759.351055,
}],
};
messages[0].applyPluginMetadata(OPEN_TRACKING_ID, pluginValue);
pluginValue = {
links: [{
click_count: 1,
click_data: [{
timestamp: 1461349232.495837,
}],
}],
tracked: true,
};
messages[0].applyPluginMetadata(LINK_TRACKING_ID, pluginValue);
pluginValue = {
open_count: 1,
open_data: [{
timestamp: 1461361763.283720,
}],
};
messages[1].applyPluginMetadata(OPEN_TRACKING_ID, pluginValue);
pluginValue = {
links: [],
tracked: false,
};
messages[1].applyPluginMetadata(LINK_TRACKING_ID, pluginValue);
pluginValue = {
open_count: 0,
open_data: [],
};
messages[2].applyPluginMetadata(OPEN_TRACKING_ID, pluginValue);
pluginValue = {
links: [{
click_count: 0,
click_data: [],
}],
tracked: true,
};
messages[2].applyPluginMetadata(LINK_TRACKING_ID, pluginValue);
describe('ActivityList', function activityList() {
beforeEach(() => {
this.testSource = new TestDataSource();
spyOn(NylasEnv.packages, 'pluginIdFor').andCallFake((pluginName) => {
if (pluginName === 'open-tracking') {
return OPEN_TRACKING_ID
}
if (pluginName === 'link-tracking') {
return LINK_TRACKING_ID
}
return null
})
spyOn(ActivityListStore, "_dataSource").andReturn(this.testSource);
spyOn(FocusedPerspectiveStore, "sidebarAccountIds").andReturn(["0000000000000000000000000"]);
spyOn(DatabaseStore, "run").andCallFake((query) => {
if (query._klass === Thread) {
const thread = new Thread({
id: "0000000000000000000000000",
accountId: TEST_ACCOUNT_ID,
});
return Promise.resolve(thread);
}
return null;
});
spyOn(ActivityListStore, "focusThread").andCallThrough();
spyOn(NylasEnv, "displayWindow");
spyOn(Actions, "closePopover");
spyOn(Actions, "setFocus");
spyOn(Actions, "ensureCategoryIsFocused");
ActivityListStore.activate();
this.component = ReactTestUtils.renderIntoDocument(<ActivityList />);
});
describe('when no actions are found', () => {
it('should show empty state', () => {
const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item");
expect(items.length).toBe(0);
});
});
describe('when actions are found', () => {
it('should show activity list items', () => {
this.testSource.manuallyTrigger(messages);
waitsFor(() => {
const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item");
return items.length > 0;
});
runs(() => {
expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item").length).toBe(3);
});
});
it('should show the correct items', () => {
this.testSource.manuallyTrigger(messages);
waitsFor(() => {
const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item");
return items.length > 0;
});
runs(() => {
expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item")[0].textContent).toBe("Someone opened:Apr 22Coffee?");
expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item")[1].textContent).toBe("Jackie Luo opened:Apr 22Open me!");
expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item")[2].textContent).toBe("Jackie Luo clicked:Apr 22(No Subject)");
});
});
xit('should focus the thread', () => {
runs(() => {
return this.testSource.manuallyTrigger(messages);
})
waitsFor(() => {
const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item");
return items.length > 0;
});
runs(() => {
const item = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item")[0];
ReactTestUtils.Simulate.click(item);
});
waitsFor(() => {
return ActivityListStore.focusThread.calls.length > 0;
});
runs(() => {
expect(NylasEnv.displayWindow.calls.length).toBe(1);
expect(Actions.closePopover.calls.length).toBe(1);
expect(Actions.setFocus.calls.length).toBe(1);
expect(Actions.ensureCategoryIsFocused.calls.length).toBe(1);
});
});
});
});

View file

@ -0,0 +1,142 @@
@import "ui-variables";
.toolbar-activity {
order: 100;
position: relative;
.unread-count {
display: none;
&.active {
display: inline-block;
background: @component-active-color;
text-align: center;
color: @white;
border-radius: @border-radius-base;
font-size: 8px;
padding: 0 4px;
position: absolute;
right: -7px;
top: 5px;
line-height: 11px;
}
}
.activity-toolbar-icon {
margin-top: 20px;
background: @gray;
&.unread {
background: @component-active-color;
}
}
}
.activity-list-container {
width: 260px;
overflow: hidden;
font-size: @font-size-small;
color: @text-color-subtle;
.spacer {
flex: 1 1 0;
}
height: 282px;
&.empty {
height: 182px;
}
.empty {
text-align: center;
padding: @padding-base-horizontal * 2;
padding-top: @padding-base-vertical * 8;
img.logo {
background-color: @text-color-very-subtle;
}
.text {
margin-top: @padding-base-vertical * 6;
color: @text-color-very-subtle;
}
}
.activity-list-item {
padding: @padding-small-vertical @padding-small-horizontal;
white-space: nowrap;
border-bottom: 1px solid @border-color-primary;
cursor: default;
&.unread {
color: @text-color;
background: @background-primary;
&:hover {
background: darken(@background-primary, 2%);
}
.action-message {
font-weight: 600;
}
}
&:hover {
background: darken(@background-secondary, 2%);
}
.disclosure-triangle {
padding-top: 5px;
padding-bottom: 0;
}
.activity-icon-container {
flex-shrink: 0;
}
.activity-icon {
vertical-align: text-bottom;
}
.action-message, .title {
text-overflow: ellipsis;
overflow: hidden;
}
.timestamp {
color: @text-color-very-subtle;
text-overflow: ellipsis;
overflow: hidden;
flex-shrink: 0;
padding-left: 5px;
}
}
.activity-list-toggle-item {
height: 30px;
white-space: nowrap;
background: @background-secondary;
cursor: default;
overflow-y: hidden;
transition-property: all;
transition-duration: .5s;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
&:last-child {
border-bottom: 1px solid @border-color-primary;
}
.action-message {
padding: @padding-small-vertical @padding-small-horizontal;
text-overflow: ellipsis;
overflow: hidden;
}
.timestamp {
padding: @padding-small-vertical @padding-small-horizontal;
color: @text-color-very-subtle;
text-overflow: ellipsis;
overflow: hidden;
}
}
.activity-toggle-container {
&.hidden {
.activity-list-toggle-item {
height: 0;
&:last-child {
border-bottom: none;
}
}
}
}
}
body.platform-win32,
body.platform-linux {
.toolbar-activity {
margin-right: @padding-base-horizontal;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,64 @@
/* eslint no-prototype-builtins: 0 */
import React, {Component, PropTypes} from 'react';
import {mailMergeSessionForDraft} from './mail-merge-draft-editing-session'
export default function ListensToMailMergeSession(ComposedComponent) {
return class extends Component {
static displayName = ComposedComponent.displayName
static containerRequired = false
static propTypes = {
session: PropTypes.object,
draftClientId: PropTypes.string,
...ComposedComponent.propTypes,
}
constructor(props) {
super(props)
this.unlisten = () => {}
this.state = {
mailMergeSession: mailMergeSessionForDraft(props.draftClientId, props.session),
};
}
componentDidMount() {
const {mailMergeSession} = this.state;
if (mailMergeSession) {
this.unlisten = mailMergeSession.listen(() => {
this.setState({mailMergeSession})
});
}
}
componentWillUnmount() {
this.unlisten();
}
focus() {
if (this.refs.composed) {
this.refs.composed.focus()
}
}
render() {
const {mailMergeSession} = this.state;
if (!mailMergeSession) {
return <ComposedComponent {...this.props} sessionState={{}} />
}
const componentProps = {
...this.props,
mailMergeSession: mailMergeSession,
sessionState: mailMergeSession.state,
}
if (Component.isPrototypeOf(ComposedComponent)) {
componentProps.ref = 'composed'
}
return (
<ComposedComponent {...componentProps} />
)
}
}
}

View file

@ -0,0 +1,132 @@
import React, {Component, PropTypes} from 'react'
import MailMergeToken from './mail-merge-token'
import {DragBehaviors} from './mail-merge-constants'
import {tokenQuerySelector} from './mail-merge-utils'
import ListensToMailMergeSession from './listens-to-mail-merge-session'
/**
* MailMergeBodyTokens are rendered by the OverlaidComponents component in the
* subject and body of the composer.
* The OverlaidComponents' state is effectively the state of the contenteditable
* inside those fields, * and it decides what to render based on the
* anchor (img) tags that are present in the contenteditable.
*
* Given this setup, we use the lifecycle methods of MailMergeBodyToken to keep
* the state of the contenteditable (the tokens actually rendered in the UI),
* in sync with our token state for mail merge (tokenDataSource)
*/
class MailMergeBodyToken extends Component {
static displayName = 'MailMergeBodyToken'
static propTypes = {
className: PropTypes.string,
tokenId: PropTypes.string,
field: PropTypes.string,
colName: PropTypes.string,
sessionState: PropTypes.object,
mailMergeSession: PropTypes.object,
draftClientId: PropTypes.string,
colIdx: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
isPreview: PropTypes.bool,
}
constructor(props) {
super(props)
this.state = this.getState(props)
}
componentDidMount() {
// When the token gets mounted, it means a mail merge token anchor node was
// added to the contenteditable, via drop, paste, or any other means, so we
// add it to our mail merge state
const {colIdx, field, colName, tokenId, mailMergeSession} = this.props
const {tokenDataSource} = mailMergeSession.state
const token = tokenDataSource.getToken(field, tokenId)
if (!token) {
mailMergeSession.linkToDraft({colIdx, field, colName, tokenId})
}
}
componentWillReceiveProps(nextProps) {
this.setState(this.getState(nextProps, this.state.colIdx))
}
shouldComponentUpdate(nextProps, nextState) {
return (
this.props.isPreview !== nextProps.isPreview ||
this.state.colIdx !== nextState.colIdx ||
this.props.sessionState.selection !== nextProps.sessionState.selection ||
this.props.sessionState.tableDataSource !== nextProps.sessionState.tableDataSource ||
this.props.sessionState.tokenDataSource !== nextProps.sessionState.tokenDataSource
)
}
componentDidUpdate() {
// A token might be removed by mutations to the contenteditable, in which
// case the tokenDataSource's state is updated by componentWillUnmount.
//
// However, when a token is removed from state via other means, e.g. when a
// table column is removed, we also want to make sure that we remove it from the
// UI. Since the contenteditable is effectively the source of state for
// OverlaidComponents, we imperatively remove the token from contenteditable
// if it has been removed from our state.
const {field, tokenId, sessionState: {tokenDataSource}} = this.props
const token = tokenDataSource.getToken(field, tokenId)
if (!token) {
const node = document.querySelector(tokenQuerySelector(tokenId))
if (node) {
node.parentNode.removeChild(node)
}
}
}
componentWillUnmount() {
// A token might be removed by any sort of mutations to the contenteditable.
// When an the actual anchor node in the contenteditable is removed from
// the dom tree, OverlaidComponents will unmount our corresponding token,
// so this is where we get to update our tokenDataSource's state
const {field, tokenId, mailMergeSession} = this.props
mailMergeSession.unlinkFromDraft({field, tokenId})
}
getState(props) {
// Keep colIdx as state in case the column changes index when importing a
// new csv file, thus changing styling
const {sessionState: {tokenDataSource}, field, tokenId} = props
const nextToken = tokenDataSource.getToken(field, tokenId)
if (nextToken) {
const {colIdx, colName} = nextToken
return {colIdx, colName}
}
const {colIdx, colName} = props
return {colIdx, colName}
}
render() {
const {colIdx, colName} = this.state
const {className, draftClientId, sessionState, isPreview} = this.props
const {tableDataSource, selection} = sessionState
const selectionValue = tableDataSource.cellAt({rowIdx: selection.rowIdx, colIdx}) || "No value selected"
if (isPreview) {
return <span>{selectionValue}</span>
}
return (
<span className={className}>
<MailMergeToken
draggable
colIdx={colIdx}
colName={colName}
dragBehavior={DragBehaviors.Move}
draftClientId={draftClientId}
>
<span className="selection-value">
{selectionValue}
</span>
</MailMergeToken>
</span>
)
}
}
export default ListensToMailMergeSession(MailMergeBodyToken)

View file

@ -0,0 +1,46 @@
import classnames from 'classnames'
import React, {PropTypes} from 'react'
import {RetinaImg} from 'nylas-component-kit'
import ListensToMailMergeSession from './listens-to-mail-merge-session'
function MailMergeButton(props) {
if (props.draft.replyToMessageId) {
return <span />;
}
const {mailMergeSession, sessionState} = props
const {isWorkspaceOpen} = sessionState
const classes = classnames({
"btn": true,
"btn-toolbar": true,
"btn-enabled": isWorkspaceOpen,
"btn-mail-merge": true,
})
return (
<button
className={classes}
title="Mass Email"
onClick={mailMergeSession.toggleWorkspace}
tabIndex={-1}
style={{order: -99}}
>
<RetinaImg
name="icon-composer-mailmerge.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
</button>
)
}
MailMergeButton.displayName = 'MailMergeButton'
MailMergeButton.containerRequired = false
MailMergeButton.propTypes = {
draft: PropTypes.object,
session: PropTypes.object,
sessionState: PropTypes.object,
draftClientId: PropTypes.string,
mailMergeSession: PropTypes.object,
}
export default ListensToMailMergeSession(MailMergeButton)

View file

@ -0,0 +1,10 @@
import * as Handlers from './mail-merge-token-dnd-handlers'
export const name = 'MailMergeComposerExtension'
export {
onDragOver,
shouldAcceptDrop,
} from './mail-merge-token-dnd-handlers'
export const onDrop = Handlers.onDrop.bind(null, 'body')

View file

@ -0,0 +1,37 @@
import plugin from '../package.json'
export const PLUGIN_ID = plugin.name;
export const PLUGIN_NAME = "Mail Merge"
export const DEBUG = false
export const MAX_ROWS = 150
export const ParticipantFields = ['to', 'cc', 'bcc']
export const ContenteditableFields = ['subject', 'body']
export const LinkableFields = [...ParticipantFields, ...ContenteditableFields]
export const DataTransferTypes = {
ColIdx: 'mail-merge:col-idx',
ColName: 'mail-merge:col-name',
DraftId: 'mail-merge:draft-client-id',
DragBehavior: 'mail-merge:drag-behavior',
}
export const DragBehaviors = {
Copy: 'copy',
Move: 'move',
}
export const ActionNames = [
'addColumn',
'removeLastColumn',
'addRow',
'removeRow',
'updateCell',
'shiftSelection',
'setSelection',
'clearTableData',
'loadTableData',
'toggleWorkspace',
'linkToDraft',
'unlinkFromDraft',
]

View file

@ -0,0 +1,39 @@
import React, {Component, PropTypes} from 'react'
import MailMergeWorkspace from './mail-merge-workspace'
import ListensToMailMergeSession from './listens-to-mail-merge-session'
class MailMergeContainer extends Component {
static displayName = 'MailMergeContainer'
static containerRequired = false
static propTypes = {
session: PropTypes.object,
sessionState: PropTypes.object,
draftClientId: PropTypes.string,
mailMergeSession: PropTypes.object,
}
shouldComponentUpdate(nextProps) {
// Make sure we only update if new state has been set
// We do not care about our other props
return (
this.props.draftClientId !== nextProps.draftClientId ||
this.props.sessionState !== nextProps.sessionState
)
}
render() {
const {draftClientId, sessionState, mailMergeSession} = this.props
return (
<MailMergeWorkspace
{...sessionState}
session={mailMergeSession}
draftClientId={draftClientId}
/>
)
}
}
export default ListensToMailMergeSession(MailMergeContainer)

View file

@ -0,0 +1,146 @@
import NylasStore from 'nylas-store'
import * as TableStateReducers from './table-state-reducers'
import * as TokenStateReducers from './token-state-reducers'
import * as SelectionStateReducers from './selection-state-reducers'
import * as WorkspaceStateReducers from './workspace-state-reducers'
import {ActionNames, PLUGIN_ID, DEBUG} from './mail-merge-constants'
const sessions = new Map()
function computeNextState({name, args = []}, previousState = {}, reducers = []) {
if (reducers.length === 0) {
return previousState
}
return reducers.reduce((state, reducer) => {
if (reducer[name]) {
const reduced = reducer[name](previousState, ...args)
return {...state, ...reduced}
}
return state
}, previousState)
}
/**
* MailMergeDraftEditingSession instances hold the entire state for the Mail Merge
* plugin for a given draft, as a single state tree. Sessions trigger when any changes
* on the state tree occur.
*
* Mail Merge state for a draft can be modified by dispatching actions on a session instance.
* Available actions are defined by `MailMergeConstants.ActionNames`.
* Actions are dispatched by calling the action on a session as a method:
* ```
* session.addColumn()
* ```
*
* Internally, the session acts as a Proxy which forwards action calls into any
* registered reducers, and merges the resulting state from calling the action
* on each reducer to compute the new state tree. Registered reducers are
* currently hardcoded in this class.
*
* A session instance also acts as a proxy for the corresponding `DraftEditingSession`,
* instance, and forwards to it any changes that need to be persisted on the draft object
*
* @class MailMergeDraftEditingSession
*/
export class MailMergeDraftEditingSession extends NylasStore {
constructor(session, reducers) {
super()
this._session = session
this._reducers = reducers || [
TableStateReducers,
TokenStateReducers,
SelectionStateReducers,
WorkspaceStateReducers,
]
this._state = {}
this.initializeState()
this.initializeActionHandlers()
}
get state() {
return this._state
}
draft() {
return this._session.draft()
}
draftSession() {
return this._session
}
initializeState(draft = this._session.draft()) {
const savedMetadata = draft.metadataForPluginId(PLUGIN_ID)
const shouldLoadSavedData = (
savedMetadata &&
savedMetadata.tableDataSource &&
savedMetadata.tokenDataSource
)
const action = {name: 'initialState'}
if (shouldLoadSavedData) {
const loadedState = this.dispatch({name: 'fromJSON'}, savedMetadata)
this._state = this.dispatch(action, loadedState)
} else {
this._state = this.dispatch(action)
}
}
initializeActionHandlers() {
ActionNames.forEach((actionName) => {
// TODO ES6 Proxies would be nice here
this[actionName] = this.actionHandler(actionName).bind(this)
})
}
dispatch(action, prevState = this._state) {
const nextState = computeNextState(action, prevState, this._reducers)
if (DEBUG && action.debug !== false) {
console.log('--> action', action.name)
console.dir(action)
console.log('--> prev state')
console.dir(prevState)
console.log('--> new state')
console.dir(nextState)
}
return nextState
}
actionHandler(actionName) {
return (...args) => {
this._state = this.dispatch({name: actionName, args})
// Defer calling `saveToSession` to make sure our state changes are triggered
// before the draft changes
this.trigger()
setImmediate(this.saveToDraftSession)
}
}
saveToDraftSession = () => {
// TODO
// - What should we save in metadata?
// - The entire table data?
// - A reference to a statically hosted file?
// - Attach csv as a file to the "base" or "template" draft?
const {tokenDataSource, tableDataSource} = this._state
const draftChanges = this.dispatch({name: 'toDraftChanges', args: [this._state], debug: false}, this.draft())
const serializedState = this.dispatch({name: 'toJSON', debug: false}, {tokenDataSource, tableDataSource})
this._session.changes.add(draftChanges)
this._session.changes.addPluginMetadata(PLUGIN_ID, serializedState)
}
}
export function mailMergeSessionForDraft(draftId, draftSession) {
if (sessions.has(draftId)) {
return sessions.get(draftId)
}
if (!draftSession) {
return null
}
const sess = new MailMergeDraftEditingSession(draftSession)
sessions.set(draftId, sess)
return sess
}

View file

@ -0,0 +1,67 @@
import React, {Component, PropTypes} from 'react'
import {pickHTMLProps} from 'pick-react-known-prop'
import MailMergeToken from './mail-merge-token'
function getInputSize(value) {
return ((value || '').length || 1) + 1
}
class MailMergeHeaderInput extends Component {
static propTypes = {
draftClientId: PropTypes.string,
colIdx: PropTypes.any,
tableDataSource: PropTypes.object,
defaultValue: PropTypes.string,
onBlur: PropTypes.func,
}
constructor(props) {
super(props)
this.state = {inputSize: getInputSize(props.defaultValue)}
}
componentWillReceiveProps(nextProps) {
this.setState({inputSize: getInputSize(nextProps.defaultValue)})
}
onInputBlur = (event) => {
const {target: {value}} = event
this.setState({inputSize: getInputSize(value)})
// Can't override the original onBlur handler
this.props.onBlur(event)
}
onInputChange = (event) => {
const {target: {value}} = event
this.setState({inputSize: getInputSize(value)})
}
render() {
const {inputSize} = this.state
const {draftClientId, tableDataSource, colIdx, ...props} = this.props
const colName = tableDataSource.colAt(colIdx)
return (
<div className="header-cell">
<MailMergeToken
draggable
colIdx={colIdx}
colName={colName}
draftClientId={draftClientId}
>
<input
{...pickHTMLProps(props)}
size={inputSize}
onBlur={this.onInputBlur}
onChange={this.onInputChange}
defaultValue={props.defaultValue}
/>
</MailMergeToken>
</div>
)
}
}
export default MailMergeHeaderInput

View file

@ -0,0 +1,138 @@
import React, {Component, PropTypes} from 'react';
import classnames from 'classnames'
import {DropZone, TokenizingTextField} from 'nylas-component-kit'
import MailMergeToken from './mail-merge-token'
import {DataTransferTypes} from './mail-merge-constants'
import ListensToMailMergeSession from './listens-to-mail-merge-session'
function MailMergeParticipantToken(props) {
const {token: {tableDataSource, rowIdx, colIdx, colName}} = props
const selectionValue = tableDataSource.cellAt({rowIdx, colIdx}) || 'No value selected'
return (
<MailMergeToken draggable colIdx={colIdx} colName={colName}>
<span>{selectionValue}</span>
</MailMergeToken>
)
}
MailMergeParticipantToken.propTypes = {
token: PropTypes.shape({
colIdx: PropTypes.any,
rowIdx: PropTypes.any,
tableDataSource: PropTypes.object,
}),
}
class MailMergeParticipantsTextField extends Component {
static displayName = 'MailMergeParticipantsTextField'
static containerRequired = false
static propTypes = {
onAdd: PropTypes.func,
onRemove: PropTypes.func,
field: PropTypes.string,
session: PropTypes.object,
className: PropTypes.string,
sessionState: PropTypes.object,
draftClientId: PropTypes.string,
mailMergeSession: PropTypes.object,
}
static defaultProps = {
className: '',
}
constructor(props) {
super(props)
this._tokenWasMovedBetweenFields = false
}
// This is called by the TokenizingTextField when a token is dragged and dropped
// between fields
onAddToken = (...args) => {
const tokenToAdd = args[0][0]
if (args.length > 1 || !tokenToAdd) { return }
const {mailMergeSession} = this.props
const {colIdx, colName, tokenId, field} = tokenToAdd
// Remove from previous field
mailMergeSession.unlinkFromDraft({field, tokenId})
// Add to our current field
mailMergeSession.linkToDraft({colIdx, colName, field: this.props.field})
this._tokenWasMovedBetweenFields = true
}
onRemoveToken = ([tokenToDelete]) => {
const {field, mailMergeSession} = this.props
const {tokenId} = tokenToDelete
mailMergeSession.unlinkFromDraft({field, tokenId})
}
onDrop = (event) => {
if (this._tokenWasMovedBetweenFields) {
// Ignore drop if we already added the token
this._tokenWasMovedBetweenFields = false
return
}
const {dataTransfer} = event
const {field, mailMergeSession} = this.props
const colIdx = dataTransfer.getData(DataTransferTypes.ColIdx)
const colName = dataTransfer.getData(DataTransferTypes.ColName)
mailMergeSession.linkToDraft({colIdx, colName, field})
}
focus() {
this.refs.textField.focus()
}
shouldAcceptDrop = (event) => {
const {dataTransfer} = event
return !!dataTransfer.getData(DataTransferTypes.ColIdx)
}
render() {
const {field, className, sessionState} = this.props
const {isWorkspaceOpen, tableDataSource, selection, tokenDataSource} = sessionState
if (!isWorkspaceOpen) {
return <TokenizingTextField ref="textField" {...this.props} />
}
const classes = classnames({
'mail-merge-participants-text-field': true,
[className]: true,
})
const tokens = (
tokenDataSource.tokensForField(field)
.map((token) => ({...token, tableDataSource, rowIdx: selection.rowIdx}))
)
return (
<DropZone
onDrop={this.onDrop}
shouldAcceptDrop={this.shouldAcceptDrop}
>
<TokenizingTextField
{...this.props}
ref="textField"
className={classes}
tokens={tokens}
tokenKey={(token) => token.tokenId}
tokenRenderer={MailMergeParticipantToken}
tokenIsValid={() => true}
tokenClassNames={(token) => `token-color-${token.colIdx % 5}`}
onRequestCompletions={() => []}
completionNode={() => <span />}
onAdd={this.onAddToken}
onRemove={this.onRemoveToken}
onTokenAction={false}
/>
</DropZone>
)
}
}
export default ListensToMailMergeSession(MailMergeParticipantsTextField)

View file

@ -0,0 +1,115 @@
import {remote} from 'electron'
import React, {Component, PropTypes} from 'react'
import {RetinaImg} from 'nylas-component-kit'
import {sendMailMerge} from './mail-merge-utils'
import ListensToMailMergeSession from './listens-to-mail-merge-session'
class MailMergeSendButton extends Component {
static displayName = 'MailMergeSendButton'
static containerRequired = false
static propTypes = {
draft: PropTypes.object,
session: PropTypes.object,
sessionState: PropTypes.object,
isValidDraft: PropTypes.func,
fallback: PropTypes.func,
}
constructor(props) {
super(props)
this.state = {
sending: false,
}
}
onClick = () => {
const {sending} = this.state
if (sending) { return }
const {draft, isValidDraft} = this.props
if (draft.to.length === 0) {
const dialog = remote.dialog;
dialog.showMessageBox(remote.getCurrentWindow(), {
type: 'warning',
buttons: ['Edit Message', 'Cancel'],
message: 'Cannot Send',
detail: "Before sending, you need to drag the header cell of the column of emails to the To field in Recipients",
});
} else {
if (isValidDraft()) {
this.setState({sending: true})
try {
sendMailMerge(draft.clientId)
} catch (e) {
this.setState({sending: false})
NylasEnv.showErrorDialog(e.message)
}
}
}
}
primarySend() {
// Primary click is called when mod+enter is pressed.
// If mail merge is not open, we should revert to default behavior
const {isWorkspaceOpen} = this.props.sessionState
if (!isWorkspaceOpen && this.refs.fallbackButton) {
this.refs.fallbackButton.primarySend()
} else {
this.onClick()
}
}
render() {
const {sending} = this.state
const {isWorkspaceOpen, tableDataSource} = this.props.sessionState
if (!isWorkspaceOpen) {
const Fallback = this.props.fallback
return <Fallback ref="fallbackButton" {...this.props} />
}
const count = tableDataSource.rows().length
const action = sending ? 'Sending' : 'Send'
const sendLabel = count > 1 ? `${action} ${count} messages` : `${action} ${count} message`;
let classes = "btn btn-toolbar btn-normal btn-emphasis btn-text btn-send"
if (sending) {
classes += " btn-disabled"
}
return (
<button
tabIndex={-1}
className={classes}
style={{order: -100}}
onClick={this.onClick}
>
<span>
<RetinaImg
name="icon-composer-send.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
<span className="text">{sendLabel}</span>
</span>
</button>
);
}
}
// TODO this is a hack so that the mail merge send button can still expose
// the `primarySend` method required by the ComposerView. Ideally, this
// decorator mechanism should expose whatever instance methods are exposed
// by the component its wrapping.
// However, I think the better fix will happen when mail merge lives in its
// own window and doesn't need to override the Composer's send button, which
// is already a bit of a hack.
const EnhancedMailMergeSendButton = ListensToMailMergeSession(MailMergeSendButton)
Object.assign(EnhancedMailMergeSendButton.prototype, {
primarySend() {
if (this.refs.composed) {
this.refs.composed.primarySend()
}
},
})
export default EnhancedMailMergeSendButton

View file

@ -0,0 +1,132 @@
/* eslint react/no-danger: 0 */
import React, {Component, PropTypes} from 'react'
import {findDOMNode} from 'react-dom'
import {EditorAPI} from 'nylas-exports'
import {OverlaidComponents, DropZone} from 'nylas-component-kit'
import ListensToMailMergeSession from './listens-to-mail-merge-session'
import * as Handlers from './mail-merge-token-dnd-handlers'
class MailMergeSubjectTextField extends Component {
static displayName = 'MailMergeSubjectTextField'
static containerRequired = false
static propTypes = {
value: PropTypes.string,
fallback: PropTypes.func,
draft: PropTypes.object,
session: PropTypes.object,
sessionState: PropTypes.object,
draftClientId: PropTypes.string,
onSubjectChange: PropTypes.func.isRequired,
}
componentDidMount() {
const {isWorkspaceOpen} = this.props.sessionState
this.savedSelection = null
if (isWorkspaceOpen) {
this.editor = new EditorAPI(findDOMNode(this.refs.contenteditable))
}
}
shouldComponentUpdate(nextProps) {
return (
this.props.draftClientId !== nextProps.draftClientId ||
this.props.value !== nextProps.value ||
this.props.sessionState.isWorkspaceOpen !== nextProps.sessionState.isWorkspaceOpen
)
}
componentDidUpdate() {
const {isWorkspaceOpen} = this.props.sessionState
if (isWorkspaceOpen) {
this.editor = new EditorAPI(findDOMNode(this.refs.contenteditable))
if (this.savedSelection && this.savedSelection.rawSelection.anchorNode) {
this.editor.select(this.savedSelection)
this.savedSelection = null
}
}
}
onInputChange = (event) => {
const value = event.target.innerHTML
this.savedSelection = this.editor.currentSelection().exportSelection()
this.props.onSubjectChange(value)
}
onInputKeyDown = (event) => {
if (['Enter', 'Return'].includes(event.key)) {
event.stopPropagation()
event.preventDefault()
}
}
onDrop = (event) => {
Handlers.onDrop('subject', {editor: this.editor, event})
}
onDragOver = (event) => {
Handlers.onDragOver({editor: this.editor, event})
}
shouldAcceptDrop = (event) => {
return Handlers.shouldAcceptDrop({event})
}
focus() {
const {isWorkspaceOpen} = this.props.sessionState
if (isWorkspaceOpen) {
findDOMNode(this.refs.contenteditable).focus()
} else {
this.refs.fallback.focus()
}
}
renderContenteditable() {
const {value} = this.props
return (
<DropZone
className="mail-merge-subject-text-field composer-subject subject-field"
onDrop={this.onDrop}
onDragOver={this.onDragOver}
shouldAcceptDrop={this.shouldAcceptDrop}
>
<div
ref="contenteditable"
contentEditable
name="subject"
placeholder="Subject"
onBlur={this.onInputChange}
onInput={this.onInputChange}
onKeyDown={this.onInputKeyDown}
dangerouslySetInnerHTML={{__html: value}}
/>
</DropZone>
)
}
render() {
const {isWorkspaceOpen} = this.props.sessionState
if (!isWorkspaceOpen) {
const Fallback = this.props.fallback
return <Fallback ref="fallback" {...this.props} />
}
const {draft, session} = this.props
const exposedProps = {draft, session}
return (
<OverlaidComponents
className="mail-merge-subject-overlaid"
exposedProps={exposedProps}
>
{this.renderContenteditable()}
</OverlaidComponents>
)
}
}
export default ListensToMailMergeSession(MailMergeSubjectTextField)

View file

@ -0,0 +1,43 @@
import React, {PropTypes} from 'react'
import {EditableTable} from 'nylas-component-kit'
import {pickHTMLProps} from 'pick-react-known-prop'
import MailMergeHeaderInput from './mail-merge-header-input'
function InputRenderer(props) {
const {isHeader, draftClientId} = props;
if (!isHeader) {
return <input {...pickHTMLProps(props)} defaultValue={props.defaultValue} />
}
return <MailMergeHeaderInput draftClientId={draftClientId} {...props} />
}
InputRenderer.propTypes = {
isHeader: PropTypes.bool,
defaultValue: PropTypes.string,
draftClientId: PropTypes.string,
}
function MailMergeTable(props) {
const {draftClientId} = props
return (
<div className="mail-merge-table">
<EditableTable
{...props}
displayHeader
displayNumbers
rowHeight={30}
bodyHeight={150}
inputProps={{draftClientId}}
InputRenderer={InputRenderer}
/>
</div>
)
}
MailMergeTable.propTypes = {
tableDataSource: EditableTable.propTypes.tableDataSource,
selection: PropTypes.object,
draftClientId: PropTypes.string,
onShiftSelection: PropTypes.func,
}
export default MailMergeTable

View file

@ -0,0 +1,51 @@
import {Utils} from 'nylas-exports'
import {mailMergeSessionForDraft} from './mail-merge-draft-editing-session'
import {DataTransferTypes, DragBehaviors} from './mail-merge-constants'
function updateCursorPosition({editor, event}) {
const {clientX, clientY} = event
const range = document.caretRangeFromPoint(clientX, clientY);
range.collapse()
editor.select(range)
return range
}
export function shouldAcceptDrop({event}) {
const {dataTransfer} = event;
return !!dataTransfer.getData(DataTransferTypes.ColIdx);
}
export function onDragOver({editor, event}) {
updateCursorPosition({editor, event})
}
export function onDrop(field, {editor, event}) {
const {dataTransfer} = event
const colIdx = dataTransfer.getData(DataTransferTypes.ColIdx)
const colName = dataTransfer.getData(DataTransferTypes.ColName)
const dragBehavior = dataTransfer.getData(DataTransferTypes.DragBehavior)
const draftClientId = dataTransfer.getData(DataTransferTypes.DraftId)
const mailMergeSession = mailMergeSessionForDraft(draftClientId)
if (!mailMergeSession) {
return
}
if (dragBehavior === DragBehaviors.Move) {
const {tokenDataSource} = mailMergeSession.state
const {tokenId} = tokenDataSource.findTokens(field, {colName, colIdx}).pop() || {}
editor.removeCustomComponentByAnchorId(tokenId)
}
updateCursorPosition({editor, event})
const tokenId = Utils.generateTempId()
editor.insertCustomComponent('MailMergeBodyToken', {
field,
colIdx,
colName,
tokenId,
draftClientId,
anchorId: tokenId,
className: 'mail-merge-token-wrap',
})
}

View file

@ -0,0 +1,46 @@
import React, {PropTypes} from 'react'
import classnames from 'classnames'
import {RetinaImg} from 'nylas-component-kit'
import {DataTransferTypes, DragBehaviors} from './mail-merge-constants'
function onDragStart(event, {draftClientId, colIdx, colName, dragBehavior}) {
const {dataTransfer} = event
dataTransfer.effectAllowed = 'move'
dataTransfer.setData(DataTransferTypes.DraftId, draftClientId)
dataTransfer.setData(DataTransferTypes.ColIdx, colIdx)
dataTransfer.setData(DataTransferTypes.ColName, colName)
dataTransfer.setData(DataTransferTypes.DragBehavior, dragBehavior)
}
function MailMergeToken(props) {
const {draftClientId, colIdx, colName, children, draggable, dragBehavior} = props
const classes = classnames({
'mail-merge-token': true,
[`token-color-${colIdx % 5}`]: true,
})
const _onDragStart = event => onDragStart(event, {draftClientId, colIdx, colName, dragBehavior})
const dragHandle = draggable ? <RetinaImg name="mailmerge-grabber.png" mode={RetinaImg.Mode.ContentIsMask} /> : null;
return (
<span draggable={draggable} className={classes} onDragStart={_onDragStart}>
{dragHandle}
{children}
</span>
)
}
MailMergeToken.propTypes = {
draftClientId: PropTypes.string,
colIdx: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
colName: PropTypes.string,
children: PropTypes.node,
draggable: PropTypes.bool,
dragBehavior: PropTypes.string,
}
MailMergeToken.defaultProps = {
draggable: false,
dragBehavior: DragBehaviors.Copy,
}
export default MailMergeToken

View file

@ -0,0 +1,204 @@
import Papa from 'papaparse'
import {
Utils,
Actions,
Contact,
RegExpUtils,
DraftHelpers,
DatabaseStore,
SoundRegistry,
} from 'nylas-exports'
import {PLUGIN_ID, MAX_ROWS, DataTransferTypes, ParticipantFields} from './mail-merge-constants'
import {mailMergeSessionForDraft} from './mail-merge-draft-editing-session'
import SendManyDraftsTask from './send-many-drafts-task'
export function contactFromColIdx(colIdx, email) {
return new Contact({
name: email || '',
email: email || 'No value selected',
clientId: `${DataTransferTypes.ColIdx}:${colIdx}`,
})
}
export function colIdxFromContact(contact) {
const {clientId} = contact
if (!clientId.startsWith(DataTransferTypes.ColIdx)) {
return null
}
return contact.clientId.split(':')[2]
}
export function tokenQuerySelector(tokenId) {
if (!tokenId) {
return `img.mail-merge-token-wrap`
}
return `img.mail-merge-token-wrap[data-overlay-id="${tokenId}"]`
}
export function tokenRegex(tokenId) {
if (!tokenId) {
// https://regex101.com/r/sU7sO6/1
return /<img[^>]*?class="[^>]*?mail-merge-token-wrap[^>]*?"[^>]*?>/gim
}
// https://regex101.com/r/fJ5eN6/5
const reStr = `<img[^>]*?class="[^>]*?mail-merge-token-wrap[^>]*?" [^>]*?data-overlay-id="${tokenId}"[^>]*?>`
return new RegExp(reStr, 'gim')
}
function replaceContenteditableTokens(html, {field, tableDataSource, tokenDataSource, rowIdx}) {
const replaced = tokenDataSource.tokensForField(field)
.reduce((currentHtml, {colIdx, tokenId}) => {
const fieldValue = tableDataSource.cellAt({rowIdx, colIdx}) || ""
const markup = `<span>${fieldValue}</span>`
return currentHtml.replace(tokenRegex(tokenId), markup)
}, html)
if (tokenRegex().test(replaced)) {
throw new Error(`Field ${field} still contains tokens after attempting to replace for table values`)
}
return replaced
}
export function buildDraft(baseDraft, {tableDataSource, tokenDataSource, rowIdx}) {
if (tableDataSource.isEmpty({rowIdx})) {
return null
}
const draftToSend = baseDraft.clone()
draftToSend.clientId = Utils.generateTempId()
// Clear any previous mail merge metadata on the draft we are going to send
// and add rowIdx
draftToSend.applyPluginMetadata(PLUGIN_ID, {rowIdx})
// Replace tokens inside subject with values from table data
const draftSubject = replaceContenteditableTokens(draftToSend.subject, {
field: 'subject',
rowIdx,
tokenDataSource,
tableDataSource,
})
draftToSend.subject = Utils.extractTextFromHtml(draftSubject)
// Replace tokens inside body with values from table data
draftToSend.body = replaceContenteditableTokens(draftToSend.body, {
field: 'body',
rowIdx,
tokenDataSource,
tableDataSource,
})
// Update participant values
ParticipantFields.forEach((field) => {
draftToSend[field] = tokenDataSource.tokensForField(field).map(({colIdx}) => {
const column = tableDataSource.colAt(colIdx)
const value = (tableDataSource.cellAt({rowIdx, colIdx}) || "").trim()
const contact = new Contact({accountId: baseDraft.accountId, name: value, email: value})
if (!contact.isValid()) {
throw new Error(`Can't send messages:\nThe column ${column} contains an invalid email address at row ${rowIdx + 1}: "${value}"`)
}
return contact
})
})
return draftToSend
}
export function sendManyDrafts(mailMergeSession, recipientDrafts) {
const transformedDrafts = [];
return mailMergeSession.draftSession().ensureCorrectAccount({noSyncback: true})
.then(() => {
const baseDraft = mailMergeSession.draft();
return Promise.each(recipientDrafts, (recipientDraft) => {
recipientDraft.accountId = baseDraft.accountId;
recipientDraft.serverId = null;
return DraftHelpers.applyExtensionTransforms(recipientDraft).then((transformed) =>
transformedDrafts.push(transformed)
);
});
})
.then(() =>
DatabaseStore.inTransaction(t => t.persistModels(transformedDrafts))
)
.then(async () => {
const baseDraft = mailMergeSession.draft();
if (baseDraft.uploads.length > 0) {
recipientDrafts.forEach(async (d) => {
await DraftHelpers.removeStaleUploads(d);
})
}
const recipientClientIds = recipientDrafts.map(d => d.clientId)
Actions.queueTask(new SendManyDraftsTask(baseDraft.clientId, recipientClientIds))
if (NylasEnv.config.get("core.sending.sounds")) {
SoundRegistry.playSound('hit-send');
}
NylasEnv.close();
})
}
export function sendMailMerge(draftClientId) {
const mailMergeSession = mailMergeSessionForDraft(draftClientId)
if (!mailMergeSession) { return }
const baseDraft = mailMergeSession.draft()
const {tableDataSource, tokenDataSource} = mailMergeSession.state
const recipientDrafts = tableDataSource.rows()
.map((row, rowIdx) => (
buildDraft(baseDraft, {tableDataSource, tokenDataSource, rowIdx})
))
.filter((draft) => draft != null)
if (recipientDrafts.length === 0) {
NylasEnv.showErrorDialog(`There are no drafts to send! Add add some data to the table below`)
return
}
sendManyDrafts(mailMergeSession, recipientDrafts)
}
export function parseCSV(file, maxRows = MAX_ROWS) {
return new Promise((resolve, reject) => {
Papa.parse(file, {
skipEmptyLines: true,
complete: ({data}) => {
if (data.length === 0) {
NylasEnv.showErrorDialog(
`The csv file you are trying to import contains no rows. Please select another file.`
);
resolve(null)
return;
}
// If a cell in the first row contains a valid email address, assume that
// the table has no headers. We need row[0] to be field names, so make some up!
const emailRegexp = RegExpUtils.emailRegex();
const emailInFirstRow = data[0].find((val) => emailRegexp.test(val));
if (emailInFirstRow) {
const headers = data[0].map((val, idx) => {
return emailInFirstRow === val ? 'Email Address' : `Column ${idx}`
})
data.unshift(headers);
}
const columns = data[0].slice()
const rows = data.slice(1)
if (rows.length > maxRows) {
NylasEnv.showErrorDialog(
`The csv file you are trying to import contains more than the max allowed number of rows (${maxRows}).\nWe have only imported the first ${maxRows} rows`
);
resolve({columns, rows: rows.slice(0, maxRows)})
return
}
resolve({columns, rows})
},
error: (error) => {
NylasEnv.showErrorDialog(`Sorry, we were unable to parse the file: ${file.name}\n${error.message}`);
reject(error)
},
})
})
}

View file

@ -0,0 +1,159 @@
import React, {Component, PropTypes} from 'react'
import {RetinaImg, DropZone} from 'nylas-component-kit'
import fs from 'fs';
import {parseCSV} from './mail-merge-utils'
import MailMergeTable from './mail-merge-table'
class MailMergeWorkspace extends Component {
static displayName = 'MailMergeWorkspace'
static propTypes = {
isWorkspaceOpen: PropTypes.bool,
tableDataSource: MailMergeTable.propTypes.tableDataSource,
selection: PropTypes.object,
draftClientId: PropTypes.string,
session: PropTypes.object,
}
constructor() {
super()
this.state = {isDropping: false}
}
onDragStateChange = ({isDropping}) => {
this.setState({isDropping})
}
onChooseCSV = () => {
NylasEnv.showOpenDialog({
properties: ['openFile'],
filters: [
{ name: 'CSV Files', extensions: ['csv', 'txt'] },
],
}, (pathsToOpen) => {
if (!pathsToOpen || pathsToOpen.length === 0) {
return;
}
fs.readFile(pathsToOpen[0], (err, contents) => {
parseCSV(contents.toString()).then((tableData) => {
this.loadCSV(tableData)
});
});
});
}
onDropCSV = (event) => {
event.stopPropagation()
const {dataTransfer} = event
const file = dataTransfer.files[0]
parseCSV(file)
.then(tableData => this.loadCSV(tableData))
}
loadCSV(newTableData) {
const {tableDataSource, session} = this.props
// TODO We need to reset the table values first because `EditableTable` does
// not support controlled inputs, i.e. the inputs just use the
// defaultValue props which will only apply when the input is empty
session.clearTableData()
session.loadTableData({newTableData, prevColumns: tableDataSource.columns()})
}
shouldAcceptDrop = (event) => {
event.stopPropagation()
const {dataTransfer} = event
if (dataTransfer.files.length === 1) {
const file = dataTransfer.files[0]
if (file.type === 'text/csv') {
return true
}
}
return false
}
renderSelectionControls() {
const {selection, tableDataSource, session} = this.props
const rows = tableDataSource.rows()
return (
<div className="selection-controls">
<div className="btn btn-group">
<div
className="btn-prev"
onClick={() => session.shiftSelection({row: -1})}
>
<RetinaImg
name="toolbar-dropdown-chevron.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
</div>
<div
className="btn-next"
onClick={() => session.shiftSelection({row: 1})}
>
<RetinaImg
name="toolbar-dropdown-chevron.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
</div>
</div>
<span>Recipient {selection.rowIdx + 1} of {rows.length}</span>
<span style={{flex: 1}} />
<div className="btn" onClick={this.onChooseCSV}>
Import CSV
</div>
</div>
)
}
renderDropCover() {
const {isDropping} = this.state
const display = isDropping ? 'block' : 'none';
return (
<div className="composer-drop-cover" style={{display}}>
<div className="centered">
Drop to Import CSV
</div>
</div>
)
}
render() {
const {session, draftClientId, isWorkspaceOpen, tableDataSource, selection, ...otherProps} = this.props
if (!isWorkspaceOpen) {
return false
}
return (
<DropZone
className="mail-merge-workspace"
onDrop={this.onDropCSV}
shouldAcceptDrop={this.shouldAcceptDrop}
onDragStateChange={this.onDragStateChange}
>
<style>
{".btn-send-later { display:none; }"}
</style>
{this.renderDropCover()}
{this.renderSelectionControls()}
<MailMergeTable
{...otherProps}
selection={selection}
tableDataSource={tableDataSource}
draftClientId={draftClientId}
onCellEdited={session.updateCell}
onSetSelection={session.setSelection}
onShiftSelection={session.shiftSelection}
onAddColumn={session.addColumn}
onRemoveColumn={session.removeLastColumn}
onAddRow={session.addRow}
onRemoveRow={session.removeRow}
/>
</DropZone>
)
}
}
export default MailMergeWorkspace

View file

@ -0,0 +1,53 @@
import {
TaskRegistry,
ExtensionRegistry,
ComponentRegistry,
CustomContenteditableComponents,
} from 'nylas-exports'
import MailMergeButton from './mail-merge-button'
import MailMergeContainer from './mail-merge-container'
import SendManyDraftsTask from './send-many-drafts-task'
import MailMergeSendButton from './mail-merge-send-button'
import * as ComposerExtension from './mail-merge-composer-extension'
import MailMergeSubjectTextField from './mail-merge-subject-text-field'
import MailMergeBodyToken from './mail-merge-body-token'
import MailMergeParticipantsTextField from './mail-merge-participants-text-field'
export function activate() {
TaskRegistry.register('SendManyDraftsTask', () => SendManyDraftsTask)
ComponentRegistry.register(MailMergeContainer,
{role: 'Composer:ActionBarWorkspace'});
ComponentRegistry.register(MailMergeButton,
{role: 'Composer:ActionButton'});
ComponentRegistry.register(MailMergeSendButton,
{role: 'Composer:SendActionButton'});
ComponentRegistry.register(MailMergeParticipantsTextField,
{role: 'Composer:ParticipantsTextField'});
ComponentRegistry.register(MailMergeSubjectTextField,
{role: 'Composer:SubjectTextField'});
CustomContenteditableComponents.register('MailMergeBodyToken', MailMergeBodyToken)
ExtensionRegistry.Composer.register(ComposerExtension)
}
export function deactivate() {
TaskRegistry.unregister('SendManyDraftsTask')
ComponentRegistry.unregister(MailMergeContainer)
ComponentRegistry.unregister(MailMergeButton)
ComponentRegistry.unregister(MailMergeSendButton)
ComponentRegistry.unregister(MailMergeParticipantsTextField)
ComponentRegistry.unregister(MailMergeSubjectTextField)
CustomContenteditableComponents.unregister('MailMergeBodyToken');
ExtensionRegistry.Composer.unregister(ComposerExtension)
}
export function serialize() {
}

View file

@ -0,0 +1,122 @@
import _ from 'underscore'
import {MAX_ROWS} from './mail-merge-constants'
export function initialState(savedState) {
if (savedState && savedState.tableDataSource) {
return {
selection: {
rowIdx: 0,
colIdx: 0,
key: null,
},
}
}
return {
selection: {
rowIdx: 0,
colIdx: 0,
key: 'Enter',
},
}
}
export function clearTableData() {
return {
selection: {
rowIdx: 0,
colIdx: 0,
key: null,
},
}
}
export function loadTableData() {
return {
selection: {
rowIdx: 0,
colIdx: 0,
key: null,
},
}
}
export function addColumn({selection, tableDataSource}) {
const columns = tableDataSource.columns()
return {
selection: {
...selection,
rowIdx: null,
colIdx: columns.length,
key: 'Enter',
},
}
}
export function removeLastColumn({selection, tableDataSource}) {
const columns = tableDataSource.columns()
const nextSelection = {...selection, key: null}
if (nextSelection.colIdx === columns.length - 1) {
nextSelection.colIdx--
}
return {selection: nextSelection}
}
export function addRow({selection, tableDataSource}, {maxRows = MAX_ROWS} = {}) {
const rows = tableDataSource.rows()
if (rows.length === maxRows) {
return {selection}
}
return {
selection: {
...selection,
rowIdx: rows.length,
key: 'Enter',
},
}
}
export function removeRow({selection, tableDataSource}) {
const rows = tableDataSource.rows()
const nextSelection = {...selection, key: null}
if (nextSelection.rowIdx === rows.length - 1) {
nextSelection.rowIdx--
}
return {selection: nextSelection}
}
export function updateCell({selection}) {
return {
selection: {...selection, key: null},
}
}
export function setSelection({selection}, nextSelection) {
if (_.isEqual(selection, nextSelection)) {
return {selection}
}
return {
selection: {...nextSelection},
}
}
function shift(len, idx, delta = 0) {
const idxVal = idx != null ? idx : -1
return Math.min(len - 1, Math.max(0, idxVal + delta))
}
export function shiftSelection({tableDataSource, selection}, deltas) {
const rowLen = tableDataSource.rows().length
const colLen = tableDataSource.columns().length
const nextSelection = {
rowIdx: shift(rowLen, selection.rowIdx, deltas.row),
colIdx: shift(colLen, selection.colIdx, deltas.col),
key: deltas.key,
}
return setSelection({selection}, nextSelection)
}

View file

@ -0,0 +1,171 @@
import {
Task,
Actions,
Message,
TaskQueue,
DraftStore,
BaseDraftTask,
SendDraftTask,
SoundRegistry,
DatabaseStore,
TaskQueueStatusStore,
} from 'nylas-exports'
import {PLUGIN_ID} from './mail-merge-constants'
const SEND_DRAFT_THROTTLE = 500
export default class SendManyDraftsTask extends Task {
constructor(baseDraftClientId, draftIdsToSend = []) {
super()
this.baseDraftClientId = baseDraftClientId
this.draftIdsToSend = draftIdsToSend
this.queuedDraftIds = new Set()
this.failedDraftIds = []
}
label() {
return `Sending ${this.draftIdsToSend.length} messages`
}
shouldDequeueOtherTask(other) {
return other instanceof SendManyDraftsTask && other.draftClientId === this.baseDraftClientId;
}
isDependentOnTask(other) {
const isSameDraft = other.draftClientId === this.baseDraftClientId;
const isSaveOrSend = other instanceof BaseDraftTask;
return isSameDraft && isSaveOrSend
}
performLocal() {
if (!this.baseDraftClientId) {
const errMsg = `Attempt to call SendManyDraftsTask.performLocal without a baseDraftClientId`;
return Promise.reject(new Error(errMsg));
}
if (this.draftIdsToSend.length === 0) {
const errMsg = `Attempt to call SendManyDraftsTask.performLocal without draftIdsToSend`;
return Promise.reject(new Error(errMsg));
}
return Promise.resolve();
}
performRemote() {
const unqueuedDraftIds = this.draftIdsToSend.filter(id => !this.queuedDraftIds.has(id))
if (unqueuedDraftIds.length > 0) {
return (
DatabaseStore.modelify(Message, unqueuedDraftIds)
.then((draftsToSend) => this.queueSendTasks(draftsToSend))
.then(() => this.waitForSendTasks())
.then(() => this.onTasksProcessed())
.catch((error) => this.handleError(error))
)
}
return (
this.waitForSendTasks()
.then(() => this.onTasksProcessed())
.catch((error) => this.handleError(error))
)
}
queueSendTasks(draftsToSend, throttle = SEND_DRAFT_THROTTLE) {
return Promise.each(draftsToSend, (draft) => {
return new Promise((resolve) => {
const task = new SendDraftTask(draft.clientId, {
playSound: false,
emitError: false,
allowMultiSend: false,
})
Actions.queueTask(task)
this.queuedDraftIds.add(draft.clientId)
setTimeout(resolve, throttle)
})
})
}
waitForSendTasks() {
const waitForTaskPromises = Array.from(this.queuedDraftIds).map((draftClientId) => {
const tasks = TaskQueue.allTasks()
const task = tasks.find((t) => t instanceof SendDraftTask && t.draftClientId === draftClientId)
if (!task) {
console.warn(`SendManyDraftsTask: Can't find queued SendDraftTask for draft id: ${draftClientId}`)
this.queuedDraftIds.delete(draftClientId)
return Promise.resolve()
}
return TaskQueueStatusStore.waitForPerformRemote(task)
.then((completedTask) => {
if (!this.queuedDraftIds.has(completedTask.draftClientId)) { return }
const {status} = completedTask.queueState
if (status === Task.Status.Failed) {
this.failedDraftIds.push(completedTask.draftClientId)
}
this.queuedDraftIds.delete(completedTask.draftClientId)
})
})
return Promise.all(waitForTaskPromises)
}
onTasksProcessed() {
if (this.failedDraftIds.length > 0) {
const error = new Error(
`Sorry, some of your messages failed to send.
This could be due to sending limits imposed by your mail provider.
Please try again after a while. Also make sure your messages are addressed correctly and are not too large.`,
)
return this.handleError(error)
}
Actions.recordUserEvent("Mail Merge Sent", {
numItems: this.draftIdsToSend.length,
numFailedItems: this.failedDraftIds.length,
})
if (NylasEnv.config.get("core.sending.sounds")) {
SoundRegistry.playSound('send');
}
return Promise.resolve(Task.Status.Success)
}
handleError(error) {
return (
DraftStore.sessionForClientId(this.baseDraftClientId)
.then((session) => {
return DatabaseStore.modelify(Message, this.failedDraftIds)
.then((failedDrafts) => {
const failedDraftRowIdxs = failedDrafts.map((draft) => draft.metadataForPluginId(PLUGIN_ID).rowIdx)
const currentMetadata = session.draft().metadataForPluginId(PLUGIN_ID)
const nextMetadata = {
...currentMetadata,
failedDraftRowIdxs,
}
session.changes.addPluginMetadata(PLUGIN_ID, nextMetadata)
return session.changes.commit()
})
})
.then(() => {
this.failedDraftIds.forEach((id) => Actions.destroyDraft(id))
Actions.composePopoutDraft(this.baseDraftClientId, {errorMessage: error.message})
return Promise.resolve([Task.Status.Failed, error])
})
)
}
toJSON() {
const json = {...super.toJSON()}
json.queuedDraftIds = Array.from(json.queuedDraftIds)
return json
}
fromJSON(json) {
const result = super.fromJSON(json)
result.queuedDraftIds = new Set(result.queuedDraftIds)
return result
}
}

View file

@ -0,0 +1,95 @@
import {Table} from 'nylas-component-kit'
import {MAX_ROWS} from './mail-merge-constants'
const {TableDataSource} = Table
export function toJSON({tableDataSource}) {
return {
tableDataSource: tableDataSource.toJSON(),
}
}
export function fromJSON({tableDataSource}) {
return {
tableDataSource: new TableDataSource(tableDataSource),
}
}
export function initialState(savedState) {
if (savedState && savedState.tableDataSource instanceof TableDataSource) {
if (savedState.failedDraftRowIdxs) {
const failedRowIdxs = new Set(savedState.failedDraftRowIdxs)
const dataSource = (
savedState.tableDataSource
.filterRows((row, idx) => failedRowIdxs.has(idx))
)
return {
tableDataSource: dataSource,
}
}
return {
tableDataSource: savedState.tableDataSource,
}
}
return {
tableDataSource: new TableDataSource({
columns: ['email'],
rows: [
[null],
],
}),
}
}
export function clearTableData({tableDataSource}) {
return {
tableDataSource: tableDataSource.clear(),
}
}
export function loadTableData({tableDataSource}, {newTableData}) {
const newRows = newTableData.rows
const newCols = newTableData.columns
if (newRows.length === 0 || newCols.length === 0) {
return initialState()
}
return {
tableDataSource: new TableDataSource(newTableData),
}
}
export function addColumn({tableDataSource}) {
return {
tableDataSource: tableDataSource.addColumn(),
}
}
export function removeLastColumn({tableDataSource}) {
return {
tableDataSource: tableDataSource.removeLastColumn(),
}
}
export function addRow({tableDataSource}, {maxRows = MAX_ROWS} = {}) {
const rows = tableDataSource.rows()
if (rows.length === maxRows) {
return {tableDataSource}
}
return {
tableDataSource: tableDataSource.addRow(),
}
}
export function removeRow({tableDataSource}) {
return {
tableDataSource: tableDataSource.removeRow(),
}
}
export function updateCell({tableDataSource}, {rowIdx, colIdx, isHeader, value}) {
return {
tableDataSource: tableDataSource.updateCell({rowIdx, colIdx, isHeader, value}),
}
}

View file

@ -0,0 +1,114 @@
import _ from 'underscore'
import {Utils} from 'nylas-exports'
class FieldTokens {
constructor(field, tokens = {}) {
this._field = field
this._tokens = tokens
}
linkToken(colProps) {
const tokenId = colProps.tokenId ? colProps.tokenId : Utils.generateTempId()
return new FieldTokens(this._field, {
...this._tokens,
[tokenId]: {...colProps, field: this._field, tokenId},
})
}
unlinkToken(tokenId) {
const nextTokens = {...this._tokens}
delete nextTokens[tokenId]
return new FieldTokens(this._field, nextTokens)
}
updateToken(tokenId, props) {
const token = this._tokens[tokenId]
return new FieldTokens(this._field, {
...this._tokens,
[tokenId]: {...token, ...props},
})
}
tokens() {
return _.values(this._tokens)
}
findTokens(matcher) {
return _.where(this.tokens(), matcher)
}
getToken(tokenId) {
return this._tokens[tokenId]
}
}
class TokenDataSource {
static fromJSON(json) {
return json.reduce((dataSource, token) => {
const {field, ...props} = token
return dataSource.linkToken(field, props)
}, new TokenDataSource())
}
constructor(linkedTokensByField = {}) {
this._linkedTokensByField = linkedTokensByField
}
findTokens(field, matcher) {
if (!this._linkedTokensByField[field]) { return [] }
return this._linkedTokensByField[field].findTokens(matcher)
}
tokensForField(field) {
if (!this._linkedTokensByField[field]) { return [] }
return this._linkedTokensByField[field].tokens()
}
getToken(field, tokenId) {
if (!this._linkedTokensByField[field]) { return null }
return this._linkedTokensByField[field].getToken(tokenId)
}
linkToken(field, props) {
if (!this._linkedTokensByField[field]) {
this._linkedTokensByField[field] = new FieldTokens(field)
}
const current = this._linkedTokensByField[field]
return new TokenDataSource({
...this._linkedTokensByField,
[field]: current.linkToken(props),
})
}
unlinkToken(field, tokenId) {
if (!this._linkedTokensByField[field]) { return this }
const current = this._linkedTokensByField[field]
return new TokenDataSource({
...this._linkedTokensByField,
[field]: current.unlinkToken(tokenId),
})
}
updateToken(field, tokenId, props) {
if (!this._linkedTokensByField[field]) { return this }
const current = this._linkedTokensByField[field]
return new TokenDataSource({
...this._linkedTokensByField,
[field]: current.updateToken(tokenId, props),
})
}
toJSON() {
return Object.keys(this._linkedTokensByField)
.map((field) => this._linkedTokensByField[field])
.reduce((prevTokens, dataSource) => prevTokens.concat(dataSource.tokens()), [])
}
}
export default TokenDataSource

View file

@ -0,0 +1,122 @@
import {contactFromColIdx} from './mail-merge-utils'
import TokenDataSource from './token-data-source'
import {LinkableFields, ContenteditableFields, ParticipantFields} from './mail-merge-constants'
export function toDraftChanges(draft, {tableDataSource, selection, tokenDataSource}) {
// Save the participant fields to fake Contacts
const participantChanges = {}
ParticipantFields.forEach((field) => (
participantChanges[field] = tokenDataSource.tokensForField(field).map(({colIdx}) => {
const selectionValue = tableDataSource.cellAt({rowIdx: selection.rowIdx, colIdx}) || ""
return contactFromColIdx(colIdx, selectionValue.trim())
})
))
// Save the body and subject if they haven't been saved yet
// This is necessary because new tokens wont be saved to the contenteditable
// unless the user directly mutates the body or subject
const contenteditableChanges = {}
ContenteditableFields.forEach((field) => {
const node = document.querySelector(`.${field}-field [contenteditable]`)
if (node) {
const latestValue = node.innerHTML
if (draft[field] !== latestValue) {
contenteditableChanges[field] = latestValue
}
}
})
return {...participantChanges, ...contenteditableChanges}
}
export function toJSON({tokenDataSource}) {
return {tokenDataSource: tokenDataSource.toJSON()}
}
export function fromJSON({tokenDataSource}) {
return {tokenDataSource: TokenDataSource.fromJSON(tokenDataSource)}
}
export function initialState(savedData) {
if (savedData && savedData.tokenDataSource) {
return {
tokenDataSource: savedData.tokenDataSource,
}
}
const tokenDataSource = new TokenDataSource()
return { tokenDataSource }
}
export function loadTableData({tokenDataSource}, {newTableData}) {
const nextColumns = newTableData.columns
let nextTokenDataSource = new TokenDataSource()
// When loading table data, if the new table data contains columns with the same
// name, make sure to keep those tokens in our state with the updated position
// of the column
LinkableFields.forEach((field) => {
const currentTokens = tokenDataSource.tokensForField(field)
currentTokens.forEach((link) => {
const {colName, ...props} = link
const newColIdx = nextColumns.indexOf(colName)
if (newColIdx !== -1) {
nextTokenDataSource = nextTokenDataSource.linkToken(field, {
...props,
colName,
colIdx: newColIdx,
})
}
})
})
return {tokenDataSource: nextTokenDataSource}
}
export function linkToDraft({tokenDataSource}, args) {
const {colIdx, colName, field, ...props} = args
if (!field) { throw new Error('MailMerge: Must provide `field` to `linkToDraft`') }
if (!colIdx) { throw new Error('MailMerge: Must provide `colIdx` to `linkToDraft`') }
if (colName == null) { throw new Error('MailMerge: Must provide `colName` to `linkToDraft`') }
return {
tokenDataSource: tokenDataSource.linkToken(field, {colIdx, colName, ...props}),
}
}
export function unlinkFromDraft({tokenDataSource}, {field, tokenId}) {
if (!field) { throw new Error('MailMerge: Must provide `field` to `linkToDraft`') }
if (!tokenId) { throw new Error('MailMerge: Must provide `tokenId` to `linkToDraft`') }
return {
tokenDataSource: tokenDataSource.unlinkToken(field, tokenId),
}
}
export function removeLastColumn({tokenDataSource, tableDataSource}) {
const colIdx = tableDataSource.columns().length - 1
const colName = tableDataSource.colAt(colIdx)
let nextTokenDataSource = tokenDataSource
// Unlink any fields that where linked to the column that is being removed
LinkableFields.forEach((field) => {
const tokensToRemove = tokenDataSource.findTokens(field, {colName})
nextTokenDataSource = tokensToRemove.reduce((prevTokenDataSource, {tokenId}) => {
return prevTokenDataSource.unlinkToken(field, tokenId)
}, nextTokenDataSource)
})
return {tokenDataSource: nextTokenDataSource}
}
export function updateCell({tokenDataSource, tableDataSource}, {colIdx, isHeader, value}) {
if (!isHeader) { return {tokenDataSource} }
const currentColName = tableDataSource.colAt(colIdx)
let nextTokenDataSource = tokenDataSource
// Update any tokens that referenced the column name that is being updated
LinkableFields.forEach((field) => {
const tokens = tokenDataSource.findTokens(field, {colName: currentColName})
tokens.forEach(({tokenId}) => {
nextTokenDataSource = nextTokenDataSource.updateToken(field, tokenId, {colName: value})
})
})
return {tokenDataSource: nextTokenDataSource}
}

View file

@ -0,0 +1,14 @@
export function initialState(savedData) {
if (savedData && savedData.tokenDataSource && savedData.tableDataSource) {
return {
isWorkspaceOpen: true,
}
}
return {
isWorkspaceOpen: false,
}
}
export function toggleWorkspace({isWorkspaceOpen}) {
return {isWorkspaceOpen: !isWorkspaceOpen}
}

View file

@ -0,0 +1,24 @@
{
"name": "composer-mail-merge",
"title":"Mail Merge",
"description": "Send personalized emails at scale using CSV-formatted data.",
"main": "./lib/main",
"isHiddenOnPluginsPage": true,
"version": "0.1.0",
"engines": {
"nylas": "*"
},
"icon": "./icon.png",
"isOptional": true,
"supportedEnvs": ["production", "staging"],
"windowTypes": {
"default": true,
"composer": true,
"work": true,
"thread-popout": true
},
"license": "GPL-3.0",
"dependencies": {
"papaparse": "^4.1.2"
}
}

View file

@ -0,0 +1,38 @@
import {Table} from 'nylas-component-kit'
import TokenDataSource from '../lib/token-data-source'
const {TableDataSource} = Table
export const testData = {
columns: ['name', 'email'],
rows: [
['donald', 'donald@nylas.com'],
['hilary', 'hilary@nylas.com'],
],
}
export const testDataSource = new TableDataSource(testData)
export const testSelection = {rowIdx: 1, colIdx: 0, key: 'Enter'}
export const testTokenDataSource =
new TokenDataSource()
.linkToken('to', {colName: 'name', colIdx: 0, tokenId: 'name-0'})
.linkToken('bcc', {colName: 'email', colIdx: 1, tokenId: 'email-1'})
export const testState = {
isWorkspaceOpen: true,
selection: testSelection,
tableDataSource: testDataSource,
tokenDataSource: testTokenDataSource,
}
export const testAnchorMarkup = (tokenId) => {
return `<img class="n1-overlaid-component-anchor-container mail-merge-token-wrap" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-overlay-id="${tokenId}" data-component-props="{&quot;field&quot;:&quot;subject&quot;,&quot;colIdx&quot;:&quot;0&quot;,&quot;colName&quot;:&quot;email&quot;,&quot;draftClientId&quot;:&quot;local-0cab45d1-c763&quot;,&quot;className&quot;:&quot;mail-merge-token-wrap&quot;}" data-component-key="MailMergeBodyToken" style="width: 132.156px; height: 21px;">`
}
export const testContenteditableContent = () => {
const nameSpan = testAnchorMarkup('name-anchor')
const emailSpan = testAnchorMarkup('email-anchor')
return `<div>${nameSpan}<br>stuff${emailSpan}</div>`
}

View file

@ -0,0 +1,108 @@
import {Message} from 'nylas-exports'
import {MailMergeDraftEditingSession} from '../lib/mail-merge-draft-editing-session'
const testReducers = [
{testAction: state => ({...state, val1: 'reducer1'})},
{testAction: state => ({...state, val2: 'reducer2'})},
]
const draftModel = new Message()
const draftSess = {
draft() { return draftModel },
}
describe('MailMergeDraftEditingSession', function describeBlock() {
let mailMergeSess;
beforeEach(() => {
mailMergeSess = new MailMergeDraftEditingSession(draftSess, testReducers)
});
describe('dispatch', () => {
it('computes next state correctly based on registered reducers', () => {
const nextState = mailMergeSess.dispatch({name: 'testAction'}, {})
expect(nextState).toEqual({
val1: 'reducer1',
val2: 'reducer2',
})
});
it('computes state value for key correctly when 2 reducers ', () => {
const reducers = testReducers.concat([
{testAction: state => ({...state, val2: 'reducer3'})},
])
mailMergeSess = new MailMergeDraftEditingSession(draftSess, reducers)
const nextState = mailMergeSess.dispatch({name: 'testAction'}, {})
expect(nextState).toEqual({
val1: 'reducer1',
val2: 'reducer3',
})
});
it('passes arguments correctly to reducers', () => {
const args = ['arg1']
const reducers = testReducers.concat([
{testAction: (state, arg) => ({...state, val3: arg})},
])
mailMergeSess = new MailMergeDraftEditingSession(draftSess, reducers)
const nextState = mailMergeSess.dispatch({name: 'testAction', args}, {})
expect(nextState).toEqual({
val1: 'reducer1',
val2: 'reducer2',
val3: 'arg1',
})
});
});
describe('initializeState', () => {
it('loads any saved metadata on the draft', () => {
const savedMetadata = {
tableDataSource: {},
tokenDataSource: {},
}
const nextState = {next: 'state'}
spyOn(draftModel, 'metadataForPluginId').andReturn(savedMetadata)
spyOn(mailMergeSess, 'dispatch').andReturn(nextState)
mailMergeSess.initializeState(draftModel)
expect(mailMergeSess.dispatch.calls.length).toBe(2)
const args1 = mailMergeSess.dispatch.calls[0].args
const args2 = mailMergeSess.dispatch.calls[1].args
expect(args1).toEqual([{name: 'fromJSON'}, savedMetadata])
expect(args2).toEqual([{name: 'initialState'}, nextState])
expect(mailMergeSess._state).toEqual(nextState)
});
it('does not laod saved metadata if saved metadata is incorrect', () => {
const savedMetadata = {
tableDataSource: {},
}
const nextState = {next: 'state'}
spyOn(draftModel, 'metadataForPluginId').andReturn(savedMetadata)
spyOn(mailMergeSess, 'dispatch').andReturn(nextState)
mailMergeSess.initializeState(draftModel)
expect(mailMergeSess.dispatch.calls.length).toBe(1)
const {args} = mailMergeSess.dispatch.calls[0]
expect(args).toEqual([{name: 'initialState'}])
expect(mailMergeSess._state).toEqual(nextState)
});
it('just loads initial state if no metadata is saved on the draft', () => {
const savedMetadata = {}
const nextState = {next: 'state'}
spyOn(draftModel, 'metadataForPluginId').andReturn(savedMetadata)
spyOn(mailMergeSess, 'dispatch').andReturn(nextState)
mailMergeSess.initializeState(draftModel)
expect(mailMergeSess.dispatch.calls.length).toBe(1)
const {args} = mailMergeSess.dispatch.calls[0]
expect(args).toEqual([{name: 'initialState'}])
expect(mailMergeSess._state).toEqual(nextState)
});
});
});

View file

@ -0,0 +1,229 @@
import Papa from 'papaparse'
import {
Message,
Contact,
DraftHelpers,
Actions,
DatabaseTransaction,
} from 'nylas-exports';
import {DataTransferTypes} from '../lib/mail-merge-constants'
import SendManyDraftsTask from '../lib/send-many-drafts-task'
import {
parseCSV,
buildDraft,
sendManyDrafts,
contactFromColIdx,
} from '../lib/mail-merge-utils'
import {
testData,
testDataSource,
testAnchorMarkup,
testContenteditableContent,
} from './fixtures'
import TokenDataSource from '../lib/token-data-source'
xdescribe('MailMergeUtils', function describeBlock() {
describe('contactFromColIdx', () => {
it('creates a contact with the correct values', () => {
const email = 'email@email.com'
const contact = contactFromColIdx(0, email)
expect(contact instanceof Contact).toBe(true)
expect(contact.email).toBe(email)
expect(contact.name).toBe(email)
expect(contact.clientId).toBe(`${DataTransferTypes.ColIdx}:0`)
});
});
describe('buildDraft', () => {
beforeEach(() => {
this.baseDraft = new Message({
draft: true,
clientId: 'd1',
subject: `<div>Your email is: ${testAnchorMarkup('subject-email-anchor')}`,
body: testContenteditableContent(),
})
this.tokenDataSource = new TokenDataSource()
.linkToken('to', {colName: 'email', colIdx: 1, tokenId: 'email-0'})
.linkToken('bcc', {colName: 'email', colIdx: 1, tokenId: 'email-1'})
.linkToken('body', {colName: 'name', colIdx: 0, tokenId: 'name-anchor'})
.linkToken('body', {colName: 'email', colIdx: 1, tokenId: 'email-anchor'})
.linkToken('subject', {colName: 'email', colIdx: 1, tokenId: 'subject-email-anchor'})
});
it('creates a draft with the correct subject based on linked columns and rowIdx', () => {
const draft = buildDraft(this.baseDraft, {
rowIdx: 1,
tableDataSource: testDataSource,
tokenDataSource: this.tokenDataSource,
})
expect(draft.subject).toEqual('Your email is: hilary@nylas.com')
});
it('creates a draft with the correct body based on linked columns and rowIdx', () => {
const draft = buildDraft(this.baseDraft, {
rowIdx: 1,
tableDataSource: testDataSource,
tokenDataSource: this.tokenDataSource,
})
expect(draft.body).toEqual('<div><span>hilary</span><br>stuff<span>hilary@nylas.com</span></div>')
});
it('creates a draft with the correct participants based on linked columns and rowIdx', () => {
const draft = buildDraft(this.baseDraft, {
rowIdx: 1,
tableDataSource: testDataSource,
tokenDataSource: this.tokenDataSource,
})
expect(draft.to[0].email).toEqual('hilary@nylas.com')
expect(draft.bcc[0].email).toEqual('hilary@nylas.com')
});
it('throws error if value for participant field in invalid email address', () => {
this.tokenDataSource = this.tokenDataSource.updateToken('to', 'email-0', {colName: 'name', colIdx: 0})
expect(() => {
buildDraft(this.baseDraft, {
rowIdx: 1,
tableDataSource: testDataSource,
tokenDataSource: this.tokenDataSource,
})
}).toThrow()
});
});
describe('sendManyDrafts', () => {
beforeEach(() => {
this.baseDraft = new Message({
draft: true,
accountId: '123',
serverId: '111',
clientId: 'local-111',
})
this.drafts = [
new Message({draft: true, clientId: 'local-d1'}),
new Message({draft: true, clientId: 'local-d2'}),
new Message({draft: true, clientId: 'local-d3'}),
]
this.draftSession = {
ensureCorrectAccount: jasmine.createSpy('ensureCorrectAccount').andCallFake(() => {
return Promise.resolve()
}),
}
this.session = {
draftSession: () => this.draftSession,
draft: () => this.baseDraft,
}
spyOn(DraftHelpers, 'applyExtensionTransforms').andCallFake((d) => {
const transformed = d.clone()
transformed.body = 'transformed'
return Promise.resolve(transformed)
})
spyOn(DatabaseTransaction.prototype, 'persistModels').andReturn(Promise.resolve())
spyOn(Actions, 'queueTask')
spyOn(Actions, 'queueTasks')
spyOn(NylasEnv.config, 'get').andReturn(false)
spyOn(NylasEnv, 'close')
})
it('ensures account is correct', () => {
waitsForPromise(() => {
return sendManyDrafts(this.session, this.drafts)
.then(() => {
expect(this.draftSession.ensureCorrectAccount).toHaveBeenCalled()
})
})
});
it('applies extension transforms to each draft and saves them', () => {
waitsForPromise(() => {
return sendManyDrafts(this.session, this.drafts)
.then(() => {
const transformedDrafts = DatabaseTransaction.prototype.persistModels.calls[0].args[0]
expect(transformedDrafts.length).toBe(3)
transformedDrafts.forEach((d) => {
expect(d.body).toBe('transformed')
expect(d.accountId).toBe('123')
expect(d.serverId).toBe(null)
})
})
})
});
it('queues the correct task', () => {
waitsForPromise(() => {
return sendManyDrafts(this.session, this.drafts)
.then(() => {
const task = Actions.queueTask.calls[0].args[0]
expect(task instanceof SendManyDraftsTask).toBe(true)
expect(task.baseDraftClientId).toBe('local-111')
expect(task.draftIdsToSend).toEqual(['local-d1', 'local-d2', 'local-d3'])
})
})
});
});
describe('parseCSV', () => {
beforeEach(() => {
spyOn(NylasEnv, 'showErrorDialog')
});
it('shows error when csv file is empty', () => {
spyOn(Papa, 'parse').andCallFake((file, {complete}) => {
complete({data: []})
})
waitsForPromise(() => {
return parseCSV()
.then((data) => {
expect(NylasEnv.showErrorDialog).toHaveBeenCalled()
expect(data).toBe(null)
})
})
});
it('returns the correct table data', () => {
spyOn(Papa, 'parse').andCallFake((file, {complete}) => {
complete({data: [testData.columns].concat(testData.rows)})
})
waitsForPromise(() => {
return parseCSV()
.then((data) => {
expect(data).toEqual(testData)
})
})
});
it('adds a header row if the first row contains a value that resembles an email', () => {
spyOn(Papa, 'parse').andCallFake((file, {complete}) => {
complete({data: [...testData.rows]})
})
waitsForPromise(() => {
return parseCSV()
.then((data) => {
expect(data).toEqual({
columns: ['Column 0', 'Email Address'],
rows: testData.rows,
})
})
})
});
it('only imports MAX_ROWS number of rows', () => {
spyOn(Papa, 'parse').andCallFake((file, {complete}) => {
complete({
data: [testData.columns].concat([...testData.rows, ['extra', 'col@email.com']]),
})
})
waitsForPromise(() => {
return parseCSV(null, 2)
.then((data) => {
expect(data.rows.length).toBe(2)
expect(data).toEqual(testData)
expect(NylasEnv.showErrorDialog).toHaveBeenCalled()
})
})
});
});
});

View file

@ -0,0 +1,182 @@
import {
clearTableData,
loadTableData,
addColumn,
removeLastColumn,
addRow,
removeRow,
updateCell,
setSelection,
shiftSelection,
} from '../lib/selection-state-reducers'
import {testState, testSelection} from './fixtures'
describe('SelectionStateReducers', function describeBlock() {
describe('clearTableData', () => {
it('sets selection correctly', () => {
const {selection} = clearTableData()
expect(selection).toEqual({
rowIdx: 0,
colIdx: 0,
key: null,
})
});
});
describe('loadTableData', () => {
it('sets selection correctly', () => {
const {selection} = loadTableData()
expect(selection).toEqual({
rowIdx: 0,
colIdx: 0,
key: null,
})
});
});
describe('addColumn', () => {
it('sets selection to the header and last column', () => {
const {selection} = addColumn(testState)
expect(selection).toEqual({rowIdx: null, colIdx: 2, key: 'Enter'})
});
});
describe('removeLastColumn', () => {
it('only sets key to null if selection is not in last column', () => {
const {selection} = removeLastColumn(testState)
expect(selection).toEqual({...testSelection, key: null})
});
it('decreases col selection by 1 if selection is currently in last column', () => {
const {selection} = removeLastColumn({...testState, selection: {rowIdx: 1, colIdx: 1, key: 'Enter'}})
expect(selection).toEqual({rowIdx: 1, colIdx: 0, key: null})
});
});
describe('addRow', () => {
it('does nothing if MAX_ROWS reached', () => {
const {selection} = addRow(testState, {maxRows: 2})
expect(selection).toBe(testSelection)
});
it('sets selection to last row', () => {
const {selection} = addRow(testState, {maxRows: 3})
expect(selection).toEqual({rowIdx: 2, colIdx: 0, key: 'Enter'})
});
});
describe('removeRow', () => {
it('only sets key to null if selection is not in last row', () => {
const {selection} = removeRow(testState)
expect(selection).toEqual({...testSelection, rowIdx: 0, key: null})
});
it('decreases row selection by 1 if selection is currently in last row', () => {
const {selection} = removeRow({...testState, selection: {rowIdx: 1, colIdx: 1, key: 'Enter'}})
expect(selection).toEqual({rowIdx: 0, colIdx: 1, key: null})
});
});
describe('updateCell', () => {
it('sets selection key to null (wont make input focus)', () => {
const {selection} = updateCell(testState)
expect(selection.key).toBe(null)
});
});
describe('setSelection', () => {
it('sets the selection to the given selection if selection has changed', () => {
const {selection} = setSelection(testState, {rowIdx: 1, colIdx: 1, key: null})
expect(selection).toEqual({rowIdx: 1, colIdx: 1, key: null})
});
it('returns same selection otherwise', () => {
const {selection} = setSelection(testState, {...testSelection})
expect(selection).toBe(testSelection)
});
});
describe('shiftSelection', () => {
it('sets the given key', () => {
const {selection} = shiftSelection(testState, {row: 0, col: 0, key: null})
expect(selection.key).toBe(null)
});
it('shifts row selection correctly when rowIdx is null (header)', () => {
let nextSelection = shiftSelection({
...testState,
selection: {rowIdx: null, col: 0},
}, {row: 1}).selection
expect(nextSelection.rowIdx).toBe(0)
nextSelection = shiftSelection({
...testState,
selection: {rowIdx: null, col: 0},
}, {row: 2}).selection
expect(nextSelection.rowIdx).toBe(1)
nextSelection = shiftSelection({
...testState,
selection: {rowIdx: null, col: 0},
}, {row: -1}).selection
expect(nextSelection.rowIdx).toBe(0)
});
it('shifts row selection by correct value', () => {
let nextState = shiftSelection(
testState,
{row: -1}
)
expect(nextState.selection.rowIdx).toBe(0)
nextState = shiftSelection(
{...testState, selection: {rowIdx: 0, colIdx: 0, key: 'Enter'}},
{row: 1}
)
expect(nextState.selection.rowIdx).toBe(1)
});
it('does not shift row selection when at the edges', () => {
let nextState = shiftSelection(
testState,
{row: 2}
)
expect(nextState.selection.rowIdx).toBe(1)
nextState = shiftSelection(
{...testState, selection: {rowIdx: 0, colIdx: 0, key: 'Enter'}},
{row: -2}
)
expect(nextState.selection.rowIdx).toBe(0)
});
it('shifts col selection by correct value', () => {
let nextState = shiftSelection(
testState,
{col: 1}
)
expect(nextState.selection.colIdx).toBe(1)
nextState = shiftSelection(
{...testState, selection: {rowIdx: 0, colIdx: 1, key: 'Enter'}},
{col: -1}
)
expect(nextState.selection.colIdx).toBe(0)
});
it('does not shift col selection when at the edges', () => {
let nextState = shiftSelection(
testState,
{col: -2}
)
expect(nextState.selection.colIdx).toBe(0)
nextState = shiftSelection(
{...testState, selection: {rowIdx: 0, colIdx: 1, key: 'Enter'}},
{col: 2}
)
expect(nextState.selection.colIdx).toBe(1)
});
});
});

View file

@ -0,0 +1,251 @@
import {
Task,
Actions,
Message,
TaskQueue,
DraftStore,
DatabaseStore,
SendDraftTask,
TaskQueueStatusStore,
} from 'nylas-exports'
import SendManyDraftsTask from '../lib/send-many-drafts-task'
import {PLUGIN_ID} from '../lib/mail-merge-constants'
xdescribe('SendManyDraftsTask', function describeBlock() {
beforeEach(() => {
this.baseDraft = new Message({
clientId: 'baseId',
files: ['f1', 'f2'],
uploads: [],
})
this.d1 = new Message({
clientId: 'd1',
uploads: ['u1'],
})
this.d2 = new Message({
clientId: 'd2',
})
this.task = new SendManyDraftsTask('baseId', ['d1', 'd2'])
spyOn(DatabaseStore, 'modelify').andReturn(Promise.resolve([this.baseDraft, this.d1, this.d2]))
spyOn(DatabaseStore, 'inTransaction').andCallFake((cb) => {
return cb({persistModels() { return Promise.resolve() }})
})
});
describe('performRemote', () => {
beforeEach(() => {
spyOn(this.task, 'prepareDraftsToSend').andCallFake((baseId, draftIds) => {
return Promise.resolve(draftIds.map(id => this[id]))
})
spyOn(this.task, 'queueSendTasks').andReturn(Promise.resolve())
spyOn(this.task, 'waitForSendTasks').andReturn(Promise.resolve())
spyOn(this.task, 'onTasksProcessed')
spyOn(this.task, 'handleError').andCallFake((error) =>
Promise.resolve([Task.Status.Failed, error])
)
});
it('queues all drafts for sending when no tasks have been queued yet', () => {
waitsForPromise(() => {
return this.task.performRemote()
.then(() => {
expect(this.task.prepareDraftsToSend).toHaveBeenCalledWith('baseId', ['d1', 'd2'])
expect(this.task.queueSendTasks).toHaveBeenCalledWith([this.d1, this.d2])
expect(this.task.waitForSendTasks).toHaveBeenCalled()
})
})
});
it('only queues drafts that have not been queued for sending', () => {
this.task.queuedDraftIds = new Set(['d1'])
waitsForPromise(() => {
return this.task.performRemote()
.then(() => {
expect(this.task.prepareDraftsToSend).toHaveBeenCalledWith('baseId', ['d2'])
expect(this.task.queueSendTasks).toHaveBeenCalledWith([this.d2])
expect(this.task.waitForSendTasks).toHaveBeenCalled()
})
})
});
it('only waits for tasks to complete when all drafts have been queued for sending', () => {
this.task.queuedDraftIds = new Set(['d1', 'd2'])
waitsForPromise(() => {
return this.task.performRemote()
.then(() => {
expect(this.task.prepareDraftsToSend).not.toHaveBeenCalled()
expect(this.task.queueSendTasks).not.toHaveBeenCalled()
expect(this.task.waitForSendTasks).toHaveBeenCalled()
})
})
});
it('handles errors', () => {
jasmine.unspy(this.task, 'onTasksProcessed')
spyOn(this.task, 'onTasksProcessed').andReturn(Promise.reject(new Error('Oh no!')))
this.task.queuedDraftIds = new Set(['d1', 'd2'])
waitsForPromise(() => {
return this.task.performRemote()
.then(() => {
expect(this.task.handleError).toHaveBeenCalled()
})
})
});
});
describe('prepareDraftsToSend', () => {
it('updates the files and uploads on each draft to send', () => {
waitsForPromise(() => {
return this.task.prepareDraftsToSend('baseId', ['d1', 'd2'])
.then((draftsToSend) => {
expect(DatabaseStore.modelify).toHaveBeenCalledWith(Message, ['baseId', 'd1', 'd2'])
expect(draftsToSend.length).toBe(2)
expect(draftsToSend[0].files).toEqual(this.baseDraft.files)
expect(draftsToSend[0].uploads).toEqual([])
expect(draftsToSend[1].files).toEqual(this.baseDraft.files)
expect(draftsToSend[1].uploads).toEqual([])
})
})
});
});
describe('queueSendTasks', () => {
beforeEach(() => {
spyOn(Actions, 'queueTask')
});
it('queues SendDraftTask for all passed in drafts', () => {
waitsForPromise(() => {
const promise = this.task.queueSendTasks([this.d1, this.d2], 0)
advanceClock(1)
advanceClock(1)
return promise.then(() => {
expect(Actions.queueTask.calls.length).toBe(2)
expect(Array.from(this.task.queuedDraftIds)).toEqual(['d1', 'd2'])
Actions.queueTask.calls.forEach(({args}, idx) => {
const task = args[0]
expect(task instanceof SendDraftTask).toBe(true)
expect(task.draftClientId).toEqual(`d${idx + 1}`)
})
})
})
});
});
describe('waitForSendTasks', () => {
it('it updates queuedDraftIds and warns if there are no tasks matching the draft client id', () => {
this.task.queuedDraftIds = new Set(['d2'])
spyOn(TaskQueue, 'allTasks').andReturn([])
spyOn(console, 'warn')
waitsForPromise(() => {
return this.task.waitForSendTasks()
.then(() => {
expect(this.task.queuedDraftIds.size).toBe(0)
expect(console.warn).toHaveBeenCalled()
})
})
});
it('resolves when all queued tasks complete', () => {
this.task.queuedDraftIds = new Set(['d2'])
spyOn(TaskQueue, 'allTasks').andReturn([new SendDraftTask('d2')])
spyOn(TaskQueueStatusStore, 'waitForPerformRemote').andCallFake((task) => {
task.queueState.status = Task.Status.Success
return Promise.resolve(task)
})
waitsForPromise(() => {
return this.task.waitForSendTasks()
.then(() => {
expect(Array.from(this.task.queuedDraftIds)).toEqual([])
expect(this.task.failedDraftIds).toEqual([])
})
})
});
it('saves any draft ids of drafts that failed to send', () => {
this.task.queuedDraftIds = new Set(['d1', 'd2'])
spyOn(TaskQueue, 'allTasks').andReturn([new SendDraftTask('d1'), new SendDraftTask('d2')])
spyOn(TaskQueueStatusStore, 'waitForPerformRemote').andCallFake((task) => {
if (task.draftClientId === 'd1') {
task.queueState.status = Task.Status.Failed
} else {
task.queueState.status = Task.Status.Success
}
return Promise.resolve(task)
})
waitsForPromise(() => {
return this.task.waitForSendTasks()
.then(() => {
expect(Array.from(this.task.queuedDraftIds)).toEqual([])
expect(this.task.failedDraftIds).toEqual(['d1'])
})
})
});
});
describe('handleError', () => {
beforeEach(() => {
this.baseDraft.applyPluginMetadata(PLUGIN_ID, {tableDataSource: {}})
this.d1.applyPluginMetadata(PLUGIN_ID, {rowIdx: 0})
this.d2.applyPluginMetadata(PLUGIN_ID, {rowIdx: 1})
this.baseSession = {
draft: () => { return this.baseDraft },
changes: {
addPluginMetadata: jasmine.createSpy('addPluginMetadata'),
commit() { return Promise.resolve() },
},
}
this.task.failedDraftIds = ['d1', 'd2']
spyOn(Actions, 'destroyDraft')
spyOn(Actions, 'composePopoutDraft')
spyOn(DraftStore, 'sessionForClientId').andReturn(Promise.resolve(this.baseSession))
jasmine.unspy(DatabaseStore, 'modelify')
spyOn(DatabaseStore, 'modelify').andReturn(Promise.resolve([this.d1, this.d2]))
});
it('correctly saves the failed rowIdxs to the base draft metadata', () => {
waitsForPromise(() => {
return this.task.handleError({message: 'Error!'})
.then((status) => {
expect(status[0]).toBe(Task.Status.Failed)
expect(DatabaseStore.modelify).toHaveBeenCalledWith(Message, this.task.failedDraftIds)
expect(this.baseSession.changes.addPluginMetadata).toHaveBeenCalledWith(PLUGIN_ID, {
tableDataSource: {},
failedDraftRowIdxs: [0, 1],
})
})
})
});
it('correctly destroys failed drafts', () => {
waitsForPromise(() => {
return this.task.handleError({message: 'Error!'})
.then((status) => {
expect(status[0]).toBe(Task.Status.Failed)
expect(Actions.destroyDraft.calls.length).toBe(2)
expect(Actions.destroyDraft.calls[0].args).toEqual(['d1'])
expect(Actions.destroyDraft.calls[1].args).toEqual(['d2'])
})
})
});
it('correctly pops out base composer with error msg', () => {
waitsForPromise(() => {
return this.task.handleError({message: 'Error!'})
.then((status) => {
expect(status[0]).toBe(Task.Status.Failed)
expect(Actions.composePopoutDraft).toHaveBeenCalledWith('baseId', {
errorMessage: 'Error!',
})
})
})
});
});
});

View file

@ -0,0 +1,142 @@
import {
initialState,
fromJSON,
toJSON,
clearTableData,
loadTableData,
addColumn,
removeLastColumn,
addRow,
removeRow,
updateCell,
} from '../lib/table-state-reducers'
import {testData, testDataSource} from './fixtures'
describe('TableStateReducers', function describeBlock() {
describe('initialState', () => {
it('returns correct initial state when there is saved state', () => {
const savedState = {tableDataSource: testDataSource}
expect(initialState(savedState)).toEqual(savedState)
});
it('keeps only rowIdxs that failed if failedRowIdxs present in saved state', () => {
const savedState = {tableDataSource: testDataSource, failedDraftRowIdxs: [1]}
const {tableDataSource} = initialState(savedState)
expect(tableDataSource.rows()).toEqual([testDataSource.rowAt(1)])
});
});
describe('fromJSON', () => {
it('returns correct data source from json table data', () => {
const {tableDataSource} = fromJSON({tableDataSource: testData})
expect(tableDataSource.toJSON()).toEqual(testData)
});
});
describe('toJSON', () => {
it('returns correct json object from data source', () => {
const {tableDataSource} = toJSON({tableDataSource: testDataSource})
expect(tableDataSource).toEqual(testData)
});
});
describe('clearTableData', () => {
it('clears all data correcltly', () => {
const {tableDataSource} = clearTableData({tableDataSource: testDataSource})
expect(tableDataSource.toJSON()).toEqual({
columns: [],
rows: [[]],
})
});
});
describe('loadTableData', () => {
it('loads table data correctly', () => {
const newTableData = {
columns: ['my-col'],
rows: [['my-val']],
}
const {tableDataSource} = loadTableData({tableDataSource: testDataSource}, {newTableData})
expect(tableDataSource.toJSON()).toEqual(newTableData)
});
it('returns initial state if new table data is empty', () => {
const newTableData = {
columns: [],
rows: [[]],
}
const {tableDataSource} = loadTableData({tableDataSource: testDataSource}, {newTableData})
expect(tableDataSource.toJSON()).toEqual(initialState().tableDataSource.toJSON())
});
});
describe('addColumn', () => {
it('pushes a new column to the data source\'s columns', () => {
const {tableDataSource} = addColumn({tableDataSource: testDataSource})
expect(tableDataSource.columns()).toEqual(['name', 'email', null])
});
it('pushes a new column to every row', () => {
const {tableDataSource} = addColumn({tableDataSource: testDataSource})
expect(tableDataSource.rows()).toEqual([
['donald', 'donald@nylas.com', null],
['hilary', 'hilary@nylas.com', null],
])
});
});
describe('removeLastColumn', () => {
it('removes last column from the data source\'s columns', () => {
const {tableDataSource} = removeLastColumn({tableDataSource: testDataSource})
expect(tableDataSource.columns()).toEqual(['name'])
});
it('removes last column from every row', () => {
const {tableDataSource} = removeLastColumn({tableDataSource: testDataSource})
expect(tableDataSource.rows()).toEqual([['donald'], ['hilary']])
});
});
describe('addRow', () => {
it('does nothing if MAX_ROWS reached', () => {
const {tableDataSource} = addRow({tableDataSource: testDataSource}, {maxRows: 2})
expect(tableDataSource).toBe(testDataSource)
});
it('pushes an empty row with correct number of columns', () => {
const {tableDataSource} = addRow({tableDataSource: testDataSource}, {maxRows: 3})
expect(tableDataSource.rows()).toEqual([
['donald', 'donald@nylas.com'],
['hilary', 'hilary@nylas.com'],
[null, null],
])
});
});
describe('removeRow', () => {
it('removes last row', () => {
const {tableDataSource} = removeRow({tableDataSource: testDataSource})
expect(tableDataSource.rows()).toEqual([['donald', 'donald@nylas.com']])
});
});
describe('updateCell', () => {
it('updates cell value correctly when updating a cell that is /not/ a header', () => {
const {tableDataSource} = updateCell({tableDataSource: testDataSource}, {
rowIdx: 0, colIdx: 0, isHeader: false, value: 'new-val',
})
expect(tableDataSource.rows()).toEqual([
['new-val', 'donald@nylas.com'],
['hilary', 'hilary@nylas.com'],
])
});
it('updates cell value correctly when updating a cell that /is/ a header', () => {
const {tableDataSource} = updateCell({tableDataSource: testDataSource}, {
rowIdx: null, colIdx: 0, isHeader: true, value: 'new-val',
})
expect(tableDataSource.columns()).toEqual(['new-val', 'email'])
});
});
});

View file

@ -0,0 +1,155 @@
import {Contact} from 'nylas-exports'
import {
toDraftChanges,
toJSON,
initialState,
loadTableData,
linkToDraft,
unlinkFromDraft,
removeLastColumn,
updateCell,
} from '../lib/token-state-reducers'
import {testState, testTokenDataSource, testData} from './fixtures'
describe('WorkspaceStateReducers', function describeBlock() {
describe('toDraftChanges', () => {
it('returns an object with participant fields populated with the correct Contact objects', () => {
const {to, bcc} = toDraftChanges({}, testState)
expect(to.length).toBe(1)
expect(bcc.length).toBe(1)
const toContact = to[0]
const bccContact = bcc[0]
expect(toContact instanceof Contact).toBe(true)
expect(toContact.email).toEqual('hilary')
expect(bccContact instanceof Contact).toEqual(true)
expect(bccContact.email).toEqual('hilary@nylas.com')
});
});
describe('toJSON', () => {
it('only saves linked fields to json', () => {
expect(toJSON(testState)).toEqual({
tokenDataSource: [
{field: 'to', colName: 'name', colIdx: 0, tokenId: 'name-0'},
{field: 'bcc', colName: 'email', colIdx: 1, tokenId: 'email-1'},
],
})
});
});
describe('initialState', () => {
it('loads saved linked fields correctly when provided', () => {
expect(initialState({tokenDataSource: testTokenDataSource})).toEqual({
tokenDataSource: testTokenDataSource,
})
});
});
describe('loadTableData', () => {
describe('when newTableData contains columns that have already been linked in the prev tableData', () => {
it(`preserves the linked fields for the old columns that are still present
and update the index to the new value in newTableData`, () => {
const newTableData = {
columns: ['email', 'other'],
rows: [
['donald@nylas.com', 'd'],
['john@gmail.com', 'j'],
],
}
const nextState = loadTableData(testState, {newTableData, prevColumns: testData.columns})
expect(nextState.tokenDataSource.toJSON()).toEqual([
{field: 'bcc', colName: 'email', colIdx: 0, tokenId: 'email-1'},
])
});
});
describe('when newTableData only contains new columns', () => {
it('unlinks all fields that are no longer present ', () => {
const newTableData = {
columns: ['other1'],
rows: [
['donald@nylas.com'],
['john@gmail.com'],
],
}
const nextState = loadTableData(testState, {newTableData, prevColumns: testData.columns})
expect(nextState.tokenDataSource.toJSON()).toEqual([])
});
});
});
describe('linkToDraft', () => {
it('adds the new field correctly to tokenDataSource state', () => {
const nextState = linkToDraft(testState, {
colIdx: 1,
colName: 'email',
field: 'body',
name: 'some',
tokenId: 'email-2',
})
expect(nextState.tokenDataSource.toJSON()).toEqual([
{field: 'to', colName: 'name', colIdx: 0, tokenId: 'name-0'},
{field: 'bcc', colName: 'email', colIdx: 1, tokenId: 'email-1'},
{field: 'body', colName: 'email', colIdx: 1, tokenId: 'email-2', name: 'some'},
])
// Check that object ref is updated
expect(testTokenDataSource).not.toBe(nextState.tokenDataSource)
});
it('adds a new link if column has already been linked to that field', () => {
const nextState = linkToDraft(testState, {
colIdx: 1,
colName: 'email',
field: 'bcc',
name: 'some',
tokenId: 'email-2',
})
expect(nextState.tokenDataSource.toJSON()).toEqual([
{field: 'to', colName: 'name', colIdx: 0, tokenId: 'name-0'},
{field: 'bcc', colName: 'email', colIdx: 1, tokenId: 'email-1'},
{field: 'bcc', colName: 'email', colIdx: 1, tokenId: 'email-2', name: 'some'},
])
});
});
describe('unlinkFromDraft', () => {
it('removes field correctly from tokenDataSource state', () => {
const nextState = unlinkFromDraft(testState, {field: 'bcc', tokenId: 'email-1'})
expect(nextState.tokenDataSource.toJSON()).toEqual([
{field: 'to', colName: 'name', colIdx: 0, tokenId: 'name-0'},
])
// Check that object ref is updated
expect(testTokenDataSource).not.toBe(nextState.tokenDataSource)
});
});
describe('removeLastColumn', () => {
it('removes any tokenDataSource that were associated with the removed column', () => {
const nextState = removeLastColumn(testState)
expect(nextState.tokenDataSource.toJSON()).toEqual([
{field: 'to', colName: 'name', colIdx: 0, tokenId: 'name-0'},
])
});
});
describe('updateCell', () => {
it('updates tokenDataSource when a column name (header cell) is updated', () => {
const nextState = updateCell(testState, {colIdx: 0, isHeader: true, value: 'nombre'})
expect(nextState.tokenDataSource.toJSON()).toEqual([
{field: 'to', colName: 'nombre', colIdx: 0, tokenId: 'name-0'},
{field: 'bcc', colName: 'email', colIdx: 1, tokenId: 'email-1'},
])
});
it('does not update tokens state otherwise', () => {
const nextState = updateCell(testState, {colIdx: 0, isHeader: false, value: 'nombre'})
expect(nextState.tokenDataSource).toBe(testTokenDataSource)
});
});
});

View file

@ -0,0 +1,29 @@
import {
initialState,
toggleWorkspace,
} from '../lib/workspace-state-reducers'
import {testState} from './fixtures'
describe('WorkspaceStateReducers', function describeBlock() {
describe('initialState', () => {
it('always opens the workspace if there is saved data', () => {
expect(initialState(testState)).toEqual({
isWorkspaceOpen: true,
})
});
it('defaults to closed', () => {
expect(initialState()).toEqual({
isWorkspaceOpen: false,
})
});
});
describe('toggleWorkspace', () => {
it('toggles workspace worrectly', () => {
expect(toggleWorkspace({isWorkspaceOpen: false})).toEqual({isWorkspaceOpen: true})
expect(toggleWorkspace({isWorkspaceOpen: true})).toEqual({isWorkspaceOpen: false})
});
});
});

View file

@ -0,0 +1,218 @@
@import 'ui-variables';
.mail-merge-workspace {
width: 100%;
height: 250px;
z-index: 1;
border-top: 1px solid lightgrey;
padding: @padding-large-vertical * 1.2 @padding-large-horizontal * 1.2;
.selection-controls {
display: flex;
align-items: center;
color: @text-color-very-subtle;
margin-bottom: @padding-small-horizontal;
.btn.btn-group {
padding: 0;
&:active {
background: initial;
}
}
.btn {
display: flex;
align-items: center;
height: 1.5em;
margin-right: 5px;
color: @text-color-very-subtle;
&:hover {
img {
background-color: @text-color-subtle;
}
}
.btn-prev,.btn-next {
height: 100%;
width: 15px;
img {
background-color: @text-color-very-subtle;
}
&:active {
background: darken(@btn-default-bg-color, 9%);
}
}
.btn-prev img {
transform: rotate(90deg) translate(-9px, -8px);
}
.btn-next img {
transform: rotate(-90deg) translate(9px, 8px);
}
}
}
.mail-merge-table {
height: 90%;
.editable-table-container {
width: 100%;
&>.key-commands-region {
width: initial;
}
}
.nylas-table.editable-table {
max-width: 700px;
font-size: 0.9em;
.table-row-header {
&.selected, th, th.selected {
background: initial;
border: 1px solid lighten(@border-color-secondary, 5%);
}
}
.table-row.table-row-header {
height: 35px;
}
.table-row {
height: 30px;
}
.numbered-cell {
width: 30px;
}
th.table-cell {
.mail-merge-token {
input {
cursor: -webkit-grab;
}
}
}
td.table-cell input {
cursor: default;
}
.table-cell:not(.numbered-cell) {
width: 120px;
}
.header-cell {
display: flex;
align-items: center;
height: 28px;
padding-left: 6px;
}
}
}
}
.generate-token-colors(@n, @i: 0) when (@i =< @n) {
@base-color: hsla(197 + (@i * 20), 58%, 95%, 1);
@text-color: darken(desaturate(@base-color, 28%), 49%);
@border-color: darken(@base-color, 8%);
.token-color-@{i}.mail-merge-token {
background-color: @base-color;
color: @text-color;
border-color: darken(@base-color, 8%);
img {
background-color: @text-color;
}
}
.mail-merge-participants-text-field {
.token.token-color-@{i} {
&.selected,&.dragging {
border-color: @text-color;
}
}
}
.generate-token-colors(@n, (@i + 1));
}
.generate-token-colors(4);
.mail-merge-token {
border: 1px solid;
border-radius: 5px;
padding-left: 7px;
cursor: -webkit-grab;
}
.mail-merge-token-wrap,
.n1-overlaid-component-anchor-container.mail-merge-token-wrap {
margin: 0 1px;
vertical-align: bottom;
.mail-merge-token {
padding: @padding-small-vertical * 0.5 @padding-small-horizontal * 0.5;
padding-right: 2px;
img {
margin-right: 10px;
}
}
}
.header-cell .mail-merge-token {
display: flex;
align-items: center;
height: 22px;
input {
max-width: 90%;
text-align: left;
height: 100%;
line-height: 100%;
color: inherit;
padding-bottom: 2px;
}
}
.mail-merge-participants-text-field {
.token {
box-shadow: none;
padding: 0;
border-radius: 5px;
.mail-merge-token {
display: flex;
align-items: center;
padding: 0 @padding-base-vertical;
font-size: 0.9em;
img {
margin-right: 10px;
}
}
&.selected, &.dragging {
background: none;
border-radius: 5px;
}
}
}
.mail-merge-subject-overlaid {
.toggle-preview {
top: 2px;
right: 22px;
}
}
.mail-merge-subject-text-field {
div[contenteditable] {
line-height: 21px;
font-weight: 400;
padding: 13px 0 9px 0;
min-width: 5em;
background-color: transparent;
border: none;
margin: 0;
&:empty:before {
content: attr(placeholder);
color: @text-color-very-subtle;
}
}
}

View file

@ -0,0 +1,27 @@
# QuickSchedule
Say goodbye to the hassle of scheduling! This new plugin lets you avoid
the typical back-and-forth of picking a time to meet. Just select a few
options, and your recipient confirms with one click. It's the best way to
instantly schedule meetings.
This plugin works by adding a small "Schedule" button next to the Send
button in the composer. Clicking the button will prompt the creation of a
quick event creator.
You can even select a set of proposed times. When you do this a calendar
pops up with your availability. You can then select some proposed times
for the receipient to choose from.
#### Enable this plugin
1. Download and run N1
2. Navigate to Preferences > Plugins and click "Enable" beside the plugin.
#### Who is this for?
Anyone who makes a lot of appointments! If you are a developer, this is
also a great example of a more complicated plugin that requires a backend
service, and demonstrates how arbitrary JavaScript can be inserted to
create custom functionality.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,15 @@
import {Rx, CalendarDataSource} from 'nylas-exports'
import ProposedTimeCalendarStore from '../proposed-time-calendar-store'
export default class ProposedTimeCalendarDataSource extends CalendarDataSource {
buildObservable({startTime, endTime, disabledCalendars}) {
this.observable = Rx.Observable.combineLatest([
super.buildObservable({startTime, endTime, disabledCalendars}),
Rx.Observable.fromStore(ProposedTimeCalendarStore).map((store) => store.proposalsAsEvents()),
])
.map(([superResult, proposedTimes]) => {
return {events: superResult.events.concat(proposedTimes)}
})
return this.observable;
}
}

Some files were not shown because too many files have changed in this diff Show more