- Refactoring of the mail sending part. Mail queue (table 'queued') is now used also for all test emails.

- More options how to send test emails.
- Fixed problems with pausing a campaign (#593)
- Started rework of transactional sender of templates (#606), however this contains functionality regression at the moment because it does not interpret templates as HBS. It needs HBS option for templates as described in https://github.com/Mailtrain-org/mailtrain/issues/611#issuecomment-502345227

TODO:
- detect sending errors connected to not able to contact the mailer and pause/retry campaing and queued sending - don't mark the recipients as BOUNCED
- add FAILED campaign state and fall into it if sending to campaign consistently fails (i.e. the error with sending is not temporary)
- if the same happends for queued email, delete the message
This commit is contained in:
Tomas Bures 2019-06-25 07:18:06 +02:00
parent ff66a6c39e
commit 30b361290b
42 changed files with 1366 additions and 786 deletions

View file

@ -41,16 +41,17 @@ async function search(context, offset, limit, search) {
async function add(context, email) {
enforce(email, 'Email has to be set');
return await knex.transaction(async tx => {
shares.enforceGlobalPermission(context, 'manageBlacklist');
const existing = await tx('blacklist').where('email', email).first();
if (!existing) {
await tx('blacklist').insert({email});
}
shares.enforceGlobalPermission(context, 'manageBlacklist');
try {
await knex('blacklist').insert({email});
await activityLog.logBlacklistActivity(BlacklistActivityType.ADD, email);
});
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
} else {
throw err;
}
}
}
async function remove(context, email) {

View file

@ -21,12 +21,14 @@ const {LinkId} = require('./links');
const feedcheck = require('../lib/feedcheck');
const contextHelpers = require('../lib/context-helpers');
const {convertFileURLs} = require('../lib/campaign-content');
const {CampaignSender, MessageType} = require('../lib/campaign-sender');
const lists = require('./lists');
const {EntityActivityType, CampaignActivityType} = require('../../shared/activity-log');
const activityLog = require('../lib/activity-log');
const allowedKeysCommon = ['name', 'description', 'segment', 'namespace',
'send_configuration', 'from_name_override', 'from_email_override', 'reply_to_override', 'subject_override', 'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url'];
'send_configuration', 'from_name_override', 'from_email_override', 'reply_to_override', 'subject', 'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url'];
const allowedKeysCreate = new Set(['type', 'source', ...allowedKeysCommon]);
const allowedKeysCreateRssEntry = new Set(['type', 'source', 'parent', ...allowedKeysCommon]);
@ -168,7 +170,7 @@ async function listTestUsersDTAjax(context, campaignId, params) {
let subsQry;
if (subsQrys.length === 1) {
const subsUnionSql = '(' + subsQrys[0].sql + ') as `test_subscriptions`'
const subsUnionSql = '(' + subsQrys[0].sql + ') as `test_subscriptions`';
subsQry = knex.raw(subsUnionSql, subsQrys[0].bindings);
} else {
@ -342,7 +344,7 @@ async function rawGetByTx(tx, key, id) {
.groupBy('campaigns.id')
.select([
'campaigns.id', 'campaigns.cid', 'campaigns.name', 'campaigns.description', 'campaigns.namespace', 'campaigns.status', 'campaigns.type', 'campaigns.source',
'campaigns.send_configuration', 'campaigns.from_name_override', 'campaigns.from_email_override', 'campaigns.reply_to_override', 'campaigns.subject_override',
'campaigns.send_configuration', 'campaigns.from_name_override', 'campaigns.from_email_override', 'campaigns.reply_to_override', 'campaigns.subject',
'campaigns.data', 'campaigns.click_tracking_disabled', 'campaigns.open_tracking_disabled', 'campaigns.unsubscribe_url', 'campaigns.scheduled',
'campaigns.delivered', 'campaigns.unsubscribed', 'campaigns.bounced', 'campaigns.complained', 'campaigns.blacklisted', 'campaigns.opened', 'campaigns.clicks',
knex.raw(`GROUP_CONCAT(CONCAT_WS(\':\', campaign_lists.list, campaign_lists.segment) ORDER BY campaign_lists.id SEPARATOR \';\') as lists`)
@ -632,21 +634,32 @@ async function remove(context, id) {
});
}
async function enforceSendPermissionTx(tx, context, campaignId) {
async function enforceSendPermissionTx(tx, context, campaignOrCampaignId, isToTestUsers, listId) {
let campaign;
if (typeof campaignId === 'object') {
campaign = campaignId;
if (typeof campaignOrCampaignId === 'object') {
campaign = campaignOrCampaignId;
} else {
campaign = await getByIdTx(tx, context, campaignId, false);
campaign = await getByIdTx(tx, context, campaignOrCampaignId, false);
}
const sendConfiguration = await sendConfigurations.getByIdTx(tx, contextHelpers.getAdminContext(), campaign.send_configuration, false, false);
const requiredPermission = getSendConfigurationPermissionRequiredForSend(campaign, sendConfiguration);
const requiredSendConfigurationPermission = getSendConfigurationPermissionRequiredForSend(campaign, sendConfiguration);
await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', campaign.send_configuration, requiredSendConfigurationPermission);
await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', campaign.send_configuration, requiredPermission);
await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaign.id, 'send');
const requiredListAndCampaignPermission = isToTestUsers ? 'sendToTestUsers' : 'send';
await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaign.id, requiredListAndCampaignPermission);
if (listId) {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, requiredListAndCampaignPermission);
} else {
for (const listIds of campaign.lists) {
await shares.enforceEntityPermissionTx(tx, context, 'list', listIds.list, requiredListAndCampaignPermission);
}
}
}
@ -845,12 +858,9 @@ async function getSubscribersQueryGeneratorTx(tx, campaignId) {
async function _changeStatus(context, campaignId, permittedCurrentStates, newState, invalidStateMessage, scheduled = null) {
await knex.transaction(async tx => {
const entity = await tx('campaigns').where('id', campaignId).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
}
const entity = await getByIdTx(tx, context, campaignId, false);
await enforceSendPermissionTx(tx, context, entity);
await enforceSendPermissionTx(tx, context, entity, false);
if (!permittedCurrentStates.includes(entity.status)) {
throw new interoperableErrors.InvalidStateError(invalidStateMessage);
@ -869,11 +879,11 @@ async function _changeStatus(context, campaignId, permittedCurrentStates, newSta
async function start(context, campaignId, startAt) {
await _changeStatus(context, campaignId, [CampaignStatus.IDLE, CampaignStatus.SCHEDULED, CampaignStatus.PAUSED, CampaignStatus.FINISHED], CampaignStatus.SCHEDULED, 'Cannot start campaign until it is in IDLE or PAUSED state', startAt);
await _changeStatus(context, campaignId, [CampaignStatus.IDLE, CampaignStatus.PAUSED, CampaignStatus.FINISHED], CampaignStatus.SCHEDULED, 'Cannot start campaign until it is in IDLE, PAUSED, or FINISHED state', startAt);
}
async function stop(context, campaignId) {
await _changeStatus(context, campaignId, [CampaignStatus.SCHEDULED], CampaignStatus.PAUSED, 'Cannot stop campaign until it is in SCHEDULED state');
await _changeStatus(context, campaignId, [CampaignStatus.SCHEDULED, CampaignStatus.SENDING], CampaignStatus.PAUSING, 'Cannot stop campaign until it is in SCHEDULED or SENDING state');
}
async function reset(context, campaignId) {
@ -944,6 +954,103 @@ async function fetchRssCampaign(context, cid) {
});
}
async function testSend(context, data) {
// Though it's a bit counterintuitive, this handles also test sends of a template (i.e. without any campaign id)
await knex.transaction(async tx => {
const processSubscriber = async (sendConfigurationId, listId, subscriptionId, messageData) => {
await CampaignSender.queueMessageTx(tx, sendConfigurationId, listId, subscriptionId, MessageType.TEST, messageData);
await activityLog.logEntityActivity('campaign', CampaignActivityType.TEST_SEND, campaignId, {list: listId, subscription: subscriptionId});
};
const campaignId = data.campaignId;
if (campaignId) { // This means we are sending a campaign
/*
Data coming from the client:
- html, text
- subjectPrepend, subjectAppend
- listCid, subscriptionCid
- listId, segmentId
*/
const campaign = await getByIdTx(tx, context, campaignId, false);
const sendConfigurationId = campaign.send_configuration;
const messageData = {
campaignId: campaignId,
subject: data.subjectPrepend + campaign.subject + data.subjectAppend,
html: data.html, // The html and text may be undefined
text: data.text,
attachments: []
};
const attachments = await files.listTx(tx, contextHelpers.getAdminContext(), 'campaign', 'attachment', campaignId);
for (const attachment of attachments) {
messageData.attachments.push({
filename: attachment.originalname,
path: files.getFilePath('campaign', 'attachment', campaign.id, attachment.filename),
id: attachment.id
});
}
let listId = data.listId;
if (!listId && data.listCid) {
const list = await lists.getByCidTx(tx, context, data.listCid);
listId = list.id;
}
const segmentId = data.segmentId;
if (listId) {
await enforceSendPermissionTx(tx, context, campaign, true, listId);
if (data.subscriptionCid) {
const subscriber = await subscriptions.getByCidTx(tx, context, listId, data.subscriptionCid);
await processSubscriber(sendConfigurationId, listId, subscriber.id, messageData);
} else {
const subscribers = await subscriptions.listTestUsersTx(tx, context, listId, segmentId);
for (const subscriber of subscribers) {
await processSubscriber(sendConfigurationId, listId, subscriber.id, messageData);
}
}
} else {
for (const lstSeg of campaign.lists) {
await enforceSendPermissionTx(tx, context, campaign, true, lstSeg.list);
const subscribers = await subscriptions.listTestUsersTx(tx, context, lstSeg.list, segmentId);
for (const subscriber of subscribers) {
await processSubscriber(sendConfigurationId, lstSeg.list, subscriber.id, messageData);
}
}
}
} else { // This means we are sending a template
/*
Data coming from the client:
- html, text
- listCid, subscriptionCid, sendConfigurationId
*/
const messageData = {
subject: 'Test',
html: data.html,
text: data.text
};
const list = await lists.getByCidTx(tx, context, data.listCid);
const subscriber = await subscriptions.getByCidTx(tx, context, list.id, data.subscriptionCid);
await processSubscriber(data.sendConfigurationId, list.id, subscriber.id, messageData);
}
});
senders.scheduleCheck();
}
module.exports.Content = Content;
module.exports.hash = hash;
@ -986,4 +1093,6 @@ module.exports.rawGetByTx = rawGetByTx;
module.exports.getTrackingSettingsByCidTx = getTrackingSettingsByCidTx;
module.exports.getStatisticsOpened = getStatisticsOpened;
module.exports.fetchRssCampaign = fetchRssCampaign;
module.exports.fetchRssCampaign = fetchRssCampaign;
module.exports.testSend = testSend;

View file

@ -45,7 +45,7 @@ async function listDTAjax(context, type, subType, entityId, params) {
await shares.enforceEntityPermission(context, type, entityId, getFilesPermission(type, subType, 'view'));
return await dtHelpers.ajaxList(
params,
builder => builder.from(getFilesTable(type, subType)).where({entity: entityId}),
builder => builder.from(getFilesTable(type, subType)).where({entity: entityId, delete_pending: false}),
['id', 'originalname', 'filename', 'size', 'created']
);
}
@ -53,7 +53,7 @@ async function listDTAjax(context, type, subType, entityId, params) {
async function listTx(tx, context, type, subType, entityId) {
enforceTypePermitted(type, subType);
await shares.enforceEntityPermissionTx(tx, context, type, entityId, getFilesPermission(type, subType, 'view'));
return await tx(getFilesTable(type, subType)).where({entity: entityId}).select(['id', 'originalname', 'filename', 'size', 'created']).orderBy('originalname', 'asc');
return await tx(getFilesTable(type, subType)).where({entity: entityId, delete_pending: false}).select(['id', 'originalname', 'filename', 'size', 'created']).orderBy('originalname', 'asc');
}
async function list(context, type, subType, entityId) {
@ -65,7 +65,7 @@ async function list(context, type, subType, entityId) {
async function getFileById(context, type, subType, id) {
enforceTypePermitted(type, subType);
const file = await knex.transaction(async tx => {
const file = await tx(getFilesTable(type, subType)).where('id', id).first();
const file = await tx(getFilesTable(type, subType)).where({id: id, delete_pending: false}).first();
await shares.enforceEntityPermissionTx(tx, context, type, file.entity, getFilesPermission(type, subType, 'view'));
return file;
});
@ -85,7 +85,7 @@ async function _getFileBy(context, type, subType, entityId, key, value) {
enforceTypePermitted(type, subType);
const file = await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, type, entityId, getFilesPermission(type, subType, 'view'));
const file = await tx(getFilesTable(type, subType)).where({entity: entityId, [key]: value}).first();
const file = await tx(getFilesTable(type, subType)).where({entity: entityId, delete_pending: false, [key]: value}).first();
return file;
});
@ -155,7 +155,7 @@ async function createFiles(context, type, subType, entityId, files, replacementB
await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, type, entityId, getFilesPermission(type, subType, 'manage'));
const existingNamesRows = await tx(getFilesTable(type, subType)).where('entity', entityId).select(['id', 'filename', 'originalname']);
const existingNamesRows = await tx(getFilesTable(type, subType)).where({entity: entityId, delete_pending: false}).select(['id', 'filename', 'originalname']);
const existingNameSet = new Set();
for (const row of existingNamesRows) {
@ -275,18 +275,49 @@ async function createFiles(context, type, subType, entityId, files, replacementB
}
}
async function lockTx(tx, type, subType, id) {
enforceTypePermitted(type, subType);
const filesTableName = getFilesTable(type, subType);
await tx(filesTableName).where('id', id).increment('lock_count');
}
async function unlockTx(tx, type, subType, id) {
enforceTypePermitted(type, subType);
const filesTableName = getFilesTable(type, subType);
const file = await tx(filesTableName).where('id', id).first();
enforce(file, `File ${id} not found`);
enforce(file.lock_count > 0, `Corrupted lock count at file ${id}`);
if (file.lock_count === 1 && file.delete_pending) {
await tx(filesTableName).where('id', id).del();
const filePath = getFilePath(type, subType, file.entity, file.filename);
await fs.removeAsync(filePath);
} else {
await tx(filesTableName).where('id', id).update({lock_count: file.lock_count - 1});
}
}
async function removeFile(context, type, subType, id) {
enforceTypePermitted(type, subType);
const file = await knex.transaction(async tx => {
const file = await tx(getFilesTable(type, subType)).where('id', id).select('entity', 'filename').first();
await knex.transaction(async tx => {
const filesTableName = getFilesTable(type, subType);
const file = await tx(filesTableName).where('id', id).first();
await shares.enforceEntityPermissionTx(tx, context, type, file.entity, getFilesPermission(type, subType, 'manage'));
await tx(getFilesTable(type, subType)).where('id', id).del();
return {filename: file.filename, entity: file.entity};
});
const filePath = getFilePath(type, subType, file.entity, file.filename);
await fs.removeAsync(filePath);
if (!file.lock_count) {
await tx(filesTableName).where('id', file.id).del();
const filePath = getFilePath(type, subType, file.entity, file.filename);
await fs.removeAsync(filePath);
} else {
await tx(filesTableName).where('id', file.id).update({delete_pending: true});
}
});
}
async function copyAllTx(tx, context, fromType, fromSubType, fromEntityId, toType, toSubType, toEntityId) {
@ -296,7 +327,7 @@ async function copyAllTx(tx, context, fromType, fromSubType, fromEntityId, toTyp
enforceTypePermitted(toType, toSubType);
await shares.enforceEntityPermissionTx(tx, context, toType, toEntityId, getFilesPermission(toType, toSubType, 'manage'));
const rows = await tx(getFilesTable(fromType, fromSubType)).where({entity: fromEntityId});
const rows = await tx(getFilesTable(fromType, fromSubType)).where({entity: fromEntityId, delete_pending: false});
for (const row of rows) {
const fromFilePath = getFilePath(fromType, fromSubType, fromEntityId, row.filename);
const toFilePath = getFilePath(toType, toSubType, toEntityId, row.filename);
@ -339,4 +370,6 @@ module.exports.getFileUrl = getFileUrl;
module.exports.getFilePath = getFilePath;
module.exports.copyAllTx = copyAllTx;
module.exports.removeAllTx = removeAllTx;
module.exports.lockTx = lockTx;
module.exports.unlockTx = unlockTx;
module.exports.ReplacementBehavior = ReplacementBehavior;

View file

@ -11,6 +11,7 @@ const he = require('he');
const { getPublicUrl } = require('../lib/urls');
const tools = require('../lib/tools');
const shortid = require('shortid');
const {enforce} = require('../lib/helpers');
const LinkId = {
OPEN: -1,
@ -103,16 +104,16 @@ async function countLink(remoteIp, userAgent, campaignCid, listCid, subscription
}
async function addOrGet(campaignId, url) {
return await knex.transaction(async tx => {
const link = await tx('links').select(['id', 'cid']).where({
campaign: campaignId,
url
}).first();
const link = await knex('links').select(['id', 'cid']).where({
campaign: campaignId,
url
}).first();
if (!link) {
let cid = shortid.generate();
if (!link) {
let cid = shortid.generate();
const ids = await tx('links').insert({
try {
const ids = await knex('links').insert({
campaign: campaignId,
cid,
url
@ -122,10 +123,21 @@ async function addOrGet(campaignId, url) {
id: ids[0],
cid
};
} else {
return link;
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
const link = await knex('links').select(['id', 'cid']).where({
campaign: campaignId,
url
}).first();
enforce(link);
return link;
}
}
});
} else {
return link;
}
}
async function updateLinks(campaign, list, subscription, mergeTags, message) {

View file

@ -14,7 +14,7 @@ const mailers = require('../lib/mailers');
const senders = require('../lib/senders');
const dependencyHelpers = require('../lib/dependency-helpers');
const allowedKeys = new Set(['name', 'description', 'from_email', 'from_email_overridable', 'from_name', 'from_name_overridable', 'reply_to', 'reply_to_overridable', 'subject', 'subject_overridable', 'x_mailer', 'verp_hostname', 'verp_disable_sender_header', 'mailer_type', 'mailer_settings', 'namespace']);
const allowedKeys = new Set(['name', 'description', 'from_email', 'from_email_overridable', 'from_name', 'from_name_overridable', 'reply_to', 'reply_to_overridable', 'x_mailer', 'verp_hostname', 'verp_disable_sender_header', 'mailer_type', 'mailer_settings', 'namespace']);
const allowedMailerTypes = new Set(Object.values(MailerType));
@ -75,7 +75,7 @@ async function _getByTx(tx, context, key, id, withPermissions, withPrivateData)
entity.mailer_settings = JSON.parse(entity.mailer_settings);
} else {
entity = await tx('send_configurations').where(key, id).select(
['id', 'name', 'cid', 'description', 'from_email', 'from_email_overridable', 'from_name', 'from_name_overridable', 'reply_to', 'reply_to_overridable', 'subject', 'subject_overridable']
['id', 'name', 'cid', 'description', 'from_email', 'from_email_overridable', 'from_name', 'from_name_overridable', 'reply_to', 'reply_to_overridable']
).first();
if (!entity) {

View file

@ -19,6 +19,8 @@ const lists = require('./lists');
const allowedKeysBase = new Set(['email', 'tz', 'is_test', 'status']);
const TEST_USERS_LIST_LIMIT = 1000;
const fieldTypes = {};
const Cardinality = {
@ -409,6 +411,32 @@ async function list(context, listId, grouped, offset, limit) {
});
}
async function listTestUsersTx(tx, context, listId, segmentId, grouped) {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions');
let entitiesQry = tx(getSubscriptionTableName(listId)).orderBy('id', 'asc').where('is_test', true).limit(TEST_USERS_LIST_LIMIT);
if (segmentId) {
const addSegmentQuery = await segments.getQueryGeneratorTx(tx, listId, segmentId);
entitiesQry = entitiesQry.where(function() {
addSegmentQuery(this);
});
}
const entities = await entitiesQry;
if (grouped) {
const groupedFieldsMap = await getGroupedFieldsMapTx(tx, listId);
for (const entity of entities) {
groupSubscription(groupedFieldsMap, entity);
}
}
return entities;
}
// Note that this does not do all the work in the transaction. Thus it is prone to fail if the list is deleted in during the run of the function
async function* listIterator(context, listId, segmentId, grouped = true) {
let groupedFieldsMap;
@ -855,6 +883,7 @@ module.exports.getByEmail = getByEmail;
module.exports.list = list;
module.exports.listIterator = listIterator;
module.exports.listDTAjax = listDTAjax;
module.exports.listTestUsersTx = listTestUsersTx;
module.exports.listTestUsersDTAjax = listTestUsersDTAjax;
module.exports.serverValidate = serverValidate;
module.exports.create = create;

View file

@ -7,11 +7,17 @@ const dtHelpers = require('../lib/dt-helpers');
const interoperableErrors = require('../../shared/interoperable-errors');
const namespaceHelpers = require('../lib/namespace-helpers');
const shares = require('./shares');
const reports = require('./reports');
const files = require('./files');
const dependencyHelpers = require('../lib/dependency-helpers');
const {convertFileURLs} = require('../lib/campaign-content');
const mailers = require('../lib/mailers');
const tools = require('../lib/tools');
const sendConfigurations = require('./send-configurations');
const { getMergeTagsForBases } = require('../../shared/templates');
const { getTrustedUrl, getSandboxUrl, getPublicUrl } = require('../lib/urls');
const htmlToText = require('html-to-text');
const allowedKeys = new Set(['name', 'description', 'type', 'data', 'html', 'text', 'namespace']);
function hash(entity) {
@ -145,6 +151,65 @@ async function remove(context, id) {
});
}
const MAX_EMAIL_COUNT = 100;
async function sendAsTransactionalEmail(context, templateId, sendConfigurationId, emails, subject, mergeTags) {
// TODO - Update this to use CampaignSender.queueMessageTx (with renderedHtml and renderedText)
if (emails.length > MAX_EMAIL_COUNT) {
throw new Error(`Cannot send more than ${MAX_EMAIL_COUNT} emails at once`);
}
await knex.transaction(async tx => {
const template = await getByIdTx(tx, context, templateId,false);
const sendConfiguration = await sendConfigurations.getByIdTx(tx, context, sendConfigurationId, false, false);
await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', sendConfigurationId, 'sendWithoutOverrides');
const mailer = await mailers.getOrCreateMailer(sendConfigurationId);
const variablesSkeleton = {
...getMergeTagsForBases(getTrustedUrl(), getSandboxUrl(), getPublicUrl()),
...mergeTags
};
for (const email of emails) {
const variables = {
...variablesSkeleton,
EMAIL: email
};
const html = tools.formatTemplate(
template.html,
null,
variables,
true
);
const text = (template.text || '').trim()
? tools.formatTemplate(
template.text,
null,
variables,
false
) : htmlToText.fromString(html, {wordwrap: 130});
return mailer.sendTransactionalMail(
{
to: email,
subject,
from: {
name: sendConfiguration.from_name,
address: sendConfiguration.from_email
},
html,
text
}
);
}
});
}
module.exports.hash = hash;
module.exports.getByIdTx = getByIdTx;
module.exports.getById = getById;
@ -153,3 +218,4 @@ module.exports.listByNamespaceDTAjax = listByNamespaceDTAjax;
module.exports.create = create;
module.exports.updateWithConsistencyCheck = updateWithConsistencyCheck;
module.exports.remove = remove;
module.exports.sendAsTransactionalEmail = sendAsTransactionalEmail;

View file

@ -65,7 +65,7 @@ async function _validateAndPreprocess(tx, context, campaignId, entity) {
await shares.enforceEntityPermissionTx(tx, context, 'campaign', entity.source_campaign, 'view');
}
await campaigns.enforceSendPermissionTx(tx, context, campaignId);
await campaigns.enforceSendPermissionTx(tx, context, campaignId, false);
}
async function create(context, campaignId, entity) {

View file

@ -311,7 +311,7 @@ async function sendPasswordReset(locale, usernameOrEmail) {
const { adminEmail } = await settings.get(contextHelpers.getAdminContext(), ['adminEmail']);
const mailer = await mailers.getOrCreateMailer();
await mailer.sendTransactionalMail({
await mailer.sendTransactionalMailBasedOnTemplate({
to: {
address: user.email
},