Finished support for triggered campaigns. So far only smoke-tested for subscription trigger.

This commit is contained in:
Tomas Bures 2018-11-21 01:41:10 +03:00
parent 4f5b2d10e4
commit b37ad9863c
56 changed files with 416 additions and 213 deletions

View file

@ -14,3 +14,8 @@ The migration should happen almost automatically. There are however the followin
5. Zone MTA configuration endpoint (webhooks/zone-mta/sender-config) has changed. The send-configuration CID has to be 5. Zone MTA configuration endpoint (webhooks/zone-mta/sender-config) has changed. The send-configuration CID has to be
part of the URL - e.g. webhooks/zone-mta/sender-config/system. part of the URL - e.g. webhooks/zone-mta/sender-config/system.
6. If there are lists that contain birthday or date fields that were created before
commit `bc73a0df0cab9943d726bd12fc1c6f2ff1279aa7` (on Jan 3, 2018), they still have TIMESTAMP data type in DB instead
of DATETIME. The problem was that that commit did not introduce migration from TIMESTAMP to DATETIME.
Mailtrain v2 does this migration, however in some corner cases, this may shift the date by a day back or forth.

View file

@ -190,6 +190,8 @@ class SendControls extends Component {
date.minute(time.minute()); date.minute(time.minute());
date.second(0); date.second(0);
date.millisecond(0); date.millisecond(0);
date.utcOffset(0, true); // TODO, process offset from user settings
await this.postAndMaskStateError(`rest/campaign-start-at/${this.props.entity.id}/${date.valueOf()}`); await this.postAndMaskStateError(`rest/campaign-start-at/${this.props.entity.id}/${date.valueOf()}`);
@ -245,6 +247,7 @@ class SendControls extends Component {
<div> <div>
<DatePicker id="date" label={t('date')} /> <DatePicker id="date" label={t('date')} />
<InputField id="time" label={t('time')} help={t('enter24hourTimeInFormatHhmmEg1348')}/> <InputField id="time" label={t('time')} help={t('enter24hourTimeInFormatHhmmEg1348')}/>
{/* TODO: Timezone selector */}
</div> </div>
} }
</Form> </Form>

View file

@ -65,7 +65,7 @@ export default class List extends Component {
}); });
} }
if (perms.includes('manageTriggers')) { if (this.props.campaign.permissions.includes('manageTriggers')) {
tableDeleteDialogAddDeleteButton(actions, this, null, data[0], data[1]); tableDeleteDialogAddDeleteButton(actions, this, null, data[0], data[1]);
} }

View file

@ -95,14 +95,13 @@ export function getRuleHelpers(t, fields) {
} }
}; };
// TODO: This generates strings that cannot be statically detected. It will require dynamic discovery of translatable strings.
function getRelativeDateTreeLabel(rule, textFragment) { function getRelativeDateTreeLabel(rule, textFragment) {
if (rule.value === 0) { if (rule.value === 0) {
return t('dateInColumnColName' + textFragment + ' the current date', {colName: ruleHelpers.getColumnName(rule.column)}) return t(/*ignore*/'Date in column ' + textFragment + ' the current date', {colName: ruleHelpers.getColumnName(rule.column)})
} else if (rule.value > 0) { } else if (rule.value > 0) {
return t('dateInColumnColName' + textFragment + ' {{value}}-th day after the current date', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}); return t(/*ignore*/'Date in column ' + textFragment + ' {{value}}-th day after the current date', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value});
} else { } else {
return t('dateInColumnColName' + textFragment + ' {{value}}-th day before the current date', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}); return t(/*ignore*/'Date in column ' + textFragment + ' {{value}}-th day before the current date', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value});
} }
} }
@ -129,22 +128,47 @@ export function getRuleHelpers(t, fields) {
}, },
eqTodayPlusDays: { eqTodayPlusDays: {
dropdownLabel: t('onXthDayBeforeafterCurrentDate'), dropdownLabel: t('onXthDayBeforeafterCurrentDate'),
/*
tMark('Date in column is the current date')
tMark('Date in column is {{value}}-th day after the current date')
tMark('Date in column is {{value}}-th day before the current date')
*/
treeLabel: rule => getRelativeDateTreeLabel(rule, 'is'), treeLabel: rule => getRelativeDateTreeLabel(rule, 'is'),
}, },
ltTodayPlusDays: { ltTodayPlusDays: {
dropdownLabel: t('beforeXthDayBeforeafterCurrentDate'), dropdownLabel: t('beforeXthDayBeforeafterCurrentDate'),
/*
tMark('Date in column is before the current date')
tMark('Date in column is before {{value}}-th day after the current date')
tMark('Date in column is before {{value}}-th day before the current date')
*/
treeLabel: rule => getRelativeDateTreeLabel(rule, 'is before'), treeLabel: rule => getRelativeDateTreeLabel(rule, 'is before'),
}, },
leTodayPlusDays: { leTodayPlusDays: {
dropdownLabel: t('beforeOrOnXthDayBeforeafterCurrentDate'), dropdownLabel: t('beforeOrOnXthDayBeforeafterCurrentDate'),
/*
tMark('Date in column is before or on the current date')
tMark('Date in column is before or on {{value}}-th day after the current date')
tMark('Date in column is before or on {{value}}-th day before the current date')
*/
treeLabel: rule => getRelativeDateTreeLabel(rule, 'is before or on'), treeLabel: rule => getRelativeDateTreeLabel(rule, 'is before or on'),
}, },
gtTodayPlusDays: { gtTodayPlusDays: {
dropdownLabel: t('afterXthDayBeforeafterCurrentDate'), dropdownLabel: t('afterXthDayBeforeafterCurrentDate'),
/*
tMark('Date in column is after the current date')
tMark('Date in column is after {{value}}-th day after the current date')
tMark('Date in column is after {{value}}-th day after the current date')
*/
treeLabel: rule => getRelativeDateTreeLabel(rule, 'is after'), treeLabel: rule => getRelativeDateTreeLabel(rule, 'is after'),
}, },
geTodayPlusDays: { geTodayPlusDays: {
dropdownLabel: t('afterOrOnXthDayBeforeafterCurrentDate'), dropdownLabel: t('afterOrOnXthDayBeforeafterCurrentDate'),
/*
tMark('Date in column is after or on the current date')
tMark('Date in column is after or on {{value}}-th day after the current date')
tMark('Date in column is after or on {{value}}-th day after the current date')
*/
treeLabel: rule => getRelativeDateTreeLabel(rule, 'is after or on'), treeLabel: rule => getRelativeDateTreeLabel(rule, 'is after or on'),
} }
}; };

View file

@ -87,10 +87,10 @@ export default class CUD extends Component {
' <table class="table table-bordered table-hover data-table display nowrap" width="100%" data-row-sort="1,1" data-paging="false">\n' + ' <table class="table table-bordered table-hover data-table display nowrap" width="100%" data-row-sort="1,1" data-paging="false">\n' +
' <thead>\n' + ' <thead>\n' +
' <th>\n' + ' <th>\n' +
' {{#translate}}Email{{/translate}}\n' + ' Email\n' +
' </th>\n' + ' </th>\n' +
' <th>\n' + ' <th>\n' +
' {{#translate}}Tracker Count{{/translate}}\n' + ' Tracker Count\n' +
' </th>\n' + ' </th>\n' +
' </thead>\n' + ' </thead>\n' +
' {{#if results}}\n' + ' {{#if results}}\n' +
@ -146,16 +146,16 @@ export default class CUD extends Component {
' <table class="table table-bordered table-hover data-table display nowrap" width="100%" data-row-sort="1,1,1,1" data-paging="false">\n' + ' <table class="table table-bordered table-hover data-table display nowrap" width="100%" data-row-sort="1,1,1,1" data-paging="false">\n' +
' <thead>\n' + ' <thead>\n' +
' <th>\n' + ' <th>\n' +
' {{#translate}}Country{{/translate}}\n' + ' Country\n' +
' </th>\n' + ' </th>\n' +
' <th>\n' + ' <th>\n' +
' {{#translate}}Opened{{/translate}}\n' + ' Opened\n' +
' </th>\n' + ' </th>\n' +
' <th>\n' + ' <th>\n' +
' {{#translate}}All{{/translate}}\n' + ' All\n' +
' </th>\n' + ' </th>\n' +
' <th>\n' + ' <th>\n' +
' {{#translate}}Percentage{{/translate}}\n' + ' Percentage\n' +
' </th>\n' + ' </th>\n' +
' </thead>\n' + ' </thead>\n' +
' {{#if results}}\n' + ' {{#if results}}\n' +

View file

@ -314,11 +314,11 @@ let str2 = util.format( _('My name is "%s"'), 'Mailtrain');
### Translating Handlebars Files ### Translating Handlebars Files
Enclose translatable strings to `{{#translate}}` tags Enclose translatable strings to `` tags
```handlebars ```handlebars
<p> <p>
Mailtrain {{#translate}}the best newsletter app{{/translate}} Mailtrain the best newsletter app
</p> </p>
``` ```

View file

@ -137,6 +137,8 @@ function parseSpec(specStr) {
const elems = entry.match(entryMatcher); const elems = entry.match(entryMatcher);
if (elems) { if (elems) {
spec[elems[1]] = elems[2]; spec[elems[1]] = elems[2];
} else {
spec[entry] = true;
} }
} }
} }
@ -224,6 +226,10 @@ function parseT(fragment) {
const originalKey = match[5]; const originalKey = match[5];
const spec = parseSpec(match[3]); const spec = parseSpec(match[3]);
if (spec.ignore) {
return null;
}
// console.log(`${file}: ${line}`); // console.log(`${file}: ${line}`);
// console.log(` |${match[1]}|${match[2]}|${match[4]}|${match[5]}|${match[6]}| - ${JSON.stringify(spec)}`); // console.log(` |${match[1]}|${match[2]}|${match[4]}|${match[5]}|${match[6]}| - ${JSON.stringify(spec)}`);
@ -254,27 +260,30 @@ function processFile(file) {
function update(fragments, parseFun) { function update(fragments, parseFun) {
if (fragments) { if (fragments) {
for (const fragment of fragments) { for (const fragment of fragments) {
const {key, originalKey, value, originalValue, replacement} = parseFun(fragment); const parseStruct = parseFun(fragment);
// console.log(`${key} <- ${originalKey} | ${value} <- ${originalValue} | ${fragment} -> ${replacement}`); if (parseStruct) {
const {key, originalKey, value, originalValue, replacement} = parseStruct;
// console.log(`${key} <- ${originalKey} | ${value} <- ${originalValue} | ${fragment} -> ${replacement}`);
source = source.split(fragment).join(replacement); source = source.split(fragment).join(replacement);
setInDict(resDict, key, value); setInDict(resDict, key, value);
const variants = originalKey ? findAllVariantsByPrefixInDict(originalResDict, originalKey + '_') : [];
for (const variant of variants) {
setInDict(resDict, key + '_' + variant, findInDict(originalResDict, originalKey + '_' + variant));
}
if (originalKey !== key) {
renamedKeys.set(originalKey, key);
const variants = originalKey ? findAllVariantsByPrefixInDict(originalResDict, originalKey + '_') : [];
for (const variant of variants) { for (const variant of variants) {
renamedKeys.set(originalKey + '_' + variant, key + '_' + variant); setInDict(resDict, key + '_' + variant, findInDict(originalResDict, originalKey + '_' + variant));
} }
}
if (originalKey !== key || originalValue !== value) { if (originalKey !== key) {
anyUpdates = true; renamedKeys.set(originalKey, key);
for (const variant of variants) {
renamedKeys.set(originalKey + '_' + variant, key + '_' + variant);
}
}
if (originalKey !== key || originalValue !== value) {
anyUpdates = true;
}
} }
} }
} }

View file

@ -89,6 +89,7 @@ mysql:
port: 3306 port: 3306
charset: utf8mb4 charset: utf8mb4
# The timezone configured on the MySQL server. This can be 'local', 'Z', or an offset in the form +HH:MM or -HH:MM # The timezone configured on the MySQL server. This can be 'local', 'Z', or an offset in the form +HH:MM or -HH:MM
# If the MySQL server runs on the same server as Mailtrain, use 'local'
timezone: local timezone: local
verp: verp:

32
server/dbtest.js Normal file
View file

@ -0,0 +1,32 @@
'use strict';
const config = require('config');
const knex = require('./lib/knex');
const moment = require('moment');
const shortid = require('shortid');
async function run() {
// const info = await knex('subscription__1').columnInfo();
// console.log(info);
// const ts = moment().toDate();
const ts = new Date(Date.now());
console.log(ts);
const cid = shortid.generate();
await knex('subscription__1')
.insert({
email: cid,
cid,
custom_date_mmddyy_rjkeojrzz: ts
});
const row = await knex('subscription__1').select(['id', 'created', 'custom_date_mmddyy_rjkeojrzz']).where('cid', cid).first();
// const row = await knex('subscription__1').where('id', 2).first();
console.log(row);
}
run();

View file

@ -279,14 +279,25 @@ class CampaignSender {
return await this._getMessage(campaign, list, subscriptionGrouped, mergeTags, false); return await this._getMessage(campaign, list, subscriptionGrouped, mergeTags, false);
} }
async sendMessage(listId, email) { async sendMessageByEmail(listId, email) {
const subscriptionGrouped = await subscriptions.getByEmail(contextHelpers.getAdminContext(), listId, email);
await this._sendMessage(listId, subscriptionGrouped);
}
async sendMessageBySubscriptionId(listId, subscriptionId) {
const subscriptionGrouped = await subscriptions.getById(contextHelpers.getAdminContext(), listId, subscriptionId);
await this._sendMessage(listId, subscriptionGrouped);
}
async _sendMessage(listId, subscriptionGrouped) {
const email = subscriptionGrouped.email;
if (await blacklist.isBlacklisted(email)) { if (await blacklist.isBlacklisted(email)) {
return; return;
} }
const list = this.listsById.get(listId); const list = this.listsById.get(listId);
const subscriptionGrouped = await subscriptions.getByEmail(contextHelpers.getAdminContext(), list.id, email); const flds = this.listsFieldsGrouped.get(list.id);
const flds = this.listsFieldsGrouped.get(listId);
const campaign = this.campaign; const campaign = this.campaign;
const mergeTags = fields.getMergeTags(flds, subscriptionGrouped, this._getExtraTags(campaign)); const mergeTags = fields.getMergeTags(flds, subscriptionGrouped, this._getExtraTags(campaign));
@ -391,16 +402,28 @@ class CampaignSender {
const responseId = response.split(/\s+/).pop(); const responseId = response.split(/\s+/).pop();
const now = new Date(); const now = new Date();
await knex('campaign_messages').insert({
campaign: this.campaign.id, if (campaign.type === CampaignType.REGULAR || campaign.type === CampaignType.RSS_ENTRY) {
list: listId, await knex('campaign_messages').insert({
subscription: subscriptionGrouped.id, campaign: this.campaign.id,
send_configuration: sendConfiguration.id, list: list.id,
status, subscription: subscriptionGrouped.id,
response, send_configuration: sendConfiguration.id,
response_id: responseId, status,
updated: now response,
}); response_id: responseId,
updated: now
});
} else if (campaign.type = CampaignType.TRIGGERED) {
await knex('queued')
.where({
campaign: this.campaign.id,
list: list.id,
subscription: subscriptionGrouped.id
})
.del();
}
} }
} }

View file

@ -1,14 +1,26 @@
'use strict'; 'use strict';
const config = require('config'); const config = require('config');
const moment = require('moment');
const knex = require('knex')({ const knex = require('knex')({
client: 'mysql2', client: 'mysql2',
connection: config.mysql, connection: {
...config.mysql,
// DATE and DATETIME types contain no timezone info. The MySQL driver tries to interpret them w.r.t. to local time, which
// does not work well with assigning these values in UTC and handling them as if in UTC
dateStrings: [
'DATE',
'DATETIME'
]
},
migrations: { migrations: {
directory: __dirname + '/../setup/knex/migrations' directory: __dirname + '/../setup/knex/migrations'
} }
//, debug: true //, debug: true
}); });
module.exports = knex; module.exports = knex;

View file

@ -52,7 +52,7 @@ function tUI(key, lang, args) {
args = {}; args = {};
} }
return i18n.t(key, { ...args, defaultValue, lng: lang }); return i18n.t(key, { ...args, lng: lang });
} }
function tMark(key) { function tMark(key) {

View file

@ -11,8 +11,6 @@ const fields = require('./fields');
const subscriptions = require('./subscriptions'); const subscriptions = require('./subscriptions');
const dependencyHelpers = require('../lib/dependency-helpers'); const dependencyHelpers = require('../lib/dependency-helpers');
const { parseDate, parseBirthday, DateFormat } = require('../../shared/date');
const allowedKeys = new Set(['name', 'settings']); const allowedKeys = new Set(['name', 'settings']);

View file

@ -59,7 +59,7 @@ fieldTypes.date = {
afterJSON: (groupedField, entity) => { afterJSON: (groupedField, entity) => {
const key = getFieldColumn(groupedField); const key = getFieldColumn(groupedField);
if (key in entity) { if (key in entity) {
entity[key] = entity[key] ? moment(entity[key]).toDate() : null; entity[key] = entity[key] ? moment(entity[key]).toISOString() : null;
} }
}, },
listRender: (groupedField, value) => formatDate(groupedField.settings.dateFormat, value) listRender: (groupedField, value) => formatDate(groupedField.settings.dateFormat, value)
@ -69,7 +69,7 @@ fieldTypes.birthday = {
afterJSON: (groupedField, entity) => { afterJSON: (groupedField, entity) => {
const key = getFieldColumn(groupedField); const key = getFieldColumn(groupedField);
if (key in entity) { if (key in entity) {
entity[key] = entity[key] ? moment(entity[key]).toDate() : null; entity[key] = entity[key] ? moment(entity[key]).toISOString() : null;
} }
}, },
listRender: (groupedField, value) => formatBirthday(groupedField.settings.dateFormat, value) listRender: (groupedField, value) => formatBirthday(groupedField.settings.dateFormat, value)
@ -539,6 +539,7 @@ async function _update(tx, listId, existing, filteredEntity) {
} }
} }
console.log(filteredEntity);
await tx(getSubscriptionTableName(listId)).where('id', existing.id).update(filteredEntity); await tx(getSubscriptionTableName(listId)).where('id', existing.id).update(filteredEntity);
if ('status' in filteredEntity) { if ('status' in filteredEntity) {

View file

@ -77,6 +77,7 @@ async function create(context, campaignId, entity) {
const filteredEntity = filterObject(entity, allowedKeys); const filteredEntity = filterObject(entity, allowedKeys);
filteredEntity.campaign = campaignId; filteredEntity.campaign = campaignId;
filteredEntity.last_check = new Date(); // This is to prevent processing subscriptions that predate this trigger.
const ids = await tx('triggers').insert(filteredEntity); const ids = await tx('triggers').insert(filteredEntity);
const id = ids[0]; const id = ids[0];

View file

@ -235,6 +235,7 @@ router.postAsync('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, as
shares.throwPermissionDenied(); shares.throwPermissionDenied();
} }
// TODO: Validate date/birthady formats here. Now if the value is wrong, it gets simply ignored
const subscriptionData = await fields.fromPost(contextHelpers.getAdminContext(), list.id, req.body); const subscriptionData = await fields.fromPost(contextHelpers.getAdminContext(), list.id, req.body);
const email = cleanupFromPost(req.body.EMAIL); const email = cleanupFromPost(req.body.EMAIL);

View file

@ -15,6 +15,7 @@ const workerProcesses = new Map();
const idleWorkers = []; const idleWorkers = [];
let campaignSchedulerRunning = false; let campaignSchedulerRunning = false;
let queuedSchedulerRunning = false;
let workerSchedulerRunning = false; let workerSchedulerRunning = false;
const campaignsCheckPeriod = 5 * 1000; const campaignsCheckPeriod = 5 * 1000;
@ -27,6 +28,7 @@ const messageQueueCont = new Map(); // campaignId -> next batch callback
const workAssignment = new Map(); // workerId -> { campaignId, subscribers: [{listId, email}] } const workAssignment = new Map(); // workerId -> { campaignId, subscribers: [{listId, email}] }
let workerSchedulerCont = null; let workerSchedulerCont = null;
let queuedLastId = 0;
function messagesProcessed(workerId) { function messagesProcessed(workerId) {
@ -151,7 +153,6 @@ async function processCampaign(campaignId) {
messageQueueCont.set(campaignId, resolve); messageQueueCont.set(campaignId, resolve);
}); });
// noinspection JSIgnoredPromiseFromCall
setImmediate(scheduleWorkers); setImmediate(scheduleWorkers);
await nextBatchNeeded; await nextBatchNeeded;
@ -175,36 +176,86 @@ async function scheduleCampaigns() {
campaignSchedulerRunning = true; campaignSchedulerRunning = true;
while (true) { try {
let campaignId = 0; while (true) {
let campaignId = 0;
await knex.transaction(async tx => { await knex.transaction(async tx => {
const scheduledCampaign = await tx('campaigns') const scheduledCampaign = await tx('campaigns')
.whereIn('campaigns.type', [CampaignType.REGULAR, CampaignType.RSS_ENTRY]) .whereIn('campaigns.type', [CampaignType.REGULAR, CampaignType.RSS_ENTRY])
.where('campaigns.status', CampaignStatus.SCHEDULED) .where('campaigns.status', CampaignStatus.SCHEDULED)
.where(qry => qry.whereNull('campaigns.scheduled').orWhere('campaigns.scheduled', '<=', new Date())) .where(qry => qry.whereNull('campaigns.scheduled').orWhere('campaigns.scheduled', '<=', new Date()))
.select(['id']) .select(['id'])
.first(); .first();
if (scheduledCampaign) { if (scheduledCampaign) {
await tx('campaigns').where('id', scheduledCampaign.id).update({status: CampaignStatus.SENDING}); await tx('campaigns').where('id', scheduledCampaign.id).update({status: CampaignStatus.SENDING});
campaignId = scheduledCampaign.id; campaignId = scheduledCampaign.id;
}
});
if (campaignId) {
// noinspection JSIgnoredPromiseFromCall
processCampaign(campaignId);
} else {
break;
} }
});
if (campaignId) {
// noinspection JSIgnoredPromiseFromCall
processCampaign(campaignId);
} else {
break;
} }
} catch (err) {
log.error('Senders', `Scheduling campaigns failed with error: ${err.message}`)
log.verbose(err);
} }
campaignSchedulerRunning = false; campaignSchedulerRunning = false;
} }
async function processQueued() {
if (queuedSchedulerRunning) {
return;
}
queuedSchedulerRunning = true;
try {
while (true) {
const rows = await knex('queued')
.orderBy('id', 'asc')
.where('id', '>', queuedLastId)
.limit(retrieveBatchSize);
if (rows.length === 0) {
break;
}
for (const row of rows) {
let msgQueue = messageQueue.get(row.campaign);
if (!msgQueue) {
msgQueue = [];
messageQueue.set(row.campaign, msgQueue);
}
msgQueue.push({
listId: row.list,
subscriptionId: row.subscription
});
}
queuedLastId = rows[rows.length - 1].id;
setImmediate(scheduleWorkers);
}
} catch (err) {
log.error('Senders', `Processing queued messages failed with error: ${err.message}`)
log.verbose(err);
}
queuedSchedulerRunning = false;
}
async function spawnWorker(workerId) { async function spawnWorker(workerId) {
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
log.verbose('Senders', `Spawning worker process ${workerId}`); log.verbose('Senders', `Spawning worker process ${workerId}`);
@ -251,6 +302,9 @@ function periodicCampaignsCheck() {
// noinspection JSIgnoredPromiseFromCall // noinspection JSIgnoredPromiseFromCall
scheduleCampaigns(); scheduleCampaigns();
// noinspection JSIgnoredPromiseFromCall
processQueued();
setTimeout(periodicCampaignsCheck, campaignsCheckPeriod); setTimeout(periodicCampaignsCheck, campaignsCheckPeriod);
} }
@ -286,5 +340,6 @@ async function init() {
periodicCampaignsCheck(); periodicCampaignsCheck();
} }
// noinspection JSIgnoredPromiseFromCall
init(); init();

View file

@ -4,6 +4,7 @@ const config = require('config');
const log = require('../lib/log'); const log = require('../lib/log');
const mailers = require('../lib/mailers'); const mailers = require('../lib/mailers');
const CampaignSender = require('../lib/campaign-sender'); const CampaignSender = require('../lib/campaign-sender');
const {enforce} = require('../lib/helpers');
const workerId = Number.parseInt(process.argv[2]); const workerId = Number.parseInt(process.argv[2]);
let running = false; let running = false;
@ -21,8 +22,17 @@ async function processMessages(campaignId, subscribers) {
for (const subData of subscribers) { for (const subData of subscribers) {
try { try {
await cs.sendMessage(subData.listId, subData.email); if (subData.email) {
log.verbose('Senders', 'Message sent and status updated for %s:%s', subData.listId, subData.email); await cs.sendMessageByEmail(subData.listId, subData.email);
} else if (subData.subscriptionId) {
await cs.sendMessageBySubscriptionId(subData.listId, subData.subscriptionId);
} else {
enforce(false);
}
log.verbose('Senders', 'Message sent and status updated for %s:%s', subData.listId, subData.email || subData.subscriptionId);
} catch (err) { } catch (err) {
log.error('Senders', `Sending message to ${subData.listId}:${subData.email} failed with error: ${err.message}`) log.error('Senders', `Sending message to ${subData.listId}:${subData.email} failed with error: ${err.message}`)
log.verbose(err); log.verbose(err);

View file

@ -15,17 +15,21 @@ const triggerCheckPeriod = 15 * 1000;
const triggerFirePeriod = 60 * 1000; const triggerFirePeriod = 60 * 1000;
async function start() { async function run() {
while (true) { while (true) {
const fired = await knex.transaction(async tx => { const fired = await knex.transaction(async tx => {
const currentTs = new Date(); const currentTs = Date.now();
const trigger = await tx('triggers').where('enabled', true).andWhere('last_check', '<', currentTs - triggerFirePeriod).orderBy('last_check', 'asc').first(); const trigger = await tx('triggers')
.where('enabled', true)
.where(qry => qry.whereNull('last_check').orWhere('last_check', '<', new Date(currentTs - triggerFirePeriod)))
.orderBy('last_check', 'asc')
.first();
if (!trigger) { if (!trigger) {
return false; return false;
} }
const campaign = campaigns.getByIdTx(tx, contextHelpers.getAdminContext(), trigger.campaign, false); const campaign = await campaigns.getByIdTx(tx, contextHelpers.getAdminContext(), trigger.campaign, false);
for (const cpgList of campaign.lists) { for (const cpgList of campaign.lists) {
const addSegmentQuery = cpgList.segment ? await segments.getQueryGeneratorTx(tx, cpgList.list, cpgList.segment) : () => { const addSegmentQuery = cpgList.segment ? await segments.getQueryGeneratorTx(tx, cpgList.list, cpgList.segment) : () => {
@ -36,8 +40,10 @@ async function start() {
.leftJoin( .leftJoin(
function () { function () {
return this.from('trigger_messages') return this.from('trigger_messages')
.where('trigger_messages.campaign', campaign.id) .innerJoin('triggers', 'trigger_messages.trigger', 'triggers.id')
.where('triggers.campaign', campaign.id)
.where('trigger_messages.list', cpgList.list) .where('trigger_messages.list', cpgList.list)
.select(['id', 'subscription'])
.as('related_trigger_messages'); .as('related_trigger_messages');
}, },
'related_trigger_messages.subscription', subsTable + '.id' 'related_trigger_messages.subscription', subsTable + '.id'
@ -45,7 +51,7 @@ async function start() {
.where(function () { .where(function () {
addSegmentQuery(this); addSegmentQuery(this);
}) })
.whereNotNull('related_trigger_messages.id') // This means only those where the trigger has not fired yet somewhen in the past .whereNull('related_trigger_messages.id') // This means only those where the trigger has not fired yet somewhen in the past
.select(subsTable + '.id'); .select(subsTable + '.id');
let column; let column;
@ -129,20 +135,18 @@ async function start() {
column = 'campaign_messages.created'; column = 'campaign_messages.created';
} }
sqlQry = sqlQry.where(column, '<=', currentTs - trigger.seconds);
if (trigger.last_check !== null) {
sqlQry = sqlQry.where(column, '>', trigger.last_check);
}
} }
sqlQry = sqlQry.where(column, '<=', new Date(currentTs - trigger.seconds));
if (trigger.last_check !== null) {
sqlQry = sqlQry.where(column, '>', trigger.last_check);
}
const subscribers = await sqlQry; const subscribers = await sqlQry;
for (const subscriber of subscribers) { for (const subscriber of subscribers) {
await tx('trigger_messages').insert({ await tx('trigger_messages').insert({
campaign: campaign.id, trigger: trigger.id,
list: cpgList.list, list: cpgList.list,
subscription: subscriber.id subscription: subscriber.id
}); });
@ -161,7 +165,7 @@ async function start() {
} }
await tx('triggers').update('last_check', currentTs).where('id', trigger.id); await tx('triggers').update('last_check', new Date(currentTs)).where('id', trigger.id);
return true; return true;
}); });
@ -177,4 +181,11 @@ async function start() {
} }
} }
function start() {
log.info('Triggers', 'Starting trigger check service');
run().catch(err => {
log.error('Triggers', err);
});
}
module.exports.start = start; module.exports.start = start;

View file

@ -246,10 +246,20 @@ async function migrateSubscriptions(knex) {
const fields = await knex('custom_fields').where('list', list.id); const fields = await knex('custom_fields').where('list', list.id);
const info = await knex('subscription__' + list.id).columnInfo();
for (const field of fields) { for (const field of fields) {
if (field.column != null) { if (field.column != null) {
await knex.schema.raw('ALTER TABLE `subscription__' + list.id + '` ADD `source_' + field.column +'` int(11) DEFAULT NULL'); await knex.schema.raw('ALTER TABLE `subscription__' + list.id + '` ADD `source_' + field.column +'` int(11) DEFAULT NULL');
} }
if (field.type === 'date' || field.type === 'birthday') {
// Fix the problem that commit bc73a0df0cab9943d726bd12fc1c6f2ff1279aa7 did not introduce migration that would convert TIMESTAMP columns to DATE
if (info[field.column].type === 'timestamp') {
await knex.schema.table('subscription__' + list.id, table => {
table.dateTime(field.column).alter();
});
}
}
} }
let lastId = 0; let lastId = 0;
@ -644,6 +654,7 @@ async function migrateReports(knex) {
await knex.schema.table('reports', table => { await knex.schema.table('reports', table => {
table.dropForeign('report_template', 'report_template_ibfk_1'); table.dropForeign('report_template', 'report_template_ibfk_1');
table.foreign('report_template').references('report_templates.id'); table.foreign('report_template').references('report_templates.id');
table.timestamp('last_run').nullable().defaultTo(null).alter();
}); });
} }
@ -1114,6 +1125,11 @@ async function migrateAttachments(knex) {
async function migrateTriggers(knex) { async function migrateTriggers(knex) {
await knex.schema.table('queued', table => { await knex.schema.table('queued', table => {
table.dropPrimary();
});
await knex.schema.table('queued', table => {
table.increments('id').first().primary();
table.renameColumn('subscriber', 'subscription'); table.renameColumn('subscriber', 'subscription');
table.renameColumn('source', 'trigger'); table.renameColumn('source', 'trigger');
}); });
@ -1174,7 +1190,7 @@ async function migrateImporter(knex) {
table.text('settings', 'longtext'); table.text('settings', 'longtext');
table.integer('mapping_type').unsigned().notNullable(); table.integer('mapping_type').unsigned().notNullable();
table.text('mapping', 'longtext'); table.text('mapping', 'longtext');
table.dateTime('last_run'); table.timestamp('last_run').nullable().defaultTo(null);
table.text('error'); table.text('error');
table.timestamp('created').defaultTo(knex.fn.now()); table.timestamp('created').defaultTo(knex.fn.now());
}); });
@ -1190,7 +1206,7 @@ async function migrateImporter(knex) {
table.integer('processed').defaultTo(0); table.integer('processed').defaultTo(0);
table.text('error'); table.text('error');
table.timestamp('created').defaultTo(knex.fn.now()); table.timestamp('created').defaultTo(knex.fn.now());
table.dateTime('finished'); table.timestamp('finished').nullable().defaultTo(null);
}); });
await knex.schema.createTable('import_failed', table => { await knex.schema.createTable('import_failed', table => {

View file

@ -6,7 +6,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{#translate}}Self hosted email newsletter app{{/translate}}"> <meta name="description" content="Self hosted email newsletter app">
<link rel="shortcut icon" href="{{publicPath}}static/favicon.ico" type="image/x-icon" /> <link rel="shortcut icon" href="{{publicPath}}static/favicon.ico" type="image/x-icon" />
<link rel="icon" href="{{publicPath}}static/favicon.ico"> <link rel="icon" href="{{publicPath}}static/favicon.ico">

View file

@ -6,7 +6,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{#translate}}Self hosted email newsletter app{{/translate}}"> <meta name="description" content="Self hosted email newsletter app">
<link rel="shortcut icon" href="{{publicPath}}static/favicon.ico" type="image/x-icon" /> <link rel="shortcut icon" href="{{publicPath}}static/favicon.ico" type="image/x-icon" />
<link rel="icon" href="{{publicPath}}static/favicon.ico"> <link rel="icon" href="{{publicPath}}static/favicon.ico">

View file

@ -6,7 +6,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{#translate}}Self hosted email newsletter app{{/translate}}"> <meta name="description" content="Self hosted email newsletter app">
<link rel="shortcut icon" href="{{publicPath}}static/favicon.ico" type="image/x-icon" /> <link rel="shortcut icon" href="{{publicPath}}static/favicon.ico" type="image/x-icon" />
<link rel="icon" href="{{publicPath}}static/favicon.ico"> <link rel="icon" href="{{publicPath}}static/favicon.ico">

View file

@ -6,7 +6,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{#translate}}Self hosted email newsletter app{{/translate}}"> <meta name="description" content="Self hosted email newsletter app">
<link rel="shortcut icon" href="{{publicPath}}static/favicon.ico" type="image/x-icon" /> <link rel="shortcut icon" href="{{publicPath}}static/favicon.ico" type="image/x-icon" />
<link rel="icon" href="{{publicPath}}static/favicon.ico"> <link rel="icon" href="{{publicPath}}static/favicon.ico">

View file

@ -6,7 +6,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{#translate}}Self hosted email newsletter app{{/translate}}"> <meta name="description" content="Self hosted email newsletter app">
<link rel="shortcut icon" href="{{publicPath}}static/favicon.ico" type="image/x-icon" /> <link rel="shortcut icon" href="{{publicPath}}static/favicon.ico" type="image/x-icon" />
<link rel="icon" href="{{publicPath}}static/favicon.ico"> <link rel="icon" href="{{publicPath}}static/favicon.ico">

View file

@ -1,23 +1,23 @@
<mj-section> <mj-section>
<mj-column> <mj-column>
<mj-text mj-class="h3"> <mj-text mj-class="h3">
{{#translate}}Email address already registered{{/translate}} Email address already registered
</mj-text> </mj-text>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}We have received a subscription request. Your email address is however already registered.{{/translate}}. We have received a subscription request. Your email address is however already registered..
</mj-text> </mj-text>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}If you received this email by mistake, simply delete it. Your existing subscription won't be affected.{{/translate}} If you received this email by mistake, simply delete it. Your existing subscription won't be affected.
</mj-text> </mj-text>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}If you want to modify your subscription then you can {{/translate}} If you want to modify your subscription then you can
<a href="{{preferencesUrl}}">{{#translate}}manage your preferences{{/translate}}</a> {{#translate}}or{{/translate}} <a href="{{unsubscribeUrl}}">{{#translate}}unsubscribe here{{/translate}}</a>. <a href="{{preferencesUrl}}">manage your preferences</a> or <a href="{{unsubscribeUrl}}">unsubscribe here</a>.
</mj-text> </mj-text>
<mj-button mj-class="button" href="{{homepage}}"> <mj-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}} Return to our website
</mj-button> </mj-button>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}For questions about this list, please contact:{{/translate}} For questions about this list, please contact:
<br/><a href="mailto:{{contactAddress}}">{{contactAddress}}</a> <br/><a href="mailto:{{contactAddress}}">{{contactAddress}}</a>
</mj-text> </mj-text>
</mj-column> </mj-column>

View file

@ -1,18 +1,18 @@
{{{title}}} {{{title}}}
{{#translate}}Email address already registered{{/translate}} Email address already registered
================================ ================================
{{#translate}}We have received a subscription request. Your email address is however already registered.{{/translate}} We have received a subscription request. Your email address is however already registered.
{{#translate}}If you received this email by mistake, simply delete it. Your existing subscription won't be affected.{{/translate}} If you received this email by mistake, simply delete it. Your existing subscription won't be affected.
{{#translate}}If you want to modify your subscription then you can:{{/translate}} If you want to modify your subscription then you can:
{{#translate}}manage your preferences{{/translate}}: {{preferencesUrl}} manage your preferences: {{preferencesUrl}}
- {{#translate}}or{{/translate}} - - or -
{{#translate}}unsubscribe here{{/translate}}: {{unsubscribeUrl}} unsubscribe here: {{unsubscribeUrl}}
{{#translate}}For questions about this list, please contact:{{/translate}} For questions about this list, please contact:
{{{contactAddress}}} {{{contactAddress}}}

View file

@ -1,16 +1,16 @@
<mj-section> <mj-section>
<mj-column> <mj-column>
<mj-text mj-class="h3"> <mj-text mj-class="h3">
{{#translate}}Please Confirm Subscription Address Change{{/translate}} Please Confirm Subscription Address Change
</mj-text> </mj-text>
<mj-button mj-class="button" href="{{confirmUrl}}"> <mj-button mj-class="button" href="{{confirmUrl}}">
{{#translate}}Yes, subscribe this email address to the list{{/translate}} Yes, subscribe this email address to the list
</mj-button> </mj-button>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}If you received this email by mistake, simply delete it. You won't be subscribed if you don't click the confirmation link above.{{/translate}} If you received this email by mistake, simply delete it. You won't be subscribed if you don't click the confirmation link above.
</mj-text> </mj-text>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}For questions about this list, please contact:{{/translate}} For questions about this list, please contact:
<br/><a href="mailto:{{contactAddress}}">{{contactAddress}}</a> <br/><a href="mailto:{{contactAddress}}">{{contactAddress}}</a>
</mj-text> </mj-text>
</mj-column> </mj-column>

View file

@ -1,10 +1,10 @@
{{{title}}} {{{title}}}
{{#translate}}Please Confirm Subscription Address Change{{/translate}} Please Confirm Subscription Address Change
========================================== ==========================================
{{#translate}}Yes, subscribe this email address to the list{{/translate}}: {{{confirmUrl}}} Yes, subscribe this email address to the list: {{{confirmUrl}}}
{{#translate}}If you received this email by mistake, simply delete it. You won't be subscribed unless you click the confirmation link above.{{/translate}} If you received this email by mistake, simply delete it. You won't be subscribed unless you click the confirmation link above.
{{#translate}}For questions about this list, please contact:{{/translate}} For questions about this list, please contact:
{{{contactAddress}}} {{{contactAddress}}}

View file

@ -1,16 +1,16 @@
<mj-section> <mj-section>
<mj-column> <mj-column>
<mj-text mj-class="h3"> <mj-text mj-class="h3">
{{#translate}}Please Confirm Subscription{{/translate}} Please Confirm Subscription
</mj-text> </mj-text>
<mj-button mj-class="button" href="{{confirmUrl}}"> <mj-button mj-class="button" href="{{confirmUrl}}">
{{#translate}}Yes, subscribe me to this list{{/translate}} Yes, subscribe me to this list
</mj-button> </mj-button>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}If you received this email by mistake, simply delete it. You won't be subscribed if you don't click the confirmation link above.{{/translate}} If you received this email by mistake, simply delete it. You won't be subscribed if you don't click the confirmation link above.
</mj-text> </mj-text>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}For questions about this list, please contact:{{/translate}} For questions about this list, please contact:
<br/><a href="mailto:{{contactAddress}}">{{contactAddress}}</a> <br/><a href="mailto:{{contactAddress}}">{{contactAddress}}</a>
</mj-text> </mj-text>
</mj-column> </mj-column>

View file

@ -1,10 +1,10 @@
{{{title}}} {{{title}}}
{{#translate}}Please Confirm Subscription{{/translate}} Please Confirm Subscription
=========================== ===========================
{{#translate}}Yes, subscribe me to this list{{/translate}}: {{{confirmUrl}}} Yes, subscribe me to this list: {{{confirmUrl}}}
{{#translate}}If you received this email by mistake, simply delete it. You won't be subscribed unless you click the confirmation link above.{{/translate}} If you received this email by mistake, simply delete it. You won't be subscribed unless you click the confirmation link above.
{{#translate}}For questions about this list, please contact:{{/translate}} For questions about this list, please contact:
{{{contactAddress}}} {{{contactAddress}}}

View file

@ -1,16 +1,16 @@
<mj-section> <mj-section>
<mj-column> <mj-column>
<mj-text mj-class="h3"> <mj-text mj-class="h3">
{{#translate}}Please Confirm Unsubscription{{/translate}} Please Confirm Unsubscription
</mj-text> </mj-text>
<mj-button mj-class="button" href="{{confirmUrl}}"> <mj-button mj-class="button" href="{{confirmUrl}}">
{{#translate}}Yes, unsubscribe me from this list{{/translate}} Yes, unsubscribe me from this list
</mj-button> </mj-button>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}If you received this email by mistake, simply delete it. You won't be unsubscribed if you don't click the confirmation link above.{{/translate}} If you received this email by mistake, simply delete it. You won't be unsubscribed if you don't click the confirmation link above.
</mj-text> </mj-text>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}For questions about this list, please contact:{{/translate}} For questions about this list, please contact:
<br/><a href="mailto:{{contactAddress}}">{{contactAddress}}</a> <br/><a href="mailto:{{contactAddress}}">{{contactAddress}}</a>
</mj-text> </mj-text>
</mj-column> </mj-column>

View file

@ -1,10 +1,10 @@
{{{title}}} {{{title}}}
{{#translate}}Please Confirm Subscription{{/translate}} Please Confirm Subscription
=========================== ===========================
{{#translate}}Yes, unsubscribe me from this list{{/translate}}: {{{confirmUrl}}} Yes, unsubscribe me from this list: {{{confirmUrl}}}
{{#translate}}If you received this email by mistake, simply delete it. You won't be unsubscribed unless you click the confirmation link above.{{/translate}} If you received this email by mistake, simply delete it. You won't be unsubscribed unless you click the confirmation link above.
{{#translate}}For questions about this list, please contact:{{/translate}} For questions about this list, please contact:
{{{contactAddress}}} {{{contactAddress}}}

View file

@ -1,17 +1,17 @@
<mj-section> <mj-section>
<mj-column> <mj-column>
<mj-text mj-class="h3"> <mj-text mj-class="h3">
{{#translate}}Subscription Confirmed{{/translate}} Subscription Confirmed
</mj-text> </mj-text>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}Your subscription to our list has been confirmed{{/translate}}. {{#translate}}If you want to modify your subscription then you can {{/translate}} Your subscription to our list has been confirmed. If you want to modify your subscription then you can
<a href="{{preferencesUrl}}">{{#translate}}manage your preferences{{/translate}}</a> {{#translate}}or{{/translate}} <a href="{{unsubscribeUrl}}">{{#translate}}unsubscribe here{{/translate}}</a>. <a href="{{preferencesUrl}}">manage your preferences</a> or <a href="{{unsubscribeUrl}}">unsubscribe here</a>.
</mj-text> </mj-text>
<mj-button mj-class="button" href="{{homepage}}"> <mj-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}} Return to our website
</mj-button> </mj-button>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}For questions about this list, please contact:{{/translate}} For questions about this list, please contact:
<br/><a href="mailto:{{contactAddress}}">{{contactAddress}}</a> <br/><a href="mailto:{{contactAddress}}">{{contactAddress}}</a>
</mj-text> </mj-text>
</mj-column> </mj-column>

View file

@ -1,16 +1,16 @@
{{{title}}} {{{title}}}
{{#translate}}Subscription Confirmed{{/translate}} Subscription Confirmed
====================== ======================
{{#translate}}Your subscription to our list has been confirmed.{{/translate}} Your subscription to our list has been confirmed.
{{#translate}}If you want to modify your subscription then you can:{{/translate}} If you want to modify your subscription then you can:
{{#translate}}manage your preferences{{/translate}}: {{preferencesUrl}} manage your preferences: {{preferencesUrl}}
- {{#translate}}or{{/translate}} - - or -
{{#translate}}unsubscribe here{{/translate}}: {{unsubscribeUrl}} unsubscribe here: {{unsubscribeUrl}}
{{#translate}}For questions about this list, please contact:{{/translate}} For questions about this list, please contact:
{{{contactAddress}}} {{{contactAddress}}}

View file

@ -1,16 +1,16 @@
<mj-section> <mj-section>
<mj-column> <mj-column>
<mj-text mj-class="h3"> <mj-text mj-class="h3">
{{#translate}}You Are Now Unsubscribed{{/translate}} You Are Now Unsubscribed
</mj-text> </mj-text>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}We have removed your email address from our list{{/translate}}. {{#translate}}If you unsubscribed by mistake, you can re-subscribe at:{{/translate}} We have removed your email address from our list. If you unsubscribed by mistake, you can re-subscribe at:
</mj-text> </mj-text>
<mj-button mj-class="button" href="{{subscribeUrl}}"> <mj-button mj-class="button" href="{{subscribeUrl}}">
{{#translate}}Subscribe{{/translate}} Subscribe
</mj-button> </mj-button>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}For questions about this list, please contact:{{/translate}} For questions about this list, please contact:
<br/><a href="mailto:{{contactAddress}}">{{contactAddress}}</a> <br/><a href="mailto:{{contactAddress}}">{{contactAddress}}</a>
</mj-text> </mj-text>
</mj-column> </mj-column>

View file

@ -1,12 +1,12 @@
{{{title}}} {{{title}}}
{{#translate}}You Are Now Unsubscribed{{/translate}} You Are Now Unsubscribed
======================== ========================
{{#translate}}We have removed your email address from our list.{{/translate}} We have removed your email address from our list.
{{#translate}}If you unsubscribed by mistake, you can re-subscribe at:{{/translate}} If you unsubscribed by mistake, you can re-subscribe at:
{{#translate}}Subscribe{{/translate}}: {{subscribeUrl}} Subscribe: {{subscribeUrl}}
{{#translate}}For questions about this list, please contact:{{/translate}} For questions about this list, please contact:
{{{contactAddress}}} {{{contactAddress}}}

View file

@ -2,11 +2,11 @@
{{#if typeSubscriptionEmail}} {{#if typeSubscriptionEmail}}
<div class="form-group email"> <div class="form-group email">
<label for="EMAIL">{{#translate}}Email Address{{/translate}}</label> <label for="EMAIL">Email Address</label>
{{#if ../isManagePreferences}} {{#if ../isManagePreferences}}
<div class="input-group"> <div class="input-group">
<input type="email" name="EMAIL" id="email" placeholder="" value="{{../email}}" readonly> <input type="email" name="EMAIL" id="email" placeholder="" value="{{../email}}" readonly>
<div class="input-group-addon"><a href="/subscription/{{../lcid}}/manage-address/{{../cid}}">{{#translate}}want to change it?{{/translate}}</a></div> <div class="input-group-addon"><a href="/subscription/{{../lcid}}/manage-address/{{../cid}}">want to change it?</a></div>
</div> </div>
{{else}} {{else}}
<input type="email" name="EMAIL" id="email" placeholder="" value="{{../email}}" required> <input type="email" name="EMAIL" id="email" placeholder="" value="{{../email}}" required>
@ -53,11 +53,11 @@
<div class="form-group gpg {{key}}"> <div class="form-group gpg {{key}}">
<label for="{{key}}">{{name}}</label> <label for="{{key}}">{{name}}</label>
{{#if ../hasPubkey}} {{#if ../hasPubkey}}
<button class="btn-download-pubkey" type="submit" form="download-pubkey">{{#translate}}Download signature verification key{{/translate}}</button> <button class="btn-download-pubkey" type="submit" form="download-pubkey">Download signature verification key</button>
{{/if}} {{/if}}
<textarea class="form-control gpg-text" rows="4" name="{{key}}" placeholder="{{#translate}}Begins with{{/translate}} &#39;-----BEGIN PGP PUBLIC KEY BLOCK-----&#39;">{{value}}</textarea> <textarea class="form-control gpg-text" rows="4" name="{{key}}" placeholder="Begins with &#39;-----BEGIN PGP PUBLIC KEY BLOCK-----&#39;">{{value}}</textarea>
<span class="help-block"> <span class="help-block">
{{#translate}}Insert your GPG public key here to encrypt messages sent to your address{{/translate}} <em>({{#translate}}optional{{/translate}})</em> Insert your GPG public key here to encrypt messages sent to your address <em>(optional)</em>
</span> </span>
</div> </div>
{{/if}} {{/if}}
@ -95,7 +95,7 @@
<label for="{{key}}">{{name}}</label> <label for="{{key}}">{{name}}</label>
<select name="{{key}}" class="form-control"> <select name="{{key}}" class="form-control">
<option value=""> <option value="">
{{#translate}}Select{{/translate}} Select
</option> </option>
{{#each options}} {{#each options}}
<option value="{{key}}" {{#if value}} selected {{/if}}>{{name}}</option> <option value="{{key}}" {{#if value}} selected {{/if}}>{{name}}</option>
@ -131,7 +131,7 @@
<label for="{{key}}">{{name}}</label> <label for="{{key}}">{{name}}</label>
<select name="{{key}}" class="form-control"> <select name="{{key}}" class="form-control">
<option value=""> <option value="">
{{#translate}}Select{{/translate}} Select
</option> </option>
{{#each options}} {{#each options}}
<option value="{{key}}" {{#if value}} selected {{/if}}>{{name}}</option> <option value="{{key}}" {{#if value}} selected {{/if}}>{{name}}</option>

View file

@ -2,13 +2,13 @@
{{#if isConfirmNotice}} {{#if isConfirmNotice}}
<div class="alert alert-warning js-warning" role="alert"> <div class="alert alert-warning js-warning" role="alert">
<strong>{{#translate}}Warning!{{/translate}}</strong> {{#translate}}If JavaScript was not enabled then no confirmation message was sent{{/translate}} <strong>Warning!</strong> If JavaScript was not enabled then no confirmation message was sent
</div> </div>
{{/if}} {{/if}}
{{#if needsJsWarning}} {{#if needsJsWarning}}
<div class="alert alert-danger js-warning" role="alert"> <div class="alert alert-danger js-warning" role="alert">
<strong>{{#translate}}Warning!{{/translate}}</strong> <strong>Warning!</strong>
{{#translate}}JavaScript must be enabled in order for this form to work{{/translate}} JavaScript must be enabled in order for this form to work
</div> </div>
{{/if}} {{/if}}

View file

@ -4,19 +4,19 @@
<input type="hidden" name="cid" value="{{cid}}"> <input type="hidden" name="cid" value="{{cid}}">
<div class="form-group email"> <div class="form-group email">
<label for="EMAIL">{{#translate}}Existing Email Address{{/translate}}</label> <label for="EMAIL">Existing Email Address</label>
<input type="email" name="EMAIL" id="email" placeholder="" value="{{email}}" readonly> <input type="email" name="EMAIL" id="email" placeholder="" value="{{email}}" readonly>
</div> </div>
<div class="form-group email"> <div class="form-group email">
<label for="EMAIL_NEW">{{#translate}}New Email Address{{/translate}}</label> <label for="EMAIL_NEW">New Email Address</label>
<input type="email" name="EMAIL_NEW" id="email-new" placeholder="{{#translate}}Your new email address{{/translate}}" value="{{email}}"> <input type="email" name="EMAIL_NEW" id="email-new" placeholder="Your new email address" value="{{email}}">
</div> </div>
<p> <p>
{{#translate}}You will receive a confirmation request to your new email address that you need to accept before your email is actually changed{{/translate}} You will receive a confirmation request to your new email address that you need to accept before your email is actually changed
</p> </p>
<button type="submit" style="position: absolute; top: -9999px; left: -9999px;">{{#translate}}Update Email Address{{/translate}}</button> <button type="submit" style="position: absolute; top: -9999px; left: -9999px;">Update Email Address</button>
</form> </form>

View file

@ -12,7 +12,7 @@
{{> subscription_custom_fields}} {{> subscription_custom_fields}}
<button type="submit" style="position: absolute; top: -9999px; left: -9999px;">{{#translate}}Update Profile{{/translate}}</button> <button type="submit" style="position: absolute; top: -9999px; left: -9999px;">Update Profile</button>
</form> </form>
<script src="/moment/moment.min.js"></script> <script src="/moment/moment.min.js"></script>

View file

@ -14,7 +14,7 @@
{{> subscription_custom_fields}} {{> subscription_custom_fields}}
<button type="submit" style="position: absolute; top: -9999px; left: -9999px;">{{#translate}}Subscribe to list{{/translate}}</button> <button type="submit" style="position: absolute; top: -9999px; left: -9999px;">Subscribe to list</button>
</form> </form>
<script> <script>

View file

@ -4,10 +4,10 @@
<input type="hidden" name="ucid" value="{{ucid}}"> <input type="hidden" name="ucid" value="{{ucid}}">
<div class="form-group"> <div class="form-group">
<label for="email">{{#translate}}Email address{{/translate}}</label> <label for="email">Email address</label>
<input type="email" name="email" id="email" placeholder="" value="{{email}}" readonly> <input type="email" name="email" id="email" placeholder="" value="{{email}}" readonly>
</div> </div>
<button type="submit" style="position: absolute; top: -9999px; left: -9999px;">{{#translate}}Unsubscribe{{/translate}}</button> <button type="submit" style="position: absolute; top: -9999px; left: -9999px;">Unsubscribe</button>
</form> </form>

View file

@ -1,13 +1,13 @@
<mj-section> <mj-section>
<mj-column> <mj-column>
<mj-text mj-class="h3"> <mj-text mj-class="h3">
{{#translate}}Almost Finished{{/translate}} Almost Finished
</mj-text> </mj-text>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}We need to confirm your email address. To complete the subscription process, please click the link in the email we just sent you.{{/translate}} We need to confirm your email address. To complete the subscription process, please click the link in the email we just sent you.
</mj-text> </mj-text>
<mj-button mj-class="button" href="{{homepage}}"> <mj-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}} Return to our website
</mj-button> </mj-button>
</mj-column> </mj-column>
</mj-section> </mj-section>

View file

@ -1,13 +1,13 @@
<mj-section> <mj-section>
<mj-column> <mj-column>
<mj-text mj-class="h3"> <mj-text mj-class="h3">
{{#translate}}Almost Finished{{/translate}} Almost Finished
</mj-text> </mj-text>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}We need to confirm your email address. To complete the unsubscription process, please click the link in the email we just sent you.{{/translate}} We need to confirm your email address. To complete the unsubscription process, please click the link in the email we just sent you.
</mj-text> </mj-text>
<mj-button mj-class="button" href="{{homepage}}"> <mj-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}} Return to our website
</mj-button> </mj-button>
</mj-column> </mj-column>
</mj-section> </mj-section>

View file

@ -1,13 +1,13 @@
<mj-section> <mj-section>
<mj-column> <mj-column>
<mj-text mj-class="h3"> <mj-text mj-class="h3">
{{#translate}}Update Your Email Address{{/translate}} Update Your Email Address
</mj-text> </mj-text>
<mj-text> <mj-text>
{{> subscription_manage_address_form}} {{> subscription_manage_address_form}}
</mj-text> </mj-text>
<mj-button mj-class="button" href="#submit"> <mj-button mj-class="button" href="#submit">
{{#translate}}Update Email Address{{/translate}} Update Email Address
</mj-button> </mj-button>
</mj-column> </mj-column>
</mj-section> </mj-section>

View file

@ -1,16 +1,16 @@
<mj-section> <mj-section>
<mj-column> <mj-column>
<mj-text mj-class="h3"> <mj-text mj-class="h3">
{{#translate}}Update Your Preferences{{/translate}} Update Your Preferences
</mj-text> </mj-text>
<mj-text> <mj-text>
{{> subscription_manage_form}}<!-- don't indent me! --> {{> subscription_manage_form}}<!-- don't indent me! -->
</mj-text> </mj-text>
<mj-button mj-class="button" href="#submit"> <mj-button mj-class="button" href="#submit">
{{#translate}}Update Profile{{/translate}} Update Profile
</mj-button> </mj-button>
<mj-text mj-class="p"> <mj-text mj-class="p">
<a href="/subscription/{{lcid}}/unsubscribe/{{cid}}">{{#translate}}Unsubscribe{{/translate}}</a> <a href="/subscription/{{lcid}}/unsubscribe/{{cid}}">Unsubscribe</a>
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>

View file

@ -1,13 +1,13 @@
<mj-section> <mj-section>
<mj-column> <mj-column>
<mj-text mj-class="h3"> <mj-text mj-class="h3">
{{#translate}}Online Unsubscription Is Not Possible{{/translate}} Online Unsubscription Is Not Possible
</mj-text> </mj-text>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}Please contact us at{{/translate}} <a href="mailto:{{contactAddress}}">{{contactAddress}}</a> {{#translate}}to get removed from the list{{/translate}}. Please contact us at <a href="mailto:{{contactAddress}}">{{contactAddress}}</a> to get removed from the list.
</mj-text> </mj-text>
<mj-button mj-class="button" href="{{homepage}}"> <mj-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}} Return to our website
</mj-button> </mj-button>
</mj-column> </mj-column>
</mj-section> </mj-section>

View file

@ -1,13 +1,13 @@
<mj-section> <mj-section>
<mj-column> <mj-column>
<mj-text mj-class="h3"> <mj-text mj-class="h3">
{{#translate}}Subscribe to List{{/translate}} Subscribe to List
</mj-text> </mj-text>
<mj-text> <mj-text>
{{> subscription_subscribe_form}}<!-- don't indent me! --> {{> subscription_subscribe_form}}<!-- don't indent me! -->
</mj-text> </mj-text>
<mj-button mj-class="button" href="#submit"> <mj-button mj-class="button" href="#submit">
{{#translate}}Subscribe to list{{/translate}} Subscribe to list
</mj-button> </mj-button>
</mj-column> </mj-column>
</mj-section> </mj-section>

View file

@ -1,13 +1,13 @@
<mj-section> <mj-section>
<mj-column> <mj-column>
<mj-text mj-class="h3"> <mj-text mj-class="h3">
{{#translate}}Subscription Confirmed{{/translate}} Subscription Confirmed
</mj-text> </mj-text>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}Your subscription to our list has been confirmed.{{/translate}}<br>{{#translate}}Thank you for subscribing!{{/translate}} Your subscription to our list has been confirmed.<br>Thank you for subscribing!
</mj-text> </mj-text>
<mj-button mj-class="button" href="{{homepage}}"> <mj-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}} Return to our website
</mj-button> </mj-button>
</mj-column> </mj-column>
</mj-section> </mj-section>

View file

@ -1,13 +1,13 @@
<mj-section> <mj-section>
<mj-column> <mj-column>
<mj-text mj-class="h3"> <mj-text mj-class="h3">
{{#translate}}Unsubscribe{{/translate}} Unsubscribe
</mj-text> </mj-text>
<mj-text> <mj-text>
{{> subscription_unsubscribe_form}} {{> subscription_unsubscribe_form}}
</mj-text> </mj-text>
<mj-button mj-class="button" href="#submit"> <mj-button mj-class="button" href="#submit">
{{#translate}}Unsubscribe{{/translate}} Unsubscribe
</mj-button> </mj-button>
</mj-column> </mj-column>
</mj-section> </mj-section>

View file

@ -1,13 +1,13 @@
<mj-section> <mj-section>
<mj-column> <mj-column>
<mj-text mj-class="h3"> <mj-text mj-class="h3">
{{#translate}}Unsubscribe Successful{{/translate}} Unsubscribe Successful
</mj-text> </mj-text>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}You have been removed from:{{/translate}} {{title}}. You have been removed from: {{title}}.
</mj-text> </mj-text>
<mj-button mj-class="button" href="{{homepage}}"> <mj-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}} Return to our website
</mj-button> </mj-button>
</mj-column> </mj-column>
</mj-section> </mj-section>

View file

@ -1,13 +1,13 @@
<mj-section> <mj-section>
<mj-column> <mj-column>
<mj-text mj-class="h3"> <mj-text mj-class="h3">
{{#translate}}Profile Updated{{/translate}} Profile Updated
</mj-text> </mj-text>
<mj-text mj-class="p"> <mj-text mj-class="p">
{{#translate}}Your profile information has been updated.{{/translate}} Your profile information has been updated.
</mj-text> </mj-text>
<mj-button mj-class="button" href="{{homepage}}"> <mj-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}} Return to our website
</mj-button> </mj-button>
</mj-column> </mj-column>
</mj-section> </mj-section>

View file

@ -12,21 +12,21 @@
<input type="hidden" name="address" value=""> <input type="hidden" name="address" value="">
<input type="hidden" class="sub-time" name="sub" value=""> <input type="hidden" class="sub-time" name="sub" value="">
{{> subscription_custom_fields}} {{> subscription_custom_fields}}
<button type="submit">{{#translate}}Subscribe to list{{/translate}}</button> <button type="submit">Subscribe to list</button>
</form> </form>
<div class="status"></div> <div class="status"></div>
<div style="display: none;"> <div style="display: none;">
<div class="spinner" data-status-template="spinner"> <div class="spinner" data-status-template="spinner">
<p>{{#translate}}Sending ...{{/translate}}</p> <p>Sending ...</p>
</div> </div>
<div class="info" data-status-template="already-subscribed"> <div class="info" data-status-template="already-subscribed">
<p>{{#translate}}It looks like you are already subscribed to this list.{{/translate}}</p> <p>It looks like you are already subscribed to this list.</p>
</div> </div>
<div class="success" data-status-template="confirm-notice"> <div class="success" data-status-template="confirm-notice">
<h4>{{#translate}}Almost Finished{{/translate}}</h4> <h4>Almost Finished</h4>
<p>{{#translate}}We need to confirm your email address. To complete the subscription process, please click the link in the email we just sent you.{{/translate}}</p> <p>We need to confirm your email address. To complete the subscription process, please click the link in the email we just sent you.</p>
</div> </div>
<div class="error" data-status-template="error"> <div class="error" data-status-template="error">
<p>{message}</p> <p>{message}</p>

View file

@ -58,7 +58,7 @@ function getSendConfigurationPermissionRequiredForSend(campaign, sendConfigurati
} }
} }
let requiredPermission = 'send'; let requiredPermission = 'sendWithoutOverrides';
if (allowedOverride) { if (allowedOverride) {
requiredPermission = 'sendWithAllowedOverrides'; requiredPermission = 'sendWithAllowedOverrides';
} }

View file

@ -39,22 +39,23 @@ function convertToFake(dict) {
return _convertToFake(dict, {}); return _convertToFake(dict, {});
} }
// The langugage labels below are intentionally not localized so that they are always native in the langugae of their speaker (regardless of the currently selected language)
const langCodes = { const langCodes = {
en_US: { en_US: {
getShortLabel: t => t('en'), getShortLabel: t => 'EN',
getLabel: t => t('english'), getLabel: t => 'English',
shortCode: 'en', shortCode: 'en',
longCode: 'en_US' longCode: 'en_US'
}, },
es: { es: {
getShortLabel: t => t('es'), getShortLabel: t => 'ES',
getLabel: t => t('spanish'), getLabel: t => 'Español',
shortCode: 'es', shortCode: 'es',
longCode: 'es' longCode: 'es'
}, },
fake: { fake: {
getShortLabel: t => t('fake'), getShortLabel: t => 'FAKE',
getLabel: t => t('fake-1'), getLabel: t => 'Fake',
shortCode: 'fake', shortCode: 'fake',
longCode: 'fake' longCode: 'fake'
} }