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
|
||||
/dist
|
||||
/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;
|
||||
}
|
||||
}
|