Merge remote-tracking branch 'k2/master'
8
.arcconfig
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"project_id" : "N1",
|
||||||
|
"conduit_uri" : "https://phab.nylas.com/",
|
||||||
|
"load" : [
|
||||||
|
"arclib"
|
||||||
|
],
|
||||||
|
"lint.engine": "ArcanistConfigurationDrivenLintEngine"
|
||||||
|
}
|
17
.arclint
Normal 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
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
"electron",
|
||||||
|
"react"
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"transform-async-generator-functions"
|
||||||
|
],
|
||||||
|
"sourceMaps": "inline"
|
||||||
|
}
|
27
.dockerignore
Normal 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
|
9
.ebextensions/enable_docker_cli_on_ssh.config
Normal 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
|
@ -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
|
@ -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
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"node": {}
|
||||||
|
}
|
||||||
|
}
|
28
Dockerfile
Normal 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
|
@ -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.
|
1
arclib/.phutil_module_cache
Normal file
|
@ -0,0 +1 @@
|
||||||
|
{"__symbol_cache_version__":11}
|
3
arclib/__phutil_library_init__.php
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
phutil_register_library('customlib', __FILE__);
|
14
arclib/__phutil_library_map__.php
Normal 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
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"lerna": "2.0.0-beta.30",
|
||||||
|
"version": "0.0.1"
|
||||||
|
}
|
11
migrations/01-expirationDate-metadata.es6
Normal 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
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
19
packages/isomorphic-core/index.js
Normal 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'),
|
||||||
|
}
|
20
packages/isomorphic-core/package.json
Normal 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"
|
||||||
|
}
|
183
packages/isomorphic-core/src/auth-helpers.js
Normal 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
|
||||||
|
},
|
||||||
|
}
|
45
packages/isomorphic-core/src/database-types.js
Normal 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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
123
packages/isomorphic-core/src/delta-stream-builder.js
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
23
packages/isomorphic-core/src/errors.js
Normal 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,
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
64
packages/isomorphic-core/src/hook-transaction-log.js
Normal 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"))
|
||||||
|
}
|
237
packages/isomorphic-core/src/imap-box.js
Normal 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;
|
397
packages/isomorphic-core/src/imap-connection.es6
Normal 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
|
136
packages/isomorphic-core/src/imap-errors.js
Normal 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,
|
||||||
|
};
|
33
packages/isomorphic-core/src/load-models.js
Normal 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
|
|
@ -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');
|
||||||
|
},
|
||||||
|
};
|
21
packages/isomorphic-core/src/models/account-token.js
Normal 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;
|
||||||
|
};
|
148
packages/isomorphic-core/src/models/account.js
Normal 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;
|
||||||
|
};
|
25
packages/isomorphic-core/src/models/transaction.js
Normal 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}`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
56
packages/isomorphic-core/src/promise-utils.js
Normal 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,
|
||||||
|
}
|
132
packages/isomorphic-core/src/sendmail-client.es6
Normal 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;
|
12
packages/local-private/README.md
Normal 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.
|
70
packages/local-private/docs/ContinuousIntegration.md
Normal 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
|
BIN
packages/local-private/encrypted_certificates/appveyor/set_win_env.ps1.enc
Executable file
BIN
packages/local-private/encrypted_certificates/appveyor/win-nylas-n1.p12.enc
Executable file
BIN
packages/local-private/packages/activity-list/assets/icon.png
Normal file
After Width: | Height: | Size: 16 KiB |
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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();
|
|
@ -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;
|
21
packages/local-private/packages/activity-list/lib/main.es6
Normal 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);
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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};
|
||||||
|
}
|
||||||
|
}
|
21
packages/local-private/packages/activity-list/package.json
Normal 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"
|
||||||
|
}
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
BIN
packages/local-private/packages/composer-mail-merge/icon.png
Normal file
After Width: | Height: | Size: 15 KiB |
|
@ -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} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
|
@ -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)
|
|
@ -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')
|
|
@ -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',
|
||||||
|
]
|
|
@ -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)
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
|
@ -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)
|
|
@ -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
|
|
@ -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)
|
|
@ -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
|
|
@ -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',
|
||||||
|
})
|
||||||
|
}
|
|
@ -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
|
|
@ -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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
|
@ -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
|
|
@ -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() {
|
||||||
|
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}),
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
@ -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}
|
||||||
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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="{"field":"subject","colIdx":"0","colName":"email","draftClientId":"local-0cab45d1-c763","className":"mail-merge-token-wrap"}" 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>`
|
||||||
|
}
|
|
@ -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)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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!',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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'])
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -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})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
27
packages/local-private/packages/composer-scheduler/README.md
Normal 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.
|
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 230 B |
After Width: | Height: | Size: 788 B |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 511 B |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 779 B |
After Width: | Height: | Size: 1.3 KiB |
BIN
packages/local-private/packages/composer-scheduler/icon.png
Normal file
After Width: | Height: | Size: 14 KiB |
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|