Finished support for triggered campaigns. So far only smoke-tested for subscription trigger.
This commit is contained in:
parent
4f5b2d10e4
commit
b37ad9863c
56 changed files with 416 additions and 213 deletions
|
@ -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.
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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'),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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' +
|
||||||
|
|
|
@ -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>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -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,7 +260,9 @@ 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);
|
||||||
|
if (parseStruct) {
|
||||||
|
const {key, originalKey, value, originalValue, replacement} = parseStruct;
|
||||||
// console.log(`${key} <- ${originalKey} | ${value} <- ${originalValue} | ${fragment} -> ${replacement}`);
|
// console.log(`${key} <- ${originalKey} | ${value} <- ${originalValue} | ${fragment} -> ${replacement}`);
|
||||||
|
|
||||||
source = source.split(fragment).join(replacement);
|
source = source.split(fragment).join(replacement);
|
||||||
|
@ -279,6 +287,7 @@ function processFile(file) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lines = source.split(/\r?\n/g);
|
const lines = source.split(/\r?\n/g);
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
|
|
@ -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
32
server/dbtest.js
Normal 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();
|
|
@ -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,9 +402,11 @@ class CampaignSender {
|
||||||
const responseId = response.split(/\s+/).pop();
|
const responseId = response.split(/\s+/).pop();
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
if (campaign.type === CampaignType.REGULAR || campaign.type === CampaignType.RSS_ENTRY) {
|
||||||
await knex('campaign_messages').insert({
|
await knex('campaign_messages').insert({
|
||||||
campaign: this.campaign.id,
|
campaign: this.campaign.id,
|
||||||
list: listId,
|
list: list.id,
|
||||||
subscription: subscriptionGrouped.id,
|
subscription: subscriptionGrouped.id,
|
||||||
send_configuration: sendConfiguration.id,
|
send_configuration: sendConfiguration.id,
|
||||||
status,
|
status,
|
||||||
|
@ -401,6 +414,16 @@ class CampaignSender {
|
||||||
response_id: responseId,
|
response_id: responseId,
|
||||||
updated: now
|
updated: now
|
||||||
});
|
});
|
||||||
|
|
||||||
|
} else if (campaign.type = CampaignType.TRIGGERED) {
|
||||||
|
await knex('queued')
|
||||||
|
.where({
|
||||||
|
campaign: this.campaign.id,
|
||||||
|
list: list.id,
|
||||||
|
subscription: subscriptionGrouped.id
|
||||||
|
})
|
||||||
|
.del();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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']);
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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];
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,6 +176,7 @@ async function scheduleCampaigns() {
|
||||||
|
|
||||||
campaignSchedulerRunning = true;
|
campaignSchedulerRunning = true;
|
||||||
|
|
||||||
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
let campaignId = 0;
|
let campaignId = 0;
|
||||||
|
|
||||||
|
@ -200,11 +202,60 @@ async function scheduleCampaigns() {
|
||||||
break;
|
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();
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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, '<=', new Date(currentTs - trigger.seconds));
|
||||||
sqlQry = sqlQry.where(column, '<=', currentTs - trigger.seconds);
|
|
||||||
|
|
||||||
if (trigger.last_check !== null) {
|
if (trigger.last_check !== null) {
|
||||||
sqlQry = sqlQry.where(column, '>', trigger.last_check);
|
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;
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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">
|
||||||
|
|
||||||
|
|
|
@ -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">
|
||||||
|
|
||||||
|
|
|
@ -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">
|
||||||
|
|
||||||
|
|
|
@ -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">
|
||||||
|
|
||||||
|
|
|
@ -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">
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}}}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}}}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}}}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}}}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}}}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}}}
|
||||||
|
|
|
@ -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}} '-----BEGIN PGP PUBLIC KEY BLOCK-----'">{{value}}</textarea>
|
<textarea class="form-control gpg-text" rows="4" name="{{key}}" placeholder="Begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'">{{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>
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -58,7 +58,7 @@ function getSendConfigurationPermissionRequiredForSend(campaign, sendConfigurati
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let requiredPermission = 'send';
|
let requiredPermission = 'sendWithoutOverrides';
|
||||||
if (allowedOverride) {
|
if (allowedOverride) {
|
||||||
requiredPermission = 'sendWithAllowedOverrides';
|
requiredPermission = 'sendWithAllowedOverrides';
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue