diff --git a/app-builder.js b/app-builder.js index 02e7bfe2..2eaa0d29 100644 --- a/app-builder.js +++ b/app-builder.js @@ -26,6 +26,7 @@ const mosaico = require('./routes/mosaico'); const files = require('./routes/files'); const links = require('./routes/links'); const archive = require('./routes/archive'); +const webhooks = require('./routes/webhooks'); const namespacesRest = require('./routes/rest/namespaces'); const sendConfigurationsRest = require('./routes/rest/send-configurations'); @@ -228,6 +229,7 @@ function createApp(appType) { useWith404Fallback('/reports', reports); } + useWith404Fallback('/webhooks', webhooks); // API endpoints useWith404Fallback('/api', api); diff --git a/client/src/send-configurations/List.js b/client/src/send-configurations/List.js index 8fcb3ba9..fbe81091 100644 --- a/client/src/send-configurations/List.js +++ b/client/src/send-configurations/List.js @@ -58,14 +58,15 @@ export default class List extends Component { const columns = [ { data: 1, title: t('Name') }, - { data: 2, title: t('Description') }, - { data: 3, title: t('Type'), render: data => this.mailerTypes[data].typeName }, - { data: 4, title: t('Created'), render: data => moment(data).fromNow() }, - { data: 5, title: t('Namespace') }, + { data: 2, title: t('ID'), render: data => {data} }, + { data: 3, title: t('Description') }, + { data: 4, title: t('Type'), render: data => this.mailerTypes[data].typeName }, + { data: 5, title: t('Created'), render: data => moment(data).fromNow() }, + { data: 6, title: t('Namespace') }, { actions: data => { const actions = []; - const perms = data[6]; + const perms = data[7]; if (perms.includes('edit')) { actions.push({ diff --git a/models/send-configurations.js b/models/send-configurations.js index 074bb126..40cd565b 100644 --- a/models/send-configurations.js +++ b/models/send-configurations.js @@ -3,6 +3,7 @@ const knex = require('../lib/knex'); const hasher = require('node-object-hash')(); const dtHelpers = require('../lib/dt-helpers'); +const shortid = require('shortid'); const { enforce, filterObject } = require('../lib/helpers'); const interoperableErrors = require('../shared/interoperable-errors'); const shares = require('./shares'); @@ -28,22 +29,33 @@ async function listDTAjax(context, params) { builder => builder .from('send_configurations') .innerJoin('namespaces', 'namespaces.id', 'send_configurations.namespace'), - ['send_configurations.id', 'send_configurations.name', 'send_configurations.description', 'send_configurations.mailer_type', 'send_configurations.created', 'namespaces.name'] + ['send_configurations.id', 'send_configurations.name', 'send_configurations.cid', 'send_configurations.description', 'send_configurations.mailer_type', 'send_configurations.created', 'namespaces.name'] ); } -async function getByIdTx(tx, context, id, withPermissions = true, withPrivateData = true) { +async function _getByTx(tx, context, key, id, withPermissions, withPrivateData) { let entity; if (withPrivateData) { - await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', id, 'viewPrivate'); - entity = await tx('send_configurations').where('id', id).first(); + entity = await tx('send_configurations').where(key, id).first(); + + if (!entity) { + shares.throwPermissionDenied(); + } + + await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', entity.id, 'viewPrivate'); + entity.mailer_settings = JSON.parse(entity.mailer_settings); } else { - await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', id, 'viewPublic'); - entity = await tx('send_configurations').where('id', id).select( - ['id', 'name', 'description', 'from_email', 'from_email_overridable', 'from_name', 'from_name_overridable', 'reply_to', 'reply_to_overridable', 'subject', 'subject_overridable'] + 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'] ).first(); + + if (!entity) { + shares.throwPermissionDenied(); + } + + await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', entity.id, 'viewPublic'); } // note that permissions are optional as as this methods may be used with synthetic admin context @@ -52,6 +64,11 @@ async function getByIdTx(tx, context, id, withPermissions = true, withPrivateDat } return entity; + +} + +async function getByIdTx(tx, context, id, withPermissions = true, withPrivateData = true) { + return await _getByTx(tx, context, 'id', id, withPermissions, withPrivateData); } async function getById(context, id, withPermissions = true, withPrivateData = true) { @@ -60,6 +77,12 @@ async function getById(context, id, withPermissions = true, withPrivateData = tr }); } +async function getByCid(context, cid, withPermissions = true, withPrivateData = true) { + return await knex.transaction(async tx => { + return await _getByTx(tx, context, 'cid', cid, withPermissions, withPrivateData); + }); +} + async function _validateAndPreprocess(tx, entity, isCreate) { await namespaceHelpers.validateEntity(tx, entity); @@ -75,7 +98,10 @@ async function create(context, entity) { await _validateAndPreprocess(tx, entity); - const ids = await tx('send_configurations').insert(filterObject(entity, allowedKeys)); + const filteredEntity = filterObject(entity, allowedKeys); + filteredEntity.cid = shortid.generate(); + + const ids = await tx('send_configurations').insert(filteredEntity); const id = ids[0]; await shares.rebuildPermissionsTx(tx, { entityTypeId: 'sendConfiguration', entityId: id }); @@ -137,6 +163,7 @@ module.exports.hash = hash; module.exports.listDTAjax = listDTAjax; module.exports.getByIdTx = getByIdTx; module.exports.getById = getById; +module.exports.getByCid = getByCid; module.exports.create = create; module.exports.updateWithConsistencyCheck = updateWithConsistencyCheck; module.exports.remove = remove; diff --git a/routes/reports.js b/routes/reports.js index 95cb97e1..2d6145c5 100644 --- a/routes/reports.js +++ b/routes/reports.js @@ -1,7 +1,6 @@ 'use strict'; const passport = require('../lib/passport'); -const _ = require('../lib/translate')._; const reports = require('../models/reports'); const reportHelpers = require('../lib/report-helpers'); const shares = require('../models/shares'); @@ -23,7 +22,7 @@ router.getAsync('/:id/download', passport.loggedIn, async (req, res) => { res.sendFile(reportHelpers.getReportContentFile(report), {headers: headers}); } else { - return res.status(404).send(_('Report not found')); + return res.status(404).send('Report not found'); } }); diff --git a/routes/webhooks.js b/routes/webhooks.js new file mode 100644 index 00000000..1db88530 --- /dev/null +++ b/routes/webhooks.js @@ -0,0 +1,243 @@ +'use strict'; + +const router = require('../lib/router-async').create(); +const request = require('request-promise'); +const campaigns = require('../models/campaigns'); +const sendConfigurations = require('../models/send-configurations'); +const contextHelpers = require('../lib/context-helpers'); +const {SubscriptionStatus} = require('../shared/lists'); +const {MailerType} = require('../shared/send-configurations'); +const log = require('npmlog'); +const multer = require('multer'); +const uploads = multer(); + + +router.postAsync('/aws', async (req, res) => { + if (typeof req.body === 'string') { + req.body = JSON.parse(req.body); + } + + switch (req.body.Type) { + + case 'SubscriptionConfirmation': + if (req.body.SubscribeURL) { + await request(req.body.SubscribeURL); + break; + } else { + const err = new Error('SubscribeURL not set'); + err.status = 400; + throw err; + } + + case 'Notification': + if (req.body.Message) { + if (typeof req.body.Message === 'string') { + req.body.Message = JSON.parse(req.body.Message); + } + + if (req.body.Message.mail && req.body.Message.mail.messageId) { + const message = await campaigns.getMessageByResponseId(req.body.Message.mail.messageId); + + if (!message) { + return; + } + + switch (req.body.Message.notificationType) { + case 'Bounce': + await campaigns.changeStatusByMessage(contextHelpers.getAdminContext(), message, SubscriptionStatus.BOUNCED, req.body.Message.bounce.bounceType === 'Permanent'); + log.verbose('AWS', 'Marked message %s as bounced', req.body.Message.mail.messageId); + break; + + case 'Complaint': + if (req.body.Message.complaint) { + await campaigns.changeStatusByMessage(contextHelpers.getAdminContext(), message, SubscriptionStatus.COMPLAINED, true); + log.verbose('AWS', 'Marked message %s as complaint', req.body.Message.mail.messageId); + } + break; + } + } + } + break; + } + + res.json({ + success: true + }); +}); + + +router.postAsync('/sparkpost', async (req, res) => { + const events = [].concat(req.body || []); // This is just a cryptic way getting an array regardless whether req.body is empty, one item, or array + + for (const curEvent of events) { + let msys = curEvent && curEvent.msys; + let evt; + + if (msys && msys.message_event) { + evt = msys.message_event; + } else if (msys && msys.unsubscribe_event) { + evt = msys.unsubscribe_event; + } else { + continue; + } + + const message = await campaigns.getMessageByCid(evt.campaign_id); + if (!message) { + continue; + } + + switch (evt.type) { + case 'bounce': + // https://support.sparkpost.com/customer/portal/articles/1929896 + await campaigns.changeStatusByMessage(contextHelpers.getAdminContext(), message, SubscriptionStatus.BOUNCED, [1, 10, 25, 30, 50].indexOf(Number(evt.bounce_class)) >= 0); + log.verbose('Sparkpost', 'Marked message %s as bounced', evt.campaign_id); + break; + + case 'spam_complaint': + await campaigns.changeStatusByMessage(contextHelpers.getAdminContext(), message, SubscriptionStatus.COMPLAINED, true); + log.verbose('Sparkpost', 'Marked message %s as complaint', evt.campaign_id); + break; + + case 'link_unsubscribe': + await campaigns.changeStatusByMessage(contextHelpers.getAdminContext(), message, SubscriptionStatus.UNSUBSCRIBED, true); + log.verbose('Sparkpost', 'Marked message %s as unsubscribed', evt.campaign_id); + break; + } + } + + return res.json({ + success: true + }); +}); + + +router.postAsync('/sendgrid', async (req, res) => { + let events = [].concat(req.body || []); + + for (const evt of events) { + if (!evt) { + continue; + } + + const message = await campaigns.getMessageByCid(evt.campaign_id); + if (!message) { + continue; + } + + switch (evt.event) { + case 'bounce': + // https://support.sparkpost.com/customer/portal/articles/1929896 + await campaigns.changeStatusByMessage(contextHelpers.getAdminContext(), message, SubscriptionStatus.BOUNCED, true); + log.verbose('Sendgrid', 'Marked message %s as bounced', evt.campaign_id); + break; + + case 'spamreport': + await campaigns.changeStatusByMessage(contextHelpers.getAdminContext(), message, SubscriptionStatus.COMPLAINED, true); + log.verbose('Sendgrid', 'Marked message %s as complaint', evt.campaign_id); + break; + + case 'group_unsubscribe': + case 'unsubscribe': + await campaigns.changeStatusByMessage(contextHelpers.getAdminContext(), message, SubscriptionStatus.UNSUBSCRIBED, true); + log.verbose('Sendgrid', 'Marked message %s as unsubscribed', evt.campaign_id); + break; + } + } + + return res.json({ + success: true + }); +}); + + +router.postAsync('/mailgun', uploads.any(), async (req, res) => { + const evt = req.body; + + const message = await campaigns.getMessageByCid([].concat(evt && evt.campaign_id || []).shift()); + if (!message) { + continue; + } + + switch (evt.event) { + case 'bounced': + await campaigns.changeStatusByMessage(contextHelpers.getAdminContext(), message, SubscriptionStatus.BOUNCED, true); + log.verbose('Mailgun', 'Marked message %s as bounced', evt.campaign_id); + break; + + case 'complained': + await campaigns.changeStatusByMessage(contextHelpers.getAdminContext(), message, SubscriptionStatus.COMPLAINED, true); + log.verbose('Mailgun', 'Marked message %s as complaint', evt.campaign_id); + break; + + case 'unsubscribed': + await campaigns.changeStatusByMessage(contextHelpers.getAdminContext(), message, SubscriptionStatus.UNSUBSCRIBED, true); + log.verbose('Mailgun', 'Marked message %s as unsubscribed', evt.campaign_id); + break; + } + + return res.json({ + success: true + }); +}); + + +router.postAsync('/zone-mta', async (req, res) => { + if (typeof req.body === 'string') { + req.body = JSON.parse(req.body); + } + + if (req.body.id) { + const message = await campaigns.getMessageByCid(req.body.id); + if (!message) { + continue; + } + + await campaigns.changeStatusByMessage(contextHelpers.getAdminContext(), message, SubscriptionStatus.BOUNCED, true); + log.verbose('ZoneMTA', 'Marked message %s as bounced', req.body.id); + } + + res.json({ + success: true + }); +}); + + +router.postAsync('/zone-mta/sender-config/:sendConfigurationCid', async (req, res) => { + if (!req.query.api_token) { + return res.json({ + error: 'api_token value not set' + }); + } + + const sendConfiguration = await sendConfigurations.getByCid(contextHelpers.getAdminContext(), req.params.sendConfigurationCid, false, true); + + if (sendConfiguration.mailer_type !== MailerType.ZONE_MTA || sendConfiguration.mailer_settings.dkimApiKey !== req.query.api_token) { + return res.json({ + error: 'invalid api_token value' + }); + } + + const dkimDomain = sendConfiguration.mailer_settings.dkimDomain; + const dkimSelector = (sendConfiguration.mailer_settings.dkimSelector || '').trim(); + const dkimPrivateKey = (sendConfiguration.mailer_settings.dkimPrivateKey || '').trim(); + + if (!dkimSelector || !dkimPrivateKey) { + // empty response + return res.json({}); + } + + const from = (req.body.from || '').trim(); + const domain = from.split('@').pop().toLowerCase().trim(); + + res.json({ + dkim: { + keys: [{ + domainName: dkimDomain || domain, + keySelector: dkimSelector, + privateKey: dkimPrivateKey + }] + } + }); +}); + +module.exports = router; diff --git a/setup/knex/migrations/20170506102634_v1_to_v2.js b/setup/knex/migrations/20170506102634_v1_to_v2.js index 4329253d..13708576 100644 --- a/setup/knex/migrations/20170506102634_v1_to_v2.js +++ b/setup/knex/migrations/20170506102634_v1_to_v2.js @@ -6,7 +6,7 @@ const {getGlobalNamespaceId} = require('../../../shared/namespaces'); const {getAdminId} = require('../../../shared/users'); const entityTypesAddNamespace = ['list', 'custom_form', 'template', 'campaign', 'report', 'report_template', 'user']; const shareableEntityTypes = ['list', 'custom_form', 'template', 'campaign', 'report', 'report_template', 'namespace', 'send_configuration', 'mosaico_template']; -const { MailerType, getSystemSendConfigurationId } = require('../../../shared/send-configurations'); +const { MailerType, getSystemSendConfigurationId, getSystemSendConfigurationCid } = require('../../../shared/send-configurations'); const { enforce } = require('../../../lib/helpers'); const { EntityVals: TriggerEntityVals, EventVals: TriggerEventVals } = require('../../../shared/triggers'); const { SubscriptionSource } = require('../../../shared/lists'); @@ -649,6 +649,7 @@ async function migrateSettings(knex) { // ----------------------------------------------------------------------------------------------------- await knex.schema.createTable('send_configurations', table => { table.increments('id').primary(); + table.string('cid'); table.string('name'); table.text('description'); table.string('from_email'); @@ -725,6 +726,7 @@ async function migrateSettings(knex) { await knex('send_configurations').insert({ id: getSystemSendConfigurationId(), + cid: getSystemSendConfigurationCid(), name: 'System', description: 'Send configuration used to deliver system emails', from_email: settings.defaultAddress, diff --git a/shared/send-configurations.js b/shared/send-configurations.js index fa044ac5..94937de5 100644 --- a/shared/send-configurations.js +++ b/shared/send-configurations.js @@ -10,7 +10,12 @@ function getSystemSendConfigurationId() { return 1; } +function getSystemSendConfigurationCid() { + return 'default'; +} + module.exports = { MailerType, - getSystemSendConfigurationId + getSystemSendConfigurationId, + getSystemSendConfigurationCid }; \ No newline at end of file