From 5164899c46ea05fc63e86bf3982acb434b541957 Mon Sep 17 00:00:00 2001 From: Ben Gotow Date: Sun, 16 Jul 2017 14:01:20 -0700 Subject: [PATCH] Convert mail rules code to ES2016 --- .../preferences/lib/main.jsx | 14 +- .../lib/tabs/preferences-mail-rules.jsx | 4 +- .../src/components/scenario-editor-row.cjsx | 155 ------------- .../src/components/scenario-editor-row.jsx | 186 ++++++++++++++++ .../src/components/scenario-editor.cjsx | 84 ------- .../src/components/scenario-editor.jsx | 94 ++++++++ .../src/flux/stores/mail-rules-store.coffee | 98 --------- .../src/flux/stores/mail-rules-store.es6 | 116 ++++++++++ .../src/mail-rules-processor.coffee | 153 ------------- .../client-app/src/mail-rules-processor.es6 | 206 ++++++++++++++++++ .../src/mail-rules-templates.coffee | 121 ---------- .../client-app/src/mail-rules-templates.es6 | 131 +++++++++++ 12 files changed, 742 insertions(+), 620 deletions(-) delete mode 100644 packages/client-app/src/components/scenario-editor-row.cjsx create mode 100644 packages/client-app/src/components/scenario-editor-row.jsx delete mode 100644 packages/client-app/src/components/scenario-editor.cjsx create mode 100644 packages/client-app/src/components/scenario-editor.jsx delete mode 100644 packages/client-app/src/flux/stores/mail-rules-store.coffee create mode 100644 packages/client-app/src/flux/stores/mail-rules-store.es6 delete mode 100644 packages/client-app/src/mail-rules-processor.coffee create mode 100644 packages/client-app/src/mail-rules-processor.es6 delete mode 100644 packages/client-app/src/mail-rules-templates.coffee create mode 100644 packages/client-app/src/mail-rules-templates.es6 diff --git a/packages/client-app/internal_packages/preferences/lib/main.jsx b/packages/client-app/internal_packages/preferences/lib/main.jsx index 89c87bc6d..753b28d79 100644 --- a/packages/client-app/internal_packages/preferences/lib/main.jsx +++ b/packages/client-app/internal_packages/preferences/lib/main.jsx @@ -7,7 +7,7 @@ import PreferencesGeneral from './tabs/preferences-general'; import PreferencesAccounts from './tabs/preferences-accounts'; import PreferencesAppearance from './tabs/preferences-appearance'; import PreferencesKeymaps from './tabs/preferences-keymaps'; -// import PreferencesMailRules from './tabs/preferences-mail-rules'; +import PreferencesMailRules from './tabs/preferences-mail-rules'; import PreferencesIdentity from './tabs/preferences-identity'; export function activate() { @@ -41,12 +41,12 @@ export function activate() { component: PreferencesKeymaps, order: 5, })) - // PreferencesUIStore.registerPreferencesTab(new PreferencesUIStore.TabItem({ - // tabId: 'Mail Rules', - // displayName: 'Mail Rules', - // component: PreferencesMailRules, - // order: 6, - // })) + PreferencesUIStore.registerPreferencesTab(new PreferencesUIStore.TabItem({ + tabId: 'Mail Rules', + displayName: 'Mail Rules', + component: PreferencesMailRules, + order: 6, + })) WorkspaceStore.defineSheet('Preferences', {}, { split: ['Preferences'], diff --git a/packages/client-app/internal_packages/preferences/lib/tabs/preferences-mail-rules.jsx b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-mail-rules.jsx index 159e329ba..a796e9288 100644 --- a/packages/client-app/internal_packages/preferences/lib/tabs/preferences-mail-rules.jsx +++ b/packages/client-app/internal_packages/preferences/lib/tabs/preferences-mail-rules.jsx @@ -53,7 +53,7 @@ class PreferencesMailRules extends React.Component { currentAccount: currentAccount, rules: rules, selectedRule: selectedRule, - tasks: TaskQueue.tasksMatching(ReprocessMailRulesTask, {}), + tasks: TaskQueue.findTasks(ReprocessMailRulesTask, {}), actionTemplates: ActionTemplatesForAccount(currentAccount), conditionTemplates: ConditionTemplatesForAccount(currentAccount), } @@ -129,7 +129,7 @@ class PreferencesMailRules extends React.Component { } _onTasksChanged = () => { - this.setState({tasks: TaskQueue.tasksMatching(ReprocessMailRulesTask, {})}) + this.setState({tasks: TaskQueue.findTasks(ReprocessMailRulesTask, {})}) } _renderAccountPicker() { diff --git a/packages/client-app/src/components/scenario-editor-row.cjsx b/packages/client-app/src/components/scenario-editor-row.cjsx deleted file mode 100644 index 3c6ebb316..000000000 --- a/packages/client-app/src/components/scenario-editor-row.cjsx +++ /dev/null @@ -1,155 +0,0 @@ -React = require 'react' -_ = require 'underscore' -Rx = require 'rx-lite' -{RetinaImg, Flexbox} = require 'nylas-component-kit' -{CategoryStore, Actions, Utils} = require 'nylas-exports' - -{Comparator, Template} = require './scenario-editor-models' - -SOURCE_SELECT_NULL = 'NULL' - -class SourceSelect extends React.Component - @displayName: 'SourceSelect' - @propTypes: - value: React.PropTypes.string - onChange: React.PropTypes.func.isRequired - options: React.PropTypes.oneOfType([ - React.PropTypes.object - React.PropTypes.array - ]).isRequired - - constructor: (@props) -> - @state = - options: [] - - componentDidMount: => - @_setupValuesSubscription() - - componentWillReceiveProps: (nextProps) => - @_setupValuesSubscription(nextProps) - - componentWillUnmount: => - @_subscription?.dispose() - @_subscription = null - - _setupValuesSubscription: (props = @props) => - @_subscription?.dispose() - @_subscription = null - if props.options instanceof Rx.Observable - @_subscription = props.options.subscribe (options) => - @setState({options}) - else - @setState(options: props.options) - - render: => - options = @state.options - - # The React - - { @state.options.map ({value, name}) => - - } - - - _onChange: (event) => - value = event.target.value - value = null if value is SOURCE_SELECT_NULL - @props.onChange(target: {value}) - -class ScenarioEditorRow extends React.Component - @displayName: 'ScenarioEditorRow' - @propTypes: - instance: React.PropTypes.object.isRequired - removable: React.PropTypes.bool - templates: React.PropTypes.array.isRequired - onChange: React.PropTypes.func - onInsert: React.PropTypes.func - onRemove: React.PropTypes.func - - constructor: (@props) -> - - render: => - template = _.findWhere(@props.templates, {key: @props.instance.templateKey}) - unless template - return Could not find template for instance key: {@props.instance.templateKey} - - - - {@_renderTemplateSelect(template)} - {@_renderComparator(template)} - {template.valueLabel} - {@_renderValue(template)} - -
- {@_renderActions()} -
- - _renderTemplateSelect: (template) => - options = @props.templates.map ({key, name}) => - - - - - _renderComparator: (template) => - options = _.map template.comparators, ({name}, key) => - - - return false unless options.length > 0 - - - - _renderValue: (template) => - if template.type is Template.Type.Enum - - - else if template.type is Template.Type.String - - - else - false - - _renderActions: => -
- { if @props.removable then
} -
+
-
- - _onChangeValue: (event) => - instance = _.clone(@props.instance) - instance.value = event.target.value - @props.onChange(instance) - - _onChangeComparator: (event) => - instance = _.clone(@props.instance) - instance.comparatorKey = event.target.value - @props.onChange(instance) - - _onChangeTemplate: (event) => - instance = _.clone(@props.instance) - - existingTemplate = _.findWhere(@props.templates, key: instance.key) - newTemplate = _.findWhere(@props.templates, key: event.target.value) - - instance = newTemplate.coerceInstance(instance) - - @props.onChange(instance) - -module.exports = ScenarioEditorRow diff --git a/packages/client-app/src/components/scenario-editor-row.jsx b/packages/client-app/src/components/scenario-editor-row.jsx new file mode 100644 index 000000000..defbc6759 --- /dev/null +++ b/packages/client-app/src/components/scenario-editor-row.jsx @@ -0,0 +1,186 @@ +import React from 'react'; +import Rx from 'rx-lite'; +import {Flexbox} from 'nylas-component-kit'; + +import {Template} from './scenario-editor-models'; + +const SOURCE_SELECT_NULL = 'NULL'; + +class SourceSelect extends React.Component { + static displayName = 'SourceSelect'; + static propTypes = { + value: React.PropTypes.string, + onChange: React.PropTypes.func.isRequired, + options: React.PropTypes.oneOfType([ + React.PropTypes.object, + React.PropTypes.array, + ]).isRequired, + }; + + constructor(props) { + super(props); + this.state = { + options: [], + }; + } + + componentDidMount() { + this._setupValuesSubscription() + } + + componentWillReceiveProps(nextProps) { + this._setupValuesSubscription(nextProps); + } + + componentWillUnmount() { + if (this._subscription) { this._subscription.dispose(); } + this._subscription = null; + } + + _setupValuesSubscription(props = this.props) { + if (this._subscription) { this._subscription.dispose(); } + this._subscription = null; + if (props.options instanceof Rx.Observable) { + this._subscription = props.options.subscribe((options) => + this.setState({options}) + ) + } else { + this.setState({options: props.options}); + } + } + + _onChange = (event) => { + this.props.onChange({target: { + value: event.target.value === SOURCE_SELECT_NULL ? null : event.target.value, + }}); + } + + render() { + // The React + + )} + + ); + } +} + +export default class ScenarioEditorRow extends React.Component { + static displayName = 'ScenarioEditorRow'; + static propTypes = { + instance: React.PropTypes.object.isRequired, + removable: React.PropTypes.bool, + templates: React.PropTypes.array.isRequired, + onChange: React.PropTypes.func, + onInsert: React.PropTypes.func, + onRemove: React.PropTypes.func, + }; + + _onChangeValue = (event) => { + const instance = JSON.parse(JSON.stringify(this.props.instance)); + instance.value = event.target.value + this.props.onChange(instance) + } + + _onChangeComparator = (event) => { + const instance = JSON.parse(JSON.stringify(this.props.instance)); + instance.comparatorKey = event.target.value + this.props.onChange(instance); + } + + _onChangeTemplate = (event) => { + const instance = JSON.parse(JSON.stringify(this.props.instance)); + const newTemplate = this.props.templates.find(t => t.key === event.target.value); + this.props.onChange(newTemplate.coerceInstance(instance)); + } + + _renderTemplateSelect() { + const options = this.props.templates.map(({key, name}) => + + ); + return ( + + ); + } + + _renderComparator(template) { + const options = Object.keys(template.comparators).map(key => + + ); + if (options.length === 0) { + return false; + } + return ( + + ); + } + + _renderValue(template) { + if (template.type === Template.Type.Enum) { + return ( + + ); + } + + if (template.type === Template.Type.String) { + return ( + + ); + } + return false; + } + + _renderActions() { + return ( +
+ { this.props.removable &&
} +
+
+
+ ); + } + + render() { + const template = this.props.templates.find(t => t.key === this.props.instance.templateKey); + if (!template) { + return ( + Could not find template for instance key: {this.props.instance.templateKey} + ); + } + return ( + + + {this._renderTemplateSelect(template)} + {this._renderComparator(template)} + {template.valueLabel} + {this._renderValue(template)} + +
+ {this._renderActions()} + + ); + } +} diff --git a/packages/client-app/src/components/scenario-editor.cjsx b/packages/client-app/src/components/scenario-editor.cjsx deleted file mode 100644 index d6636547e..000000000 --- a/packages/client-app/src/components/scenario-editor.cjsx +++ /dev/null @@ -1,84 +0,0 @@ -React = require 'react' -_ = require 'underscore' -{Comparator, Template} = require './scenario-editor-models' -ScenarioEditorRow = require './scenario-editor-row' -{RetinaImg, Flexbox} = require 'nylas-component-kit' -{Actions, Utils} = require 'nylas-exports' - -### -The ScenarioEditor takes an array of ScenarioTemplate objects which define the -scenario value space. Each ScenarioTemplate defines a `key` and it's valid -`comparators` and `values`. The ScenarioEditor gives the user the option to -create and combine instances of different templates to create a scenario. - -For example: - - Scenario Space: - - ScenarioFactory("user-name", "The name of the user") - + valueType: String - + comparators: "contains", "starts with", etc. - - SecnarioFactor("profession", "The profession of the user") - + valueType: Enum - + comparators: 'is' - - Scenario Value: - [{ - 'key': 'user-name' - 'comparator': 'contains' - 'value': 'Ben' - },{ - 'key': 'profession' - 'comparator': 'is' - 'value': 'Engineer' - }] -### - -class ScenarioEditor extends React.Component - @displayName: 'ScenarioEditor' - - @propTypes: - instances: React.PropTypes.array - className: React.PropTypes.string - onChange: React.PropTypes.func - templates: React.PropTypes.array - - @Template: Template - @Comparator: Comparator - - constructor: (@props) -> - @state = - collapsed: true - - render: => -
- { (@props.instances || []).map (instance, idx) => - 1} - templates={@props.templates} - onRemove={ => @_onRemoveRule(idx) } - onInsert={ => @_onInsertRule(idx) } - onChange={ (instance) => @_onChangeRowValue(instance, idx) } /> - } -
- - _performChange: (block) => - instances = JSON.parse(JSON.stringify(@props.instances)) - block(instances) - @props.onChange(instances) - - _onRemoveRule: (idx) => - @_performChange (instances) => - return if instances.length is 1 - instances.splice(idx, 1) - - _onInsertRule: (idx) => - @_performChange (instances) => - instances.push @props.templates[0].createDefaultInstance() - - _onChangeRowValue: (newInstance, idx) => - @_performChange (instances) => - instances[idx] = newInstance - -module.exports = ScenarioEditor diff --git a/packages/client-app/src/components/scenario-editor.jsx b/packages/client-app/src/components/scenario-editor.jsx new file mode 100644 index 000000000..293bc105d --- /dev/null +++ b/packages/client-app/src/components/scenario-editor.jsx @@ -0,0 +1,94 @@ +import React from 'react' +import {Comparator, Template} from './scenario-editor-models' +import ScenarioEditorRow from './scenario-editor-row' + +/** +The ScenarioEditor takes an array of ScenarioTemplate objects which define the +scenario value space. Each ScenarioTemplate defines a `key` and it's valid +`comparators` and `values`. The ScenarioEditor gives the user the option to +create and combine instances of different templates to create a scenario. + +For example: + + Scenario Space: + - ScenarioFactory("user-name", "The name of the user") + + valueType: String + + comparators: "contains", "starts with", etc. + - SecnarioFactor("profession", "The profession of the user") + + valueType: Enum + + comparators: 'is' + + Scenario Value: + [{ + 'key': 'user-name' + 'comparator': 'contains' + 'value': 'Ben' + },{ + 'key': 'profession' + 'comparator': 'is' + 'value': 'Engineer' + }] +*/ + +export default class ScenarioEditor extends React.Component { + static displayName = 'ScenarioEditor'; + + static propTypes = { + instances: React.PropTypes.array, + className: React.PropTypes.string, + onChange: React.PropTypes.func, + templates: React.PropTypes.array, + }; + + static Template = Template; + static Comparator = Comparator; + + constructor(props) { + super(props); + this.state = { + collapsed: true, + }; + } + + _performChange(block) { + const instances = JSON.parse(JSON.stringify(this.props.instances)); + block(instances); + this.props.onChange(instances); + } + + _onRemoveRule = (idx) => { + this._performChange((instances) => { + if (instances.length > 1) { instances.splice(idx, 1) } + }); + } + + _onInsertRule = () => { + this._performChange((instances) => { + instances.push(this.props.templates[0].createDefaultInstance()) + }); + } + + _onChangeRowValue = (newInstance, idx) => { + this._performChange((instances) => { + instances[idx] = newInstance; + }); + } + + render() { + return ( +
+ {(this.props.instances || []).map((instance, idx) => + 1} + templates={this.props.templates} + onRemove={() => this._onRemoveRule(idx)} + onInsert={() => this._onInsertRule(idx)} + onChange={(updatedInstance) => this._onChangeRowValue(updatedInstance, idx)} + /> + )} +
+ ); + } +} diff --git a/packages/client-app/src/flux/stores/mail-rules-store.coffee b/packages/client-app/src/flux/stores/mail-rules-store.coffee deleted file mode 100644 index 93b7427e8..000000000 --- a/packages/client-app/src/flux/stores/mail-rules-store.coffee +++ /dev/null @@ -1,98 +0,0 @@ -NylasStore = require 'nylas-store' -_ = require 'underscore' -Rx = require 'rx-lite' -AccountStore = require('./account-store').default -DatabaseStore = require('./database-store').default -TaskQueue = require('./task-queue').default; -ReprocessMailRulesTask = require('../tasks/reprocess-mail-rules-task').default -Utils = require '../models/utils' -Actions = require('../actions').default - -{ConditionMode, ConditionTemplates, ActionTemplates} = require '../../mail-rules-templates' - -RulesJSONKey = "MailRules-V2" - -class MailRulesStore extends NylasStore - constructor: -> - @_rules = []; - try - txt = window.localStorage.getItem(RulesJSONKey) - if txt - @_rules = JSON.parse() - catch e - console.warn("Could not load saved mail rules", e) - - @listenTo Actions.addMailRule, @_onAddMailRule - @listenTo Actions.deleteMailRule, @_onDeleteMailRule - @listenTo Actions.reorderMailRule, @_onReorderMailRule - @listenTo Actions.updateMailRule, @_onUpdateMailRule - @listenTo Actions.disableMailRule, @_onDisableMailRule - - rules: => - @_rules - - rulesForAccountId: (accountId) => - @_rules.filter (f) => f.accountId is accountId - - disabledRules: (accountId) => - @_rules.filter (f) => f.disabled - - _onDeleteMailRule: (id) => - @_rules = @_rules.filter (f) -> f.id isnt id - @_saveMailRules() - @trigger() - - _onReorderMailRule: (id, newIdx) => - currentIdx = _.findIndex(@_rules, _.matcher({id})) - return if currentIdx is -1 - rule = @_rules[currentIdx] - @_rules.splice(currentIdx, 1) - @_rules.splice(newIdx, 0, rule) - @_saveMailRules() - @trigger() - - _onAddMailRule: (properties) => - defaults = - id: Utils.generateTempId() - name: "Untitled Rule" - conditionMode: ConditionMode.All - conditions: [ConditionTemplates[0].createDefaultInstance()] - actions: [ActionTemplates[0].createDefaultInstance()] - disabled: false - - unless properties.accountId - throw new Error("AddMailRule: you must provide an account id.") - - @_rules.push(_.extend(defaults, properties)) - @_saveMailRules() - @trigger() - - _onUpdateMailRule: (id, properties) => - existing = _.find @_rules, (f) -> id is f.id - existing[key] = val for key, val of properties - @_saveMailRules() - @trigger() - - _onDisableMailRule: (id, reason) => - existing = _.find @_rules, (f) -> id is f.id - return if not existing or existing.disabled is true - - # Disable the task - existing.disabled = true - existing.disabledReason = reason - @_saveMailRules() - - # Cancel all bulk processing jobs - for task in TaskQueue.findTasks(ReprocessMailRulesTask, {}) - Actions.dequeueTask(task.id) - - @trigger() - - _saveMailRules: => - @_saveMailRulesDebounced ?= _.debounce => - window.localStorage.setItem(RulesJSONKey, JSON.stringify(@_rules)) - ,1000 - @_saveMailRulesDebounced() - - -module.exports = new MailRulesStore() diff --git a/packages/client-app/src/flux/stores/mail-rules-store.es6 b/packages/client-app/src/flux/stores/mail-rules-store.es6 new file mode 100644 index 000000000..a66287008 --- /dev/null +++ b/packages/client-app/src/flux/stores/mail-rules-store.es6 @@ -0,0 +1,116 @@ +import NylasStore from 'nylas-store'; +import _ from 'underscore'; +import TaskQueue from './task-queue'; +import ReprocessMailRulesTask from '../tasks/reprocess-mail-rules-task'; +import Utils from '../models/utils'; +import Actions from '../actions'; + +import {ConditionMode, ConditionTemplates, ActionTemplates} from '../../mail-rules-templates'; + +const RulesJSONKey = "MailRules-V2" + +class MailRulesStore extends NylasStore { + constructor() { + super(); + + this._rules = []; + try { + const txt = window.localStorage.getItem(RulesJSONKey); + if (txt) { + this._rules = JSON.parse(txt); + } + } catch (err) { + console.warn("Could not load saved mail rules", err); + } + + this.listenTo(Actions.addMailRule, this._onAddMailRule); + this.listenTo(Actions.deleteMailRule, this._onDeleteMailRule); + this.listenTo(Actions.reorderMailRule, this._onReorderMailRule); + this.listenTo(Actions.updateMailRule, this._onUpdateMailRule); + this.listenTo(Actions.disableMailRule, this._onDisableMailRule); + } + + rules() { + return this._rules; + } + + rulesForAccountId(accountId) { + return this._rules.filter((f) => f.accountId === accountId); + } + + disabledRules(accountId) { + return this._rules.filter((f) => f.accountId === accountId && f.disabled); + } + + _onDeleteMailRule = (id) => { + this._rules = this._rules.filter((f) => f.id !== id); + this._saveMailRules(); + this.trigger(); + } + + _onReorderMailRule = (id, newIdx) => { + const currentIdx = _.findIndex(this._rules, _.matcher({id})) + if (currentIdx === -1) { + return; + } + const rule = this._rules[currentIdx]; + this._rules.splice(currentIdx, 1); + this._rules.splice(newIdx, 0, rule); + this._saveMailRules(); + this.trigger(); + } + + _onAddMailRule = (properties) => { + const defaults = { + id: Utils.generateTempId(), + name: "Untitled Rule", + conditionMode: ConditionMode.All, + conditions: [ConditionTemplates[0].createDefaultInstance()], + actions: [ActionTemplates[0].createDefaultInstance()], + disabled: false, + }; + + if (!properties.accountId) { + throw new Error("AddMailRule: you must provide an account id."); + } + + this._rules.push(Object.assign(defaults, properties)); + this._saveMailRules(); + this.trigger(); + } + + _onUpdateMailRule = (id, properties) => { + const existing = this._rules.find(f => id === f.id); + Object.assign(existing, properties); + this._saveMailRules(); + this.trigger(); + } + + _onDisableMailRule = (id, reason) => { + const existing = this._rules.find(f => id === f.id); + if (!existing || existing.disabled === true) { + return; + } + + // Disable the task + existing.disabled = true; + existing.disabledReason = reason; + this._saveMailRules(); + + // Cancel all bulk processing jobs + for (const task of TaskQueue.findTasks(ReprocessMailRulesTask, {})) { + Actions.dequeueTask(task.id); + } + + this.trigger(); + } + + _saveMailRules() { + this._saveMailRulesDebounced = this._saveMailRulesDebounced || _.debounce(() => { + window.localStorage.setItem(RulesJSONKey, JSON.stringify(this._rules)); + }, 1000); + this._saveMailRulesDebounced(); + } +} + +export default new MailRulesStore() diff --git a/packages/client-app/src/mail-rules-processor.coffee b/packages/client-app/src/mail-rules-processor.coffee deleted file mode 100644 index 71c594123..000000000 --- a/packages/client-app/src/mail-rules-processor.coffee +++ /dev/null @@ -1,153 +0,0 @@ -_ = require 'underscore' - -Task = require('./flux/tasks/task').default -Actions = require('./flux/actions').default -Category = require('./flux/models/category').default -Thread = require('./flux/models/thread').default -Message = require('./flux/models/message').default -AccountStore = require('./flux/stores/account-store').default -DatabaseStore = require('./flux/stores/database-store').default -TaskQueue = require('./flux/stores/task-queue').default; - -{ConditionMode, ConditionTemplates} = require './mail-rules-templates' - -ChangeUnreadTask = require('./flux/tasks/change-unread-task').default -ChangeFolderTask = require('./flux/tasks/change-folder-task').default -ChangeStarredTask = require('./flux/tasks/change-starred-task').default -ChangeLabelsTask = require('./flux/tasks/change-labels-task').default -MailRulesStore = null - -### -Note: At first glance, it seems like these task factory methods should use the -TaskFactory. Unfortunately, the TaskFactory uses the CategoryStore and other -information about the current view. Maybe after the unified inbox refactor... -### -MailRulesActions = - markAsImportant: (message, thread) -> - DatabaseStore.findBy(Category, { - name: 'important', - accountId: thread.accountId - }).then (important) -> - return Promise.reject(new Error("Could not find `important` label")) unless important - return new ChangeLabelsTask(labelsToAdd: [important], labelsToRemove: [], threads: [thread.id], source: "Mail Rules") - - moveToTrash: (message, thread) -> - if AccountStore.accountForId(thread.accountId).usesLabels() - return MailRulesActions.moveToLabel(message, thread, 'trash') - else - DatabaseStore.findBy(Category, { name: 'trash', accountId: thread.accountId }).then (folder) -> - return Promise.reject(new Error("The folder could not be found.")) unless folder - return new ChangeFolderTask(folder: folder, threads: [thread.id], source: "Mail Rules") - - markAsRead: (message, thread) -> - new ChangeUnreadTask(unread: false, threads: [thread.id], source: "Mail Rules") - - star: (message, thread) -> - new ChangeStarredTask(starred: true, threads: [thread.id], source: "Mail Rules") - - changeFolder: (message, thread, value) -> - return Promise.reject(new Error("A folder is required.")) unless value - DatabaseStore.findBy(Category, { id: value, accountId: thread.accountId }).then (folder) -> - return Promise.reject(new Error("The folder could not be found.")) unless folder - return new ChangeFolderTask(folder: folder, threads: [thread.id], source: "Mail Rules") - - applyLabel: (message, thread, value) -> - return Promise.reject(new Error("A label is required.")) unless value - DatabaseStore.findBy(Category, { id: value, accountId: thread.accountId }).then (label) -> - return Promise.reject(new Error("The label could not be found.")) unless label - return new ChangeLabelsTask(labelsToAdd: [label], labelsToRemove: [], threads: [thread.id], source: "Mail Rules") - - # Should really be moveToArchive but stuck with legacy name - applyLabelArchive: (message, thread) -> - return MailRulesActions.moveToLabel(message, thread, 'all') - - moveToLabel: (message, thread, nameOrId) -> - return Promise.reject(new Error("A label is required.")) unless nameOrId - - Promise.props( - withId: DatabaseStore.findBy(Category, { id: nameOrId, accountId: thread.accountId }) - withName: DatabaseStore.findBy(Category, { name: nameOrId, accountId: thread.accountId }) - ).then ({withId, withName}) -> - label = withId || withName - return Promise.reject(new Error("The label could not be found.")) unless label - return new ChangeLabelsTask({ - source: "Mail Rules" - labelsToRemove: [].concat(thread.labels).filter((l) => - !l.isLockedCategory() and l.id isnt label.id - ), - labelsToAdd: [label], - threads: [thread.id] - }) - - -class MailRulesProcessor - constructor: -> - - processMessages: (messages) => - MailRulesStore ?= require './flux/stores/mail-rules-store' - return Promise.resolve() unless messages.length > 0 - - enabledRules = MailRulesStore.rules().filter (r) -> not r.disabled - - # When messages arrive, we process all the messages in parallel, but one - # rule at a time. This is important, because users can order rules which - # may do and undo a change. Ie: "Star if from Ben, Unstar if subject is "Bla" - return Promise.each enabledRules, (rule) => - matching = messages.filter (message) => - @_checkRuleForMessage(rule, message) - - # Rules are declared at the message level, but actions are applied to - # threads. To ensure we don't apply the same action 50x on the same thread, - # just process one match per thread. - matching = _.uniq matching, false, (message) -> - message.threadId - - return Promise.all( - matching.map((message) => - # We always pull the thread from the database, even though it may be in - # `incoming.thread`, because rules may be modifying it as they run! - DatabaseStore.find(Thread, message.threadId).then((thread) => - return console.warn("Cannot find thread #{message.threadId} to process mail rules.") unless thread - return @_applyRuleToMessage(rule, message, thread) - ) - ) - ) - - _checkRuleForMessage: (rule, message) => - if rule.conditionMode is ConditionMode.All - fn = _.every - else - fn = _.any - - return false unless message.accountId is rule.accountId - - fn rule.conditions, (condition) => - template = _.findWhere(ConditionTemplates, {key: condition.templateKey}) - value = template.valueForMessage(message) - template.evaluate(condition, value) - - _applyRuleToMessage: (rule, message, thread) => - actionPromises = rule.actions.map (action) => - actionFunction = MailRulesActions[action.templateKey] - if not actionFunction - return Promise.reject(new Error("#{action.templateKey} is not a supported action.")) - return actionFunction(message, thread, action.value) - - Promise.all(actionPromises).then (actionResults) -> - performLocalPromises = [] - - actionTasks = actionResults.filter (r) -> r instanceof Task - actionTasks.forEach (task) -> - performLocalPromises.push TaskQueue.waitForPerformLocal(task) - Actions.queueTask(task) - - return Promise.all(performLocalPromises) - - .catch (err) -> - # Errors can occur if a mail rule specifies an invalid label or folder, etc. - # Disable the rule. Disable the mail rule so the failure is reflected in the - # interface. - Actions.disableMailRule(rule.id, err.toString()) - return Promise.resolve() - -module.exports = new MailRulesProcessor diff --git a/packages/client-app/src/mail-rules-processor.es6 b/packages/client-app/src/mail-rules-processor.es6 new file mode 100644 index 000000000..12aca2730 --- /dev/null +++ b/packages/client-app/src/mail-rules-processor.es6 @@ -0,0 +1,206 @@ +import _ from 'underscore'; + +import Task from './flux/tasks/task'; +import Actions from './flux/actions'; +import Category from './flux/models/category'; +import Thread from './flux/models/thread'; +import Label from './flux/models/label'; +import CategoryStore from './flux/stores/category-store'; +import DatabaseStore from './flux/stores/database-store'; +import TaskQueue from './flux/stores/task-queue'; + +import {ConditionMode, ConditionTemplates} from './mail-rules-templates'; + +import ChangeUnreadTask from './flux/tasks/change-unread-task'; +import ChangeFolderTask from './flux/tasks/change-folder-task'; +import ChangeStarredTask from './flux/tasks/change-starred-task'; +import ChangeLabelsTask from './flux/tasks/change-labels-task'; +let MailRulesStore = null + +/** +Note: At first glance, it seems like these task factory methods should use the +TaskFactory. Unfortunately, the TaskFactory uses the CategoryStore and other +information about the current view. Maybe after the unified inbox refactor... +*/ +const MailRulesActions = { + markAsImportant: async (message, thread) => { + const important = await DatabaseStore.findBy(Category, { + name: 'important', + accountId: thread.accountId, + }) + if (!important) { + throw new Error("Could not find `important` label"); + } + return new ChangeLabelsTask({ + labelsToAdd: [important], + labelsToRemove: [], + threads: [thread.id], + source: "Mail Rules", + }); + }, + + moveToTrash: async (message, thread) => { + if (CategoryStore.getInboxCategory(thread.accountId) instanceof Label) { + return MailRulesActions.moveToLabel(message, thread, 'trash'); + } + const folder = await DatabaseStore.findBy(Category, { name: 'trash', accountId: thread.accountId }); + if (!folder) { + throw new Error("The folder could not be found."); + } + return new ChangeFolderTask({ + folder: folder, + threads: [thread.id], + source: "Mail Rules", + }); + }, + + markAsRead: (message, thread) => { + return new ChangeUnreadTask({ + unread: false, + threads: [thread.id], + source: "Mail Rules", + }) + }, + + star: (message, thread) => { + return new ChangeStarredTask({ + starred: true, + threads: [thread.id], + source: "Mail Rules", + }) + }, + + changeFolder: async (message, thread, value) => { + if (!value) { + throw new Error("A folder is required."); + } + const folder = await DatabaseStore.findBy(Category, { id: value, accountId: thread.accountId }); + if (!folder) { + throw new Error("The folder could not be found."); + } + return new ChangeFolderTask({ + folder: folder, + threads: [thread.id], + source: "Mail Rules", + }); + }, + + applyLabel: async (message, thread, value) => { + if (!value) { + throw new Error("A label is required."); + } + const label = await DatabaseStore.findBy(Category, { id: value, accountId: thread.accountId }); + if (!label) { + throw new Error("The label could not be found."); + } + return new ChangeLabelsTask({ + labelsToAdd: [label], + labelsToRemove: [], + threads: [thread.id], + source: "Mail Rules", + }); + }, + + // Should really be moveToArchive but stuck with legacy name + applyLabelArchive: (message, thread) => { + return MailRulesActions.moveToLabel(message, thread, 'all'); + }, + + moveToLabel: async (message, thread, nameOrId) => { + if (!nameOrId) { + throw new Error("A label is required."); + } + + const {withId, withName} = await Promise.props({ + withId: DatabaseStore.findBy(Category, { id: nameOrId, accountId: thread.accountId }), + withName: DatabaseStore.findBy(Category, { name: nameOrId, accountId: thread.accountId }), + }); + const label = withId || withName; + if (!label) { + throw new Error("The label could not be found."); + } + return new ChangeLabelsTask({ + source: "Mail Rules", + labelsToRemove: [].concat(thread.labels).filter((l) => + !l.isLockedCategory() && l.id !== label.id + ), + labelsToAdd: [label], + threads: [thread.id], + }) + }, +}; + + +class MailRulesProcessor { + async processMessages(messages) { + MailRulesStore = MailRulesStore || require('./flux/stores/mail-rules-store').default; //eslint-disable-line + if (messages.length === 0) { + return; + } + + const enabledRules = MailRulesStore.rules().filter((r) => !r.disabled); + + // When messages arrive, we process all the messages in parallel, but one + // rule at a time. This is important, because users can order rules which + // may do and undo a change. Ie: "Star if from Ben, Unstar if subject is "Bla" + for (const rule of enabledRules) { + let matching = messages.filter((message) => + this._checkRuleForMessage(rule, message) + ); + + // Rules are declared at the message level, but actions are applied to + // threads. To ensure we don't apply the same action 50x on the same thread, + // just process one match per thread. + matching = _.uniq(matching, false, (message) => message.threadId); + for (const message of matching) { + // We always pull the thread from the database, even though it may be in + // `incoming.thread`, because rules may be modifying it as they run! + const thread = await DatabaseStore.find(Thread, message.threadId); + if (!thread) { + console.warn(`Cannot find thread ${message.threadId} to process mail rules.`); + continue; + } + await this._applyRuleToMessage(rule, message, thread); + } + } + } + + _checkRuleForMessage(rule, message) { + const fn = rule.conditionMode === ConditionMode.All ? Array.prototype.every : Array.prototype.some; + if (message.accountId !== rule.accountId) { + return false; + } + + return fn.call(rule.conditions, (condition) => { + const template = ConditionTemplates.find(t => t.key === condition.templateKey); + const value = template.valueForMessage(message); + return template.evaluate(condition, value); + }); + } + + async _applyRuleToMessage(rule, message, thread) { + try { + const actionPromises = rule.actions.map((action) => { + const actionFn = MailRulesActions[action.templateKey] + if (!actionFn) { + throw new Error(`${action.templateKey} is not a supported action.`); + } + return actionFn(message, thread, action.value); + }); + + const actionResults = await Promise.all(actionPromises); + const actionTasks = actionResults.filter((r) => r instanceof Task); + const performLocalPromises = actionTasks.map(t => TaskQueue.waitForPerformLocal(t)); + Actions.queueTasks(actionTasks); + await performLocalPromises; + } catch (err) { + // Errors can occur if a mail rule specifies an invalid label or folder, etc. + // Disable the rule. Disable the mail rule so the failure is reflected in the + // interface. + Actions.disableMailRule(rule.id, err.toString()) + } + } +} + +export default new MailRulesProcessor(); + diff --git a/packages/client-app/src/mail-rules-templates.coffee b/packages/client-app/src/mail-rules-templates.coffee deleted file mode 100644 index 5206465c1..000000000 --- a/packages/client-app/src/mail-rules-templates.coffee +++ /dev/null @@ -1,121 +0,0 @@ -NylasObservables = require 'nylas-observables' -{Template} = require './components/scenario-editor-models' - -ConditionTemplates = [ - new Template('from', Template.Type.String, { - name: 'From', - valueForMessage: (message) -> - [].concat(message.from.map((c) -> c.email), message.from.map((c) -> c.name)) - }) - - new Template('to', Template.Type.String, { - name: 'To', - valueForMessage: (message) -> - [].concat(message.to.map((c) -> c.email), message.to.map((c) -> c.name)) - }) - - new Template('cc', Template.Type.String, { - name: 'Cc', - valueForMessage: (message) -> - [].concat(message.cc.map((c) -> c.email), message.cc.map((c) -> c.name)) - }) - - new Template('bcc', Template.Type.String, { - name: 'Bcc', - valueForMessage: (message) -> - [].concat(message.bcc.map((c) -> c.email), message.bcc.map((c) -> c.name)) - }) - - new Template('anyRecipient', Template.Type.String, { - name: 'Any Recipient', - valueForMessage: (message) -> - recipients = [].concat(message.to, message.cc, message.bcc, message.from) - [].concat(recipients.map((c) -> c.email), recipients.map((c) -> c.name)) - }) - - new Template('anyAttachmentName', Template.Type.String, { - name: 'Any attachment name', - valueForMessage: (message) -> - message.files.map((f) -> f.filename) - }) - - new Template('starred', Template.Type.Enum, { - name: 'Starred', - values: [{name: 'True', value: 'true'}, {name: 'False', value: 'false'}] - valueLabel: 'is:' - valueForMessage: (message) -> - if message.starred then return 'true' else return 'false' - }) - - new Template('subject', Template.Type.String, { - name: 'Subject', - valueForMessage: (message) -> - message.subject - }) - - new Template('body', Template.Type.String, { - name: 'Body', - valueForMessage: (message) -> - message.body - }) -] - -ActionTemplates = [ - new Template('markAsRead', Template.Type.None, {name: 'Mark as Read'}) - new Template('moveToTrash', Template.Type.None, {name: 'Move to Trash'}) - new Template('star', Template.Type.None, {name: 'Star'}) -] - - -module.exports = - ConditionMode: - Any: 'any' - All: 'all' - - ConditionTemplates: ConditionTemplates - - ConditionTemplatesForAccount: (account) -> - return [] unless account - return ConditionTemplates - - ActionTemplates: ActionTemplates - - ActionTemplatesForAccount: (account) -> - return [] unless account - - templates = [].concat(ActionTemplates) - - CategoryNamesObservable = NylasObservables.Categories - .forAccount(account) - .sort() - .map (cats) -> - cats.filter (cat) -> not cat.isLockedCategory() - .map (cats) -> - cats.map (cat) -> - name: cat.displayName || cat.name - value: cat.id - - if account.usesLabels() - templates.unshift new Template('markAsImportant', Template.Type.None, { - name: 'Mark as Important' - }) - templates.unshift new Template('applyLabelArchive', Template.Type.None, { - name: 'Archive' - }) - templates.unshift new Template('applyLabel', Template.Type.Enum, { - name: 'Apply Label' - values: CategoryNamesObservable - }) - templates.unshift new Template('moveToLabel', Template.Type.Enum, { - name: 'Move to Label' - values: CategoryNamesObservable - }) - - else - templates.push new Template('changeFolder', Template.Type.Enum, { - name: 'Move Message' - valueLabel: 'to folder:' - values: CategoryNamesObservable - }) - - templates diff --git a/packages/client-app/src/mail-rules-templates.es6 b/packages/client-app/src/mail-rules-templates.es6 new file mode 100644 index 000000000..c4d9a8064 --- /dev/null +++ b/packages/client-app/src/mail-rules-templates.es6 @@ -0,0 +1,131 @@ +import NylasObservables from 'nylas-observables'; +import Label from './flux/models/label'; +import CategoryStore from './flux/stores/category-store'; +import {Template} from './components/scenario-editor-models'; + +export const ConditionTemplates = [ + new Template('from', Template.Type.String, { + name: 'From', + valueForMessage: (message) => + [].concat(message.from.map((c) => c.email), message.from.map((c) => c.name)), + }), + + new Template('to', Template.Type.String, { + name: 'To', + valueForMessage: (message) => + [].concat(message.to.map((c) => c.email), message.to.map((c) => c.name)), + }), + + new Template('cc', Template.Type.String, { + name: 'Cc', + valueForMessage: (message) => + [].concat(message.cc.map((c) => c.email), message.cc.map((c) => c.name)), + }), + + new Template('bcc', Template.Type.String, { + name: 'Bcc', + valueForMessage: (message) => + [].concat(message.bcc.map((c) => c.email), message.bcc.map((c) => c.name)), + }), + + new Template('anyRecipient', Template.Type.String, { + name: 'Any Recipient', + valueForMessage: (message) => { + const recipients = [].concat(message.to, message.cc, message.bcc, message.from) + return [].concat(recipients.map((c) => c.email), recipients.map((c) => c.name)) + }, + }), + + new Template('anyAttachmentName', Template.Type.String, { + name: 'Any attachment name', + valueForMessage: (message) => + message.files.map((f) => f.filename), + }), + + new Template('starred', Template.Type.Enum, { + name: 'Starred', + values: [{name: 'True', value: 'true'}, {name: 'False', value: 'false'}], + valueLabel: 'is:', + valueForMessage: (message) => { + return message.starred ? 'true' : 'false'; + }, + }), + + new Template('subject', Template.Type.String, { + name: 'Subject', + valueForMessage: (message) => { + return message.subject; + }, + }), + + new Template('body', Template.Type.String, { + name: 'Body', + valueForMessage: (message) => { + return message.body; + }, + }), +]; + +export const ActionTemplates = [ + new Template('markAsRead', Template.Type.None, {name: 'Mark as Read'}), + new Template('moveToTrash', Template.Type.None, {name: 'Move to Trash'}), + new Template('star', Template.Type.None, {name: 'Star'}), +] + + +export const ConditionMode = { + Any: 'any', + All: 'all', +} + +export function ConditionTemplatesForAccount(account) { + return account ? ConditionTemplates : []; +} + +export function ActionTemplatesForAccount(account) { + if (!account) { + return []; + } + + const templates = [].concat(ActionTemplates); + + const CategoryNamesObservable = NylasObservables.Categories + .forAccount(account) + .sort() + .map((cats) => + cats.filter((cat) => !cat.isLockedCategory()) + ) + .map((cats) => + cats.map((cat) => { + return { + name: cat.displayName || cat.name, + value: cat.id, + } + }) + ) + + if (CategoryStore.getInboxCategory(account.id) instanceof Label) { + templates.unshift(new Template('markAsImportant', Template.Type.None, { + name: 'Mark as Important', + })); + templates.unshift(new Template('applyLabelArchive', Template.Type.None, { + name: 'Archive', + })); + templates.unshift(new Template('applyLabel', Template.Type.Enum, { + name: 'Apply Label', + values: CategoryNamesObservable, + })); + templates.unshift(new Template('moveToLabel', Template.Type.Enum, { + name: 'Move to Label', + values: CategoryNamesObservable, + })); + } else { + templates.push(new Template('changeFolder', Template.Type.Enum, { + name: 'Move Message', + valueLabel: 'to folder:', + values: CategoryNamesObservable, + })); + } + + return templates; +} \ No newline at end of file