Convert mail rules code to ES2016

This commit is contained in:
Ben Gotow 2017-07-16 14:01:20 -07:00
parent 0e214f0c3e
commit 5164899c46
12 changed files with 742 additions and 620 deletions

View file

@ -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'],

View file

@ -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() {

View file

@ -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 <select> component won't select the correct option if the value
# is null or undefined - it just leaves the selection whatever it was in the
# previous render. To work around this, we coerce null/undefined to SOURCE_SELECT_NULL.
<select value={@props.value || SOURCE_SELECT_NULL} onChange={@_onChange}>
<option key={SOURCE_SELECT_NULL} value={SOURCE_SELECT_NULL}></option>
{ @state.options.map ({value, name}) =>
<option key={value} value={value}>{name}</option>
}
</select>
_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 <span> Could not find template for instance key: {@props.instance.templateKey}</span>
<Flexbox direction="row" className="well-row">
<span>
{@_renderTemplateSelect(template)}
{@_renderComparator(template)}
<span>{template.valueLabel}</span>
{@_renderValue(template)}
</span>
<div style={flex: 1}></div>
{@_renderActions()}
</Flexbox>
_renderTemplateSelect: (template) =>
options = @props.templates.map ({key, name}) =>
<option value={key} key={key}>{name}</option>
<select
value={@props.instance.templateKey}
onChange={@_onChangeTemplate}>
{options}
</select>
_renderComparator: (template) =>
options = _.map template.comparators, ({name}, key) =>
<option key={key} value={key}>{name}</option>
return false unless options.length > 0
<select
value={@props.instance.comparatorKey}
onChange={@_onChangeComparator}>
{options}
</select>
_renderValue: (template) =>
if template.type is Template.Type.Enum
<SourceSelect
value={@props.instance.value}
onChange={@_onChangeValue}
options={template.values} />
else if template.type is Template.Type.String
<input
type="text"
value={@props.instance.value}
onChange={@_onChangeValue} />
else
false
_renderActions: =>
<div className="actions">
{ if @props.removable then <div className="btn" onClick={@props.onRemove}>&minus;</div> }
<div className="btn" onClick={@props.onInsert}>+</div>
</div>
_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

View file

@ -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 <select> component won't select the correct option if the value
// is null or undefined - it just leaves the selection whatever it was in the
// previous render. To work around this, we coerce null/undefined to SOURCE_SELECT_NULL.
return (
<select value={this.props.value || SOURCE_SELECT_NULL} onChange={this._onChange}>
<option key={SOURCE_SELECT_NULL} value={SOURCE_SELECT_NULL} />
{this.state.options.map(({value, name}) =>
<option key={value} value={value}>{name}</option>
)}
</select>
);
}
}
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}) =>
<option value={key} key={key}>{name}</option>
);
return (
<select
value={this.props.instance.templateKey}
onChange={this._onChangeTemplate}
>
{options}
</select>
);
}
_renderComparator(template) {
const options = Object.keys(template.comparators).map(key =>
<option key={key} value={key}>{template.comparators[key].name}</option>
);
if (options.length === 0) {
return false;
}
return (
<select
value={this.props.instance.comparatorKey}
onChange={this._onChangeComparator}
>
{options}
</select>
);
}
_renderValue(template) {
if (template.type === Template.Type.Enum) {
return (
<SourceSelect
value={this.props.instance.value}
onChange={this._onChangeValue}
options={template.values}
/>
);
}
if (template.type === Template.Type.String) {
return (
<input
type="text"
value={this.props.instance.value}
onChange={this._onChangeValue}
/>
);
}
return false;
}
_renderActions() {
return (
<div className="actions">
{ this.props.removable && <div className="btn" onClick={this.props.onRemove}>&minus;</div> }
<div className="btn" onClick={this.props.onInsert}>+</div>
</div>
);
}
render() {
const template = this.props.templates.find(t => t.key === this.props.instance.templateKey);
if (!template) {
return (
<span> Could not find template for instance key: {this.props.instance.templateKey}</span>
);
}
return (
<Flexbox direction="row" className="well-row">
<span>
{this._renderTemplateSelect(template)}
{this._renderComparator(template)}
<span>{template.valueLabel}</span>
{this._renderValue(template)}
</span>
<div style={{flex: 1}} />
{this._renderActions()}
</Flexbox>
);
}
}

View file

@ -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: =>
<div className={@props.className}>
{ (@props.instances || []).map (instance, idx) =>
<ScenarioEditorRow
key={idx}
instance={instance}
removable={@props.instances.length > 1}
templates={@props.templates}
onRemove={ => @_onRemoveRule(idx) }
onInsert={ => @_onInsertRule(idx) }
onChange={ (instance) => @_onChangeRowValue(instance, idx) } />
}
</div>
_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

View file

@ -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 (
<div className={this.props.className}>
{(this.props.instances || []).map((instance, idx) =>
<ScenarioEditorRow
key={idx}
instance={instance}
removable={this.props.instances.length > 1}
templates={this.props.templates}
onRemove={() => this._onRemoveRule(idx)}
onInsert={() => this._onInsertRule(idx)}
onChange={(updatedInstance) => this._onChangeRowValue(updatedInstance, idx)}
/>
)}
</div>
);
}
}

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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();

View file

@ -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

View file

@ -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;
}