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

@ -89,6 +89,7 @@ mysql:
port: 3306
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
# If the MySQL server runs on the same server as Mailtrain, use 'local'
timezone: local
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);
}
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)) {
return;
}
const list = this.listsById.get(listId);
const subscriptionGrouped = await subscriptions.getByEmail(contextHelpers.getAdminContext(), list.id, email);
const flds = this.listsFieldsGrouped.get(listId);
const flds = this.listsFieldsGrouped.get(list.id);
const campaign = this.campaign;
const mergeTags = fields.getMergeTags(flds, subscriptionGrouped, this._getExtraTags(campaign));
@ -391,16 +402,28 @@ class CampaignSender {
const responseId = response.split(/\s+/).pop();
const now = new Date();
await knex('campaign_messages').insert({
campaign: this.campaign.id,
list: listId,
subscription: subscriptionGrouped.id,
send_configuration: sendConfiguration.id,
status,
response,
response_id: responseId,
updated: now
});
if (campaign.type === CampaignType.REGULAR || campaign.type === CampaignType.RSS_ENTRY) {
await knex('campaign_messages').insert({
campaign: this.campaign.id,
list: list.id,
subscription: subscriptionGrouped.id,
send_configuration: sendConfiguration.id,
status,
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';
const config = require('config');
const moment = require('moment');
const knex = require('knex')({
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: {
directory: __dirname + '/../setup/knex/migrations'
}
//, debug: true
//, debug: true
});
module.exports = knex;

View file

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

View file

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

View file

@ -59,7 +59,7 @@ fieldTypes.date = {
afterJSON: (groupedField, entity) => {
const key = getFieldColumn(groupedField);
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)
@ -69,7 +69,7 @@ fieldTypes.birthday = {
afterJSON: (groupedField, entity) => {
const key = getFieldColumn(groupedField);
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)
@ -539,6 +539,7 @@ async function _update(tx, listId, existing, filteredEntity) {
}
}
console.log(filteredEntity);
await tx(getSubscriptionTableName(listId)).where('id', existing.id).update(filteredEntity);
if ('status' in filteredEntity) {

View file

@ -77,6 +77,7 @@ async function create(context, campaignId, entity) {
const filteredEntity = filterObject(entity, allowedKeys);
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 id = ids[0];

View file

@ -235,6 +235,7 @@ router.postAsync('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, as
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 email = cleanupFromPost(req.body.EMAIL);

View file

@ -15,6 +15,7 @@ const workerProcesses = new Map();
const idleWorkers = [];
let campaignSchedulerRunning = false;
let queuedSchedulerRunning = false;
let workerSchedulerRunning = false;
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}] }
let workerSchedulerCont = null;
let queuedLastId = 0;
function messagesProcessed(workerId) {
@ -151,7 +153,6 @@ async function processCampaign(campaignId) {
messageQueueCont.set(campaignId, resolve);
});
// noinspection JSIgnoredPromiseFromCall
setImmediate(scheduleWorkers);
await nextBatchNeeded;
@ -175,36 +176,86 @@ async function scheduleCampaigns() {
campaignSchedulerRunning = true;
while (true) {
let campaignId = 0;
try {
while (true) {
let campaignId = 0;
await knex.transaction(async tx => {
const scheduledCampaign = await tx('campaigns')
.whereIn('campaigns.type', [CampaignType.REGULAR, CampaignType.RSS_ENTRY])
.where('campaigns.status', CampaignStatus.SCHEDULED)
.where(qry => qry.whereNull('campaigns.scheduled').orWhere('campaigns.scheduled', '<=', new Date()))
.select(['id'])
.first();
await knex.transaction(async tx => {
const scheduledCampaign = await tx('campaigns')
.whereIn('campaigns.type', [CampaignType.REGULAR, CampaignType.RSS_ENTRY])
.where('campaigns.status', CampaignStatus.SCHEDULED)
.where(qry => qry.whereNull('campaigns.scheduled').orWhere('campaigns.scheduled', '<=', new Date()))
.select(['id'])
.first();
if (scheduledCampaign) {
await tx('campaigns').where('id', scheduledCampaign.id).update({status: CampaignStatus.SENDING});
campaignId = scheduledCampaign.id;
if (scheduledCampaign) {
await tx('campaigns').where('id', scheduledCampaign.id).update({status: CampaignStatus.SENDING});
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;
}
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) {
return await new Promise((resolve, reject) => {
log.verbose('Senders', `Spawning worker process ${workerId}`);
@ -251,6 +302,9 @@ function periodicCampaignsCheck() {
// noinspection JSIgnoredPromiseFromCall
scheduleCampaigns();
// noinspection JSIgnoredPromiseFromCall
processQueued();
setTimeout(periodicCampaignsCheck, campaignsCheckPeriod);
}
@ -286,5 +340,6 @@ async function init() {
periodicCampaignsCheck();
}
// noinspection JSIgnoredPromiseFromCall
init();

View file

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

View file

@ -15,17 +15,21 @@ const triggerCheckPeriod = 15 * 1000;
const triggerFirePeriod = 60 * 1000;
async function start() {
async function run() {
while (true) {
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) {
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) {
const addSegmentQuery = cpgList.segment ? await segments.getQueryGeneratorTx(tx, cpgList.list, cpgList.segment) : () => {
@ -36,8 +40,10 @@ async function start() {
.leftJoin(
function () {
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)
.select(['id', 'subscription'])
.as('related_trigger_messages');
},
'related_trigger_messages.subscription', subsTable + '.id'
@ -45,7 +51,7 @@ async function start() {
.where(function () {
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');
let column;
@ -129,20 +135,18 @@ async function start() {
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;
for (const subscriber of subscribers) {
await tx('trigger_messages').insert({
campaign: campaign.id,
trigger: trigger.id,
list: cpgList.list,
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;
});
@ -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;

View file

@ -246,10 +246,20 @@ async function migrateSubscriptions(knex) {
const fields = await knex('custom_fields').where('list', list.id);
const info = await knex('subscription__' + list.id).columnInfo();
for (const field of fields) {
if (field.column != 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;
@ -644,6 +654,7 @@ async function migrateReports(knex) {
await knex.schema.table('reports', table => {
table.dropForeign('report_template', 'report_template_ibfk_1');
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) {
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('source', 'trigger');
});
@ -1174,7 +1190,7 @@ async function migrateImporter(knex) {
table.text('settings', 'longtext');
table.integer('mapping_type').unsigned().notNullable();
table.text('mapping', 'longtext');
table.dateTime('last_run');
table.timestamp('last_run').nullable().defaultTo(null);
table.text('error');
table.timestamp('created').defaultTo(knex.fn.now());
});
@ -1190,7 +1206,7 @@ async function migrateImporter(knex) {
table.integer('processed').defaultTo(0);
table.text('error');
table.timestamp('created').defaultTo(knex.fn.now());
table.dateTime('finished');
table.timestamp('finished').nullable().defaultTo(null);
});
await knex.schema.createTable('import_failed', table => {

View file

@ -6,7 +6,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<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="icon" href="{{publicPath}}static/favicon.ico">

View file

@ -6,7 +6,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<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="icon" href="{{publicPath}}static/favicon.ico">

View file

@ -6,7 +6,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<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="icon" href="{{publicPath}}static/favicon.ico">

View file

@ -6,7 +6,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<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="icon" href="{{publicPath}}static/favicon.ico">

View file

@ -6,7 +6,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<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="icon" href="{{publicPath}}static/favicon.ico">

View file

@ -1,23 +1,23 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Email address already registered{{/translate}}
Email address already registered
</mj-text>
<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-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-class="p">
{{#translate}}If you want to modify your subscription then you can {{/translate}}
<a href="{{preferencesUrl}}">{{#translate}}manage your preferences{{/translate}}</a> {{#translate}}or{{/translate}} <a href="{{unsubscribeUrl}}">{{#translate}}unsubscribe here{{/translate}}</a>.
If you want to modify your subscription then you can
<a href="{{preferencesUrl}}">manage your preferences</a> or <a href="{{unsubscribeUrl}}">unsubscribe here</a>.
</mj-text>
<mj-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}}
Return to our website
</mj-button>
<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>
</mj-text>
</mj-column>

View file

@ -1,18 +1,18 @@
{{{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}}}

View file

@ -1,16 +1,16 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Please Confirm Subscription Address Change{{/translate}}
Please Confirm Subscription Address Change
</mj-text>
<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-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-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>
</mj-text>
</mj-column>

View file

@ -1,10 +1,10 @@
{{{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}}}

View file

@ -1,16 +1,16 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Please Confirm Subscription{{/translate}}
Please Confirm Subscription
</mj-text>
<mj-button mj-class="button" href="{{confirmUrl}}">
{{#translate}}Yes, subscribe me to this list{{/translate}}
Yes, subscribe me to this list
</mj-button>
<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-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>
</mj-text>
</mj-column>

View file

@ -1,10 +1,10 @@
{{{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}}}

View file

@ -1,16 +1,16 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Please Confirm Unsubscription{{/translate}}
Please Confirm Unsubscription
</mj-text>
<mj-button mj-class="button" href="{{confirmUrl}}">
{{#translate}}Yes, unsubscribe me from this list{{/translate}}
Yes, unsubscribe me from this list
</mj-button>
<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-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>
</mj-text>
</mj-column>

View file

@ -1,10 +1,10 @@
{{{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}}}

View file

@ -1,17 +1,17 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Subscription Confirmed{{/translate}}
Subscription Confirmed
</mj-text>
<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}}
<a href="{{preferencesUrl}}">{{#translate}}manage your preferences{{/translate}}</a> {{#translate}}or{{/translate}} <a href="{{unsubscribeUrl}}">{{#translate}}unsubscribe here{{/translate}}</a>.
Your subscription to our list has been confirmed. If you want to modify your subscription then you can
<a href="{{preferencesUrl}}">manage your preferences</a> or <a href="{{unsubscribeUrl}}">unsubscribe here</a>.
</mj-text>
<mj-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}}
Return to our website
</mj-button>
<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>
</mj-text>
</mj-column>

View file

@ -1,16 +1,16 @@
{{{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}}}

View file

@ -1,16 +1,16 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}You Are Now Unsubscribed{{/translate}}
You Are Now Unsubscribed
</mj-text>
<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-button mj-class="button" href="{{subscribeUrl}}">
{{#translate}}Subscribe{{/translate}}
Subscribe
</mj-button>
<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>
</mj-text>
</mj-column>

View file

@ -1,12 +1,12 @@
{{{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}}}

View file

@ -2,11 +2,11 @@
{{#if typeSubscriptionEmail}}
<div class="form-group email">
<label for="EMAIL">{{#translate}}Email Address{{/translate}}</label>
<label for="EMAIL">Email Address</label>
{{#if ../isManagePreferences}}
<div class="input-group">
<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>
{{else}}
<input type="email" name="EMAIL" id="email" placeholder="" value="{{../email}}" required>
@ -53,11 +53,11 @@
<div class="form-group gpg {{key}}">
<label for="{{key}}">{{name}}</label>
{{#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}}
<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">
{{#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>
</div>
{{/if}}
@ -95,7 +95,7 @@
<label for="{{key}}">{{name}}</label>
<select name="{{key}}" class="form-control">
<option value="">
{{#translate}}Select{{/translate}}
Select
</option>
{{#each options}}
<option value="{{key}}" {{#if value}} selected {{/if}}>{{name}}</option>
@ -131,7 +131,7 @@
<label for="{{key}}">{{name}}</label>
<select name="{{key}}" class="form-control">
<option value="">
{{#translate}}Select{{/translate}}
Select
</option>
{{#each options}}
<option value="{{key}}" {{#if value}} selected {{/if}}>{{name}}</option>

View file

@ -2,13 +2,13 @@
{{#if isConfirmNotice}}
<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>
{{/if}}
{{#if needsJsWarning}}
<div class="alert alert-danger js-warning" role="alert">
<strong>{{#translate}}Warning!{{/translate}}</strong>
{{#translate}}JavaScript must be enabled in order for this form to work{{/translate}}
<strong>Warning!</strong>
JavaScript must be enabled in order for this form to work
</div>
{{/if}}

View file

@ -4,19 +4,19 @@
<input type="hidden" name="cid" value="{{cid}}">
<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>
</div>
<div class="form-group email">
<label for="EMAIL_NEW">{{#translate}}New Email Address{{/translate}}</label>
<input type="email" name="EMAIL_NEW" id="email-new" placeholder="{{#translate}}Your new email address{{/translate}}" value="{{email}}">
<label for="EMAIL_NEW">New Email Address</label>
<input type="email" name="EMAIL_NEW" id="email-new" placeholder="Your new email address" value="{{email}}">
</div>
<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>
<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>

View file

@ -12,7 +12,7 @@
{{> 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>
<script src="/moment/moment.min.js"></script>

View file

@ -14,7 +14,7 @@
{{> 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>
<script>

View file

@ -4,10 +4,10 @@
<input type="hidden" name="ucid" value="{{ucid}}">
<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>
</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>

View file

@ -1,13 +1,13 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Almost Finished{{/translate}}
Almost Finished
</mj-text>
<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-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}}
Return to our website
</mj-button>
</mj-column>
</mj-section>

View file

@ -1,13 +1,13 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Almost Finished{{/translate}}
Almost Finished
</mj-text>
<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-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}}
Return to our website
</mj-button>
</mj-column>
</mj-section>

View file

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

View file

@ -1,16 +1,16 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Update Your Preferences{{/translate}}
Update Your Preferences
</mj-text>
<mj-text>
{{> subscription_manage_form}}<!-- don't indent me! -->
</mj-text>
<mj-button mj-class="button" href="#submit">
{{#translate}}Update Profile{{/translate}}
Update Profile
</mj-button>
<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-column>
</mj-section>

View file

@ -1,13 +1,13 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Online Unsubscription Is Not Possible{{/translate}}
Online Unsubscription Is Not Possible
</mj-text>
<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-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}}
Return to our website
</mj-button>
</mj-column>
</mj-section>

View file

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

View file

@ -1,13 +1,13 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
{{#translate}}Subscription Confirmed{{/translate}}
Subscription Confirmed
</mj-text>
<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-button mj-class="button" href="{{homepage}}">
{{#translate}}Return to our website{{/translate}}
Return to our website
</mj-button>
</mj-column>
</mj-section>

View file

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

View file

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

View file

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

View file

@ -12,21 +12,21 @@
<input type="hidden" name="address" value="">
<input type="hidden" class="sub-time" name="sub" value="">
{{> subscription_custom_fields}}
<button type="submit">{{#translate}}Subscribe to list{{/translate}}</button>
<button type="submit">Subscribe to list</button>
</form>
<div class="status"></div>
<div style="display: none;">
<div class="spinner" data-status-template="spinner">
<p>{{#translate}}Sending ...{{/translate}}</p>
<p>Sending ...</p>
</div>
<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 class="success" data-status-template="confirm-notice">
<h4>{{#translate}}Almost Finished{{/translate}}</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>
<h4>Almost Finished</h4>
<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 class="error" data-status-template="error">
<p>{message}</p>