diff --git a/README.md b/README.md index 9e007881..0e0f6326 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,7 @@ The instructions above use an automatically built Docker image on DockerHub (htt | LDAP_FILTER | LDAP filter | | LDAP_BASEDN | LDAP base DN | | LDAP_UIDTAG | LDAP UID tag (e.g. uid/cn/username) | +| POOL_NAME | sets builtin Zone-MTA pool name (default: os.hostname()) | If you are using docker-compose to run Mailtrain in production and need to pass your own overrides of these env-vars in a custom override like `docker-compose.override.yml`: diff --git a/client/src/lists/fields/helpers.js b/client/src/lists/fields/helpers.js index 07c84ba7..b12371f0 100644 --- a/client/src/lists/fields/helpers.js +++ b/client/src/lists/fields/helpers.js @@ -6,22 +6,22 @@ export function getFieldTypes(t) { const fieldTypes = { text: { - label: t('text'), + label: t('text') }, website: { - label: t('website'), + label: t('website') }, longtext: { - label: t('multilineText'), + label: t('multilineText') }, gpg: { - label: t('gpgPublicKey'), + label: t('gpgPublicKey') }, number: { - label: t('number'), + label: t('number') }, 'checkbox-grouped': { - label: t('checkboxesFromOptionFields'), + label: t('checkboxesFromOptionFields') }, 'radio-grouped': { label: t('radioButtonsFromOptionFields') diff --git a/client/src/lists/segments/CUD.js b/client/src/lists/segments/CUD.js index f0ab6834..af737d10 100644 --- a/client/src/lists/segments/CUD.js +++ b/client/src/lists/segments/CUD.js @@ -90,8 +90,8 @@ export default class CUD extends Component { const tree = []; for (const rule of rules) { - const ruleTypeSettings = ruleHelpers.getRuleTypeSettings(rule); - const title = ruleTypeSettings ? ruleTypeSettings.treeLabel(rule) : this.props.t('newRule'); + const ruleTreeLabel = ruleHelpers.getTreeLabel(rule); + const title = ruleTreeLabel || this.props.t('newRule'); tree.push({ rule, diff --git a/client/src/lists/segments/RuleSettingsPane.js b/client/src/lists/segments/RuleSettingsPane.js index a1b2b754..cf16c405 100644 --- a/client/src/lists/segments/RuleSettingsPane.js +++ b/client/src/lists/segments/RuleSettingsPane.js @@ -25,7 +25,7 @@ export default class RuleSettingsPane extends PureComponent { const t = props.t; this.ruleHelpers = getRuleHelpers(t, props.fields); - this.fieldTypes = getFieldTypes(t); + this.fieldTypes = { ...getFieldTypes(t), ...this.ruleHelpers.extraFieldTypes }; this.state = {}; @@ -54,9 +54,11 @@ export default class RuleSettingsPane extends PureComponent { if (!ruleHelpers.isCompositeRuleType(rule.type)) { // rule.type === null signifies primitive rule where the type has not been determined yet data = ruleHelpers.primitiveRuleTypesFormDataDefaults; - const settings = ruleHelpers.getRuleTypeSettings(rule); - if (settings) { - Object.assign(data, settings.getFormData(rule)); + const colDef = ruleHelpers.getColumnDef(rule.column); + if (colDef) { + const colType = colDef.type; + const settings = ruleHelpers.primitiveRuleTypes[colType][rule.type]; + Object.assign(data, settings.getFormData(rule, colDef)); } data.type = rule.type || ''; // On '', we display label "--SELECT--" in the type dropdown. Null would not be accepted by React. @@ -92,8 +94,10 @@ export default class RuleSettingsPane extends PureComponent { if (!ruleHelpers.isCompositeRuleType(rule.type)) { rule.column = this.getFormValue('column'); - const settings = this.ruleHelpers.getRuleTypeSettings(rule); - settings.assignRuleSettings(rule, key => this.getFormValue(key)); + const colDef = ruleHelpers.getColumnDef(rule.column); + const colType = colDef.type; + const settings = ruleHelpers.primitiveRuleTypes[colType][rule.type]; + settings.assignRuleSettings(rule, key => this.getFormValue(key), colDef); } this.props.onChange(false); @@ -118,11 +122,12 @@ export default class RuleSettingsPane extends PureComponent { const column = state.getIn(['column', 'value']); if (column) { - const colType = ruleHelpers.getColumnType(column); + const colDef = ruleHelpers.getColumnDef(column); if (ruleType) { + const colType = colDef.type; const settings = ruleHelpers.primitiveRuleTypes[colType][ruleType]; - settings.validate(state); + settings.validate(state, colDef); } } else { state.setIn(['column', 'error'], t('fieldMustBeSelected')); @@ -138,9 +143,10 @@ export default class RuleSettingsPane extends PureComponent { const column = mutStateData.getIn(['column', 'value']); if (column) { - const colType = ruleHelpers.getColumnType(column); + const colDef = ruleHelpers.getColumnDef(column); if (type) { + const colType = colDef.type; const settings = ruleHelpers.primitiveRuleTypes[colType][type]; if (!settings) { // The existing rule type does not fit the newly changed column. This resets the rule type chooser to "--- Select ---" @@ -187,8 +193,9 @@ export default class RuleSettingsPane extends PureComponent { const ruleColumn = this.getFormValue('column'); if (ruleColumn) { - const colType = ruleHelpers.getColumnType(ruleColumn); - if (colType) { + const colDef = ruleHelpers.getColumnDef(ruleColumn); + if (colDef) { + const colType = colDef.type; const ruleTypeOptions = ruleHelpers.getPrimitiveRuleTypeOptions(colType); ruleTypeOptions.unshift({ key: '', label: t('select-1')}); @@ -197,7 +204,7 @@ export default class RuleSettingsPane extends PureComponent { const ruleType = this.getFormValue('type'); if (ruleType) { - ruleSettings = ruleHelpers.primitiveRuleTypes[colType][ruleType].getForm(); + ruleSettings = ruleHelpers.primitiveRuleTypes[colType][ruleType].getForm(colDef); } } } diff --git a/client/src/lists/segments/helpers.js b/client/src/lists/segments/helpers.js index cc6d01ba..fb7f0625 100644 --- a/client/src/lists/segments/helpers.js +++ b/client/src/lists/segments/helpers.js @@ -214,16 +214,23 @@ export function getRuleHelpers(t, fields) { } }; + ruleHelpers.primitiveRuleTypes['dropdown-static'] = { + eq: { + dropdownLabel: t('equalTo'), + treeLabel: rule => t('valueInColumnColNameIsEqualToValue', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}), + } + }; + const stringValueSettings = allowEmpty => ({ - getForm: () => , - getFormData: rule => ({ + getForm: fldDef => , + getFormData: (rule, fldDef) => ({ value: rule.value }), - assignRuleSettings: (rule, getter) => { + assignRuleSettings: (rule, getter, fldDef) => { rule.value = getter('value'); }, - validate: state => { + validate: (state, fldDef) => { if (!allowEmpty && !state.getIn(['value', 'value'])) { state.setIn(['value', 'error'], t('valueMustNotBeEmpty')); } else { @@ -233,14 +240,14 @@ export function getRuleHelpers(t, fields) { }); const numberValueSettings = { - getForm: () => , - getFormData: rule => ({ + getForm: fldDef => , + getFormData: (rule, fldDef) => ({ value: rule.value.toString() }), - assignRuleSettings: (rule, getter) => { + assignRuleSettings: (rule, getter, fldDef) => { rule.value = parseInt(getter('value')); }, - validate: state => { + validate: (state, fldDef) => { const value = state.getIn(['value', 'value']).trim(); if (value === '') { state.setIn(['value', 'error'], t('valueMustNotBeEmpty')); @@ -253,14 +260,14 @@ export function getRuleHelpers(t, fields) { }; const birthdayValueSettings = { - getForm: () => , - getFormData: rule => ({ + getForm: fldDef => , + getFormData: (rule, fldDef) => ({ birthday: formatBirthday(DateFormat.INTL, rule.value) }), - assignRuleSettings: (rule, getter) => { + assignRuleSettings: (rule, getter, fldDef) => { rule.value = parseBirthday(DateFormat.INTL, getter('birthday')).toISOString(); }, - validate: state => { + validate: (state, fldDef) => { const value = state.getIn(['birthday', 'value']); const date = parseBirthday(DateFormat.INTL, value); if (!value) { @@ -274,14 +281,14 @@ export function getRuleHelpers(t, fields) { }; const dateValueSettings = { - getForm: () => , - getFormData: rule => ({ + getForm: fldDef => , + getFormData: (rule, fldDef) => ({ date: formatDate(DateFormat.INTL, rule.value) }), - assignRuleSettings: (rule, getter) => { + assignRuleSettings: (rule, getter, fldDef) => { rule.value = parseDate(DateFormat.INTL, getter('date')).toISOString(); }, - validate: state => { + validate: (state, fldDef) => { const value = state.getIn(['date', 'value']); const date = parseDate(DateFormat.INTL, value); if (!value) { @@ -295,7 +302,7 @@ export function getRuleHelpers(t, fields) { }; const dateRelativeValueSettings = { - getForm: () => + getForm: fldDef =>
, - getFormData: rule => ({ + getFormData: (rule, fldDef) => ({ daysValue: Math.abs(rule.value).toString(), direction: rule.value >= 0 ? 'after' : 'before' }), - assignRuleSettings: (rule, getter) => { + assignRuleSettings: (rule, getter, fldDef) => { const direction = getter('direction'); rule.value = parseInt(getter('daysValue')) * (direction === 'before' ? -1 : 1); }, - validate: state => { + validate: (state, fldDef) => { const value = state.getIn(['daysValue', 'value']); if (!value) { state.setIn(['daysValue', 'error'], t('numberOfDaysMustNotBeEmpty')); @@ -324,12 +331,45 @@ export function getRuleHelpers(t, fields) { }; const optionValueSettings = { - getForm: () => null, - getFormData: rule => ({}), - assignRuleSettings: (rule, getter) => {}, + getForm: fldDef => null, + getFormData: (rule, fldDef) => ({}), + assignRuleSettings: (rule, getter, fldDef) => {}, validate: state => {} }; + const staticEnumValueSettings = { + getForm: fldDef => { + const opts = []; + for (const opt in fldDef.options) { + opts.push({key: opt, label: fldDef.options[opt]}); + } + + return ; + }, + getFormData: (rule, fldDef) => { + let value; + if (rule.value in fldDef.options) { + value = rule.value; + } else { + value = fldDef.default + } + + return { + value + }; + }, + assignRuleSettings: (rule, getter, fldDef) => { + let value = getter('value'); + if (!(value in fldDef.options)) { + value = fldDef.default + } + + rule.value = value; + }, + validate: (state, fldDef) => { + } + }; + function assignSettingsToRuleTypes(ruleTypes, keys, settings) { for (const key of keys) { @@ -349,6 +389,7 @@ export function getRuleHelpers(t, fields) { assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['dropdown-enum'], ['lt', 'le', 'gt', 'ge'], stringValueSettings(false)); assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['radio-enum'], ['eq', 'like', 're'], stringValueSettings(true)); assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['radio-enum'], ['lt', 'le', 'gt', 'ge'], stringValueSettings(false)); + assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['dropdown-static'], ['eq'], staticEnumValueSettings); ruleHelpers.primitiveRuleTypesFormDataDefaults = { value: '', @@ -374,7 +415,8 @@ export function getRuleHelpers(t, fields) { date: ['eq', 'lt', 'le', 'gt', 'ge', 'eqTodayPlusDays', 'ltTodayPlusDays', 'leTodayPlusDays', 'gtTodayPlusDays', 'geTodayPlusDays'], option: ['isTrue', 'isFalse'], 'dropdown-enum': ['eq', 'like', 're', 'lt', 'le', 'gt', 'ge'], - 'radio-enum': ['eq', 'like', 're', 'lt', 'le', 'gt', 'ge'] + 'radio-enum': ['eq', 'like', 're', 'lt', 'le', 'gt', 'ge'], + 'dropdown-static': ['eq'], }; return order[columnType].map(key => ({ key, label: ruleHelpers.primitiveRuleTypes[columnType][key].dropdownLabel })); @@ -411,7 +453,20 @@ export function getRuleHelpers(t, fields) { column: 'is_test', name: t('testUser'), type: 'option' + }, + { + column: 'status', + name: t('Status'), + type: 'dropdown-static', + options: { + subscribed: t('Subscribed'), + unsubscribed: t('Unsubscribed'), + bounced: t('Bounced'), + complained: t('Complained') + }, + default: 'subscribed' } + ]; ruleHelpers.fields = [ @@ -424,10 +479,10 @@ export function getRuleHelpers(t, fields) { ruleHelpers.fieldsByColumn[fld.column] = fld; } - ruleHelpers.getColumnType = column => { + ruleHelpers.getColumnDef = column => { const field = ruleHelpers.fieldsByColumn[column]; if (field) { - return field.type; + return field; } }; @@ -438,21 +493,29 @@ export function getRuleHelpers(t, fields) { } }; - ruleHelpers.getRuleTypeSettings = rule => { - if (ruleHelpers.isCompositeRuleType(rule.type)) { - return ruleHelpers.compositeRuleTypes[rule.type]; - } else { - const colType = ruleHelpers.getColumnType(rule.column); + ruleHelpers.isCompositeRuleType = ruleType => ruleType in ruleHelpers.compositeRuleTypes; - if (colType) { + ruleHelpers.getTreeLabel = rule => { + if (ruleHelpers.isCompositeRuleType(rule.type)) { + return ruleHelpers.compositeRuleTypes[rule.type].treeLabel(rule); + } else { + const colDef = ruleHelpers.getColumnDef(rule.column); + + if (colDef) { + const colType = colDef.type; if (rule.type in ruleHelpers.primitiveRuleTypes[colType]) { - return ruleHelpers.primitiveRuleTypes[colType][rule.type]; + return ruleHelpers.primitiveRuleTypes[colType][rule.type].treeLabel(rule); } } } }; - ruleHelpers.isCompositeRuleType = ruleType => ruleType in ruleHelpers.compositeRuleTypes; + + ruleHelpers.extraFieldTypes = { + 'dropdown-static': { + label: t('Dropdown') + } + }; return ruleHelpers; } diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 11cfc418..4a0c915e 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -22,6 +22,7 @@ MYSQL_HOST=${MYSQL_HOST:-'mysql'} MYSQL_DATABASE=${MYSQL_DATABASE:-'mailtrain'} MYSQL_USER=${MYSQL_USER:-'mailtrain'} MYSQL_PASSWORD=${MYSQL_PASSWORD:-'mailtrain'} +POOL_NAME=${POOL_NAME:-$(hostname)} # Warning for users that already rely on the MAILTRAIN_SETTING variable # Can probably be removed in the future. @@ -61,6 +62,7 @@ builtinZoneMTA: level: warn mongo: mongodb://${MONGO_HOST}:27017/zone-mta redis: redis://${REDIS_HOST}:6379/2 + poolName: $POOL_NAME queue: processes: 5 diff --git a/docs/README.md b/docs/README.md index 6a6611ee..5208f6da 100644 --- a/docs/README.md +++ b/docs/README.md @@ -116,6 +116,8 @@ If you do not use VERP with ZoneMTA then you should get notified most of the bou If you are using the bundled ZoneMTA then you should make sure you are using a proper PTR record for your server. For example if you use DigitalOcean then PTR is set automatically (it's the droplet name, so make sure your droplet name is the same as the domain name you are running Mailtrain from). If you use AWS then you can request setting up PTR records using [this form](https://portal.aws.amazon.com/gp/aws/html-forms-controller/contactus/ec2-email-limit-rdns-request) (requires authentication). Otherwise you would have to check from your service provider, hot to get the PTR record changed. Everything should work without the PTR record but setting it up correctly improves the deliverability a lot. +If you are using the builtin Zone-MTA, make sure the configured pool name matches the PTR record. + ##### 7. Ready to send! With proper SPF, DKIM and PTR records (DMARC wouldn't hurt either) I got perfect 10/10 score out from [MailTester](https://www.mail-tester.com/) when sending a campaign message to a MailTester test address. I did not have VERP turned on, so the sender address matched return path address. diff --git a/server/config/default.yaml b/server/config/default.yaml index 280fae61..021576e7 100644 --- a/server/config/default.yaml +++ b/server/config/default.yaml @@ -257,6 +257,7 @@ builtinZoneMTA: level: warn processes: 2 connections: 5 + # poolName: 'mail.example.com' # defaults to os.hostname() seleniumWebDriver: browser: phantomjs diff --git a/server/lib/builtin-zone-mta.js b/server/lib/builtin-zone-mta.js index e25da829..e867792b 100644 --- a/server/lib/builtin-zone-mta.js +++ b/server/lib/builtin-zone-mta.js @@ -7,6 +7,7 @@ const path = require('path'); const fs = require('fs-extra'); const crypto = require('crypto'); const bluebird = require('bluebird'); +const os = require('os'); let zoneMtaProcess = null; @@ -119,6 +120,13 @@ async function createConfig() { } }, + pools: { + default: { + address: '0.0.0.0', + name: config.builtinZoneMTA.poolName || os.hostname() + } + }, + zones: { default: { preferIPv6: false, diff --git a/server/models/segments.js b/server/models/segments.js index 6868fed2..56cf39cf 100644 --- a/server/models/segments.js +++ b/server/models/segments.js @@ -12,6 +12,7 @@ const subscriptions = require('./subscriptions'); const dependencyHelpers = require('../lib/dependency-helpers'); const {ListActivityType} = require('../../shared/activity-log'); const activityLog = require('../lib/activity-log'); +const {SubscriptionStatus} = require('../../shared/lists'); const allowedKeys = new Set(['name', 'settings']); @@ -41,6 +42,16 @@ const predefColumns = [ { column: 'is_test', type: 'option' + }, + { + column: 'status', + type: 'dropdown-static', + options: { + subscribed: 1, + unsubscribed: 2, + bounced: 3, + complained: 4 + } } ]; @@ -85,36 +96,37 @@ const primitiveRuleTypes = { birthday: {}, option: {}, 'dropdown-enum': {}, - 'radio-enum': {} + 'radio-enum': {}, + 'dropdown-static': {} }; function stringValueSettings(sqlOperator, allowEmpty) { return { - validate: rule => { + validate: (rule, fldDef) => { enforce(typeof rule.value === 'string', 'Invalid value type in rule'); enforce(allowEmpty || rule.value, 'Value in rule must not be empty'); }, - addQuery: (subsTableName, query, rule) => query.where(subsTableName + '. ' + rule.column, sqlOperator, rule.value) + addQuery: (subsTableName, query, rule, fldDef) => query.where(subsTableName + '. ' + rule.column, sqlOperator, rule.value) }; } function numberValueSettings(sqlOperator) { return { - validate: rule => { + validate: (rule, fldDef) => { enforce(typeof rule.value === 'number', 'Invalid value type in rule'); }, - addQuery: (subsTableName, query, rule) => query.where(subsTableName + '. ' + rule.column, sqlOperator, rule.value) + addQuery: (subsTableName, query, rule, fldDef) => query.where(subsTableName + '. ' + rule.column, sqlOperator, rule.value) }; } function dateValueSettings(thisDaySqlOperator, nextDaySqlOperator) { return { - validate: rule => { + validate: (rule, fldDef) => { const date = moment.utc(rule.value); enforce(date.isValid(), 'Invalid date value'); }, - addQuery: (subsTableName, query, rule) => { + addQuery: (subsTableName, query, rule, fldDef) => { const thisDay = moment.utc(rule.value).startOf('day'); const nextDay = moment(thisDay).add(1, 'days'); @@ -131,10 +143,10 @@ function dateValueSettings(thisDaySqlOperator, nextDaySqlOperator) { function dateRelativeValueSettings(todaySqlOperator, tomorrowSqlOperator) { return { - validate: rule => { + validate: (rule, fldDef) => { enforce(typeof rule.value === 'number', 'Invalid value type in rule'); }, - addQuery: (subsTableName, query, rule) => { + addQuery: (subsTableName, query, rule, fldDef) => { const todayWithOffset = moment.utc().startOf('day').add(rule.value, 'days'); const tomorrowWithOffset = moment(todayWithOffset).add(1, 'days'); @@ -151,8 +163,18 @@ function dateRelativeValueSettings(todaySqlOperator, tomorrowSqlOperator) { function optionValueSettings(value) { return { - validate: rule => {}, - addQuery: (subsTableName, query, rule) => query.where(subsTableName + '. ' + rule.column, value) + validate: (rule, fldDef) => {}, + addQuery: (subsTableName, query, rule, fldDef) => query.where(subsTableName + '. ' + rule.column, value) + }; +} + +function staticEnumValueSettings(sqlOperator) { + return { + validate: (rule, fldDef) => { + enforce(rule.value, 'Value in rule must not be empty'); + enforce(rule.value in fldDef.options, 'Value is not permitted') + }, + addQuery: (subsTableName, query, rule, fldDef) => query.where(subsTableName + '. ' + rule.column, sqlOperator, fldDef.options[rule.value]) }; } @@ -212,6 +234,15 @@ primitiveRuleTypes['radio-enum'].le = stringValueSettings('<=', false); primitiveRuleTypes['radio-enum'].gt = stringValueSettings('>', false); primitiveRuleTypes['radio-enum'].ge = stringValueSettings('>=', false); +primitiveRuleTypes['radio-enum'].eq = stringValueSettings('=', true); +primitiveRuleTypes['radio-enum'].like = stringValueSettings('LIKE', true); +primitiveRuleTypes['radio-enum'].re = stringValueSettings('REGEXP', true); +primitiveRuleTypes['radio-enum'].lt = stringValueSettings('<', false); +primitiveRuleTypes['radio-enum'].le = stringValueSettings('<=', false); +primitiveRuleTypes['radio-enum'].gt = stringValueSettings('>', false); +primitiveRuleTypes['radio-enum'].ge = stringValueSettings('>=', false); + +primitiveRuleTypes['dropdown-static'].eq = staticEnumValueSettings('=', true); function hash(entity) { @@ -283,8 +314,9 @@ async function _validateAndPreprocess(tx, listId, entity, isCreate) { validateRule(childRule); } } else { - const colType = fieldsByColumn[rule.column].type; - primitiveRuleTypes[colType][rule.type].validate(rule); + const fldDef = fieldsByColumn[rule.column]; + const colType = fldDef.type; + primitiveRuleTypes[colType][rule.type].validate(rule, fldDef); } } @@ -421,8 +453,9 @@ async function getQueryGeneratorTx(tx, listId, id) { processRule(subQuery, childRule); }); } else { - const colType = fieldsByColumn[rule.column].type; - primitiveRuleTypes[colType][rule.type].addQuery(subsTableName, query, rule); + const fldDef = fieldsByColumn[rule.column]; + const colType = fldDef.type; + primitiveRuleTypes[colType][rule.type].addQuery(subsTableName, query, rule, fldDef); } } diff --git a/server/services/sender-worker.js b/server/services/sender-worker.js index 60a1c6c6..ab66b9f1 100644 --- a/server/services/sender-worker.js +++ b/server/services/sender-worker.js @@ -6,6 +6,8 @@ const mailers = require('../lib/mailers'); const messageSender = require('../lib/message-sender'); const {CampaignTrackerActivityType} = require('../../shared/activity-log'); const activityLog = require('../lib/activity-log'); + +// TODO - use extension manager to add check to cleanExit (in fork) that waits for sendRegularCampaignMessage or sendQueuedMessage to finish require('../lib/fork'); const MessageType = messageSender.MessageType;