Subscription/unsubscription seems to work.

This commit is contained in:
Tomas Bures 2018-01-27 16:37:14 +01:00
parent d8ee364a4b
commit e9165838dc
22 changed files with 14939 additions and 196 deletions

1
.gitignore vendored
View file

@ -3,7 +3,6 @@
node_modules
npm-debug.log
package-lock.json
.DS_Store
config/development.*
config/production.*

6159
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -24,7 +24,6 @@
"immutable": "^3.8.1",
"moment": "^2.18.1",
"moment-timezone": "^0.5.13",
"owasp-password-strength-test": "github:bures/owasp-password-strength-test",
"prop-types": "^15.5.10",
"querystringify": "^1.0.0",
"react": "^15.6.1",

View file

@ -56,7 +56,7 @@ export default class API extends Component {
return (
<div>
<Title>{t('Sign in')}</Title>
<Title>{t('API')}</Title>
<div className="panel panel-default">

View file

@ -12,7 +12,7 @@ import {
Dropdown, Form,
withForm
} from '../../lib/form';
import {Icon} from "../../lib/bootstrap-components";
import {Icon, Button} from "../../lib/bootstrap-components";
import axios from '../../lib/axios';
import {getFieldTypes, getSubscriptionStatusLabels} from './helpers';
@ -154,6 +154,7 @@ export default class List extends Component {
return (
<div>
<Toolbar>
<a href={`/subscription/${this.props.list.cid}`} className="btn-default"><Button label={t('Subscription Form')} className="btn-default"/></a>
<NavButton linkTo={`/lists/${this.props.list.id}/subscriptions/create`} className="btn-primary" icon="plus" label={t('Add Subscriber')}/>
</Toolbar>

View file

@ -25,7 +25,10 @@ export function getFieldTypes(t) {
const stringFieldType = long => ({
form: groupedField => long ? <TextArea key={getFieldKey(groupedField)} id={getFieldKey(groupedField)} label={groupedField.name}/> : <InputField key={getFieldKey(groupedField)} id={getFieldKey(groupedField)} label={groupedField.name}/>,
assignFormData: (groupedField, data) => {},
assignFormData: (groupedField, data) => {
const value = data[getFieldKey(groupedField)];
data[getFieldKey(groupedField)] = value || '';
},
initFormData: (groupedField, data) => {
data[getFieldKey(groupedField)] = '';
},
@ -109,7 +112,10 @@ export function getFieldTypes(t) {
const jsonFieldType = {
form: groupedField => <ACEEditor key={getFieldKey(groupedField)} id={getFieldKey(groupedField)} label={groupedField.name} mode="json" height="300px"/>,
assignFormData: (groupedField, data) => {},
assignFormData: (groupedField, data) => {
const value = data[getFieldKey(groupedField)];
data[getFieldKey(groupedField)] = value || '';
},
initFormData: (groupedField, data) => {
data[getFieldKey(groupedField)] = '';
},

View file

@ -10,18 +10,18 @@ function getRequestContext(req) {
return context;
}
function getAdminContext() {
const context = {
user: {
admin: true,
id: 0,
username: '',
name: '',
email: ''
}
};
const adminContext = {
user: {
admin: true,
id: 0,
username: '',
name: '',
email: ''
}
};
return context;
function getAdminContext() {
return adminContext;
}
module.exports = {

View file

@ -166,13 +166,13 @@ async function ajaxListWithPermissionsTx(tx, context, fetchSpecs, params, queryF
async function ajaxList(params, queryFun, columns, options) {
return await knex.transaction(async tx => {
return ajaxListTx(tx, params, queryFun, columns, options)
return await ajaxListTx(tx, params, queryFun, columns, options)
});
}
async function ajaxListWithPermissions(context, fetchSpecs, params, queryFun, columns, options) {
return await knex.transaction(async tx => {
return ajaxListWithPermissionsTx(tx, context, fetchSpecs, params, queryFun, columns, options)
return await ajaxListWithPermissionsTx(tx, context, fetchSpecs, params, queryFun, columns, options)
});
}

View file

@ -8,7 +8,7 @@ const knex = require('knex')({
migrations: {
directory: __dirname + '/../setup/knex/migrations'
}
, debug: true
//, debug: true
});
module.exports = knex;

View file

@ -117,7 +117,7 @@ async function _sendMail(list, email, template, subject, relativeUrls, mailOpts,
const encryptionKeys = [];
for (const fld of flds) {
if (fld.type === 'gpg' && field.value) {
if (fld.type === 'gpg' && fld.value) {
encryptionKeys.push(subscription[getFieldKey(fld)].value.trim());
}
}

View file

@ -6,10 +6,13 @@ const isemail = require('isemail');
const bluebird = require('bluebird');
const mergeTemplateIntoLayout = bluebird.promisify(require('./tools').mergeTemplateIntoLayout);
const queryParams = require('./tools').queryParams;
module.exports = {
validateEmail,
mergeTemplateIntoLayout
validateEmailGetMessage,
mergeTemplateIntoLayout,
queryParams
};
async function validateEmail(address, checkBlocked) {
@ -29,3 +32,21 @@ async function validateEmail(address, checkBlocked) {
return result;
}
function validateEmailGetMessage(result, address) {
if (result !== 0) {
let message = util.format(_('Invalid email address "%s".'), address);
switch (result) {
case 5:
message += ' ' + _('MX record not found for domain');
break;
case 6:
message += ' ' + _('Address domain not found');
break;
case 12:
message += ' ' + _('Address domain name is required');
break;
}
return message;
}
}

View file

@ -87,7 +87,7 @@ async function _getById(tx, id) {
async function getById(context, id) {
return await knex.transaction(async tx => {
shares.enforceEntityPermissionTx(tx, context, 'customForm', id, 'view');
await shares.enforceEntityPermissionTx(tx, context, 'customForm', id, 'view');
const entity = await _getById(tx, id);
entity.permissions = await shares.getPermissionsTx(tx, context, 'customForm', id);
return entity;
@ -171,9 +171,9 @@ async function updateWithConsistencyCheck(context, entity) {
async function remove(context, id) {
await knex.transaction(async tx => {
shares.enforceEntityPermissionTx(tx, context, 'customForm', id, 'delete');
await shares.enforceEntityPermissionTx(tx, context, 'customForm', id, 'delete');
lists.removeFormFromAllTx(tx, context, id);
await lists.removeFormFromAllTx(tx, context, id);
await tx('custom_forms_data').where('form', id).del();
await tx('custom_forms').where('id', id).del();

View file

@ -33,21 +33,22 @@ async function listDTAjax(context, params) {
}
async function _getByIdTx(tx, context, id) {
shares.enforceEntityPermissionTx(tx, context, 'list', id, 'view');
await shares.enforceEntityPermissionTx(tx, context, 'list', id, 'view');
const entity = await tx('lists').where('id', id).first();
entity.permissions = await shares.getPermissionsTx(tx, context, 'list', id);
return entity;
}
async function getById(context, id) {
return await knex.transaction(async tx => {
return _getByIdTx(tx, context, id);
// note that permissions are not obtained here as this methods is used only with synthetic admin context
return await _getByIdTx(tx, context, id);
});
}
async function getByIdWithListFields(context, id) {
return await knex.transaction(async tx => {
const entity = _getByIdTx(tx, context, id);
const entity = await _getByIdTx(tx, context, id);
entity.permissions = await shares.getPermissionsTx(tx, context, 'list', id);
entity.listFields = await fields.listByOrderListTx(tx, id);
return entity;
});
@ -60,8 +61,7 @@ async function getByCid(context, cid) {
shares.throwPermissionDenied();
}
shares.enforceEntityPermissionTx(tx, context, 'list', entity.id, 'view');
entity.permissions = await shares.getPermissionsTx(tx, context, 'list', entity.id);
await shares.enforceEntityPermissionTx(tx, context, 'list', entity.id, 'view');
return entity;
});
}

View file

@ -31,11 +31,16 @@ function getOptionsMap(groupedField) {
return result;
}
fieldTypes.text = fieldTypes.website = fieldTypes.longtext = fieldTypes.gpg = fieldTypes.number = {
fieldTypes.text = fieldTypes.website = fieldTypes.longtext = fieldTypes.gpg = {
afterJSON: (groupedField, entity) => {},
listRender: (groupedField, value) => value
};
fieldTypes.number = {
afterJSON: (groupedField, entity) => {},
listRender: (groupedField, value) => Number(value)
};
fieldTypes.json = {
afterJSON: (groupedField, entity) => {},
listRender: (groupedField, value) => value
@ -59,14 +64,20 @@ fieldTypes['radio-enum'] = fieldTypes['dropdown-enum'] = fieldTypes['radio-group
fieldTypes.date = {
afterJSON: (groupedField, entity) => {
entity[getFieldKey(groupedField)] = moment(entity[getFieldKey(groupedField)]).toDate();
const key = getFieldKey(groupedField);
if (key in entity) {
entity[key] = entity[key] ? moment(entity[key]).toDate() : null;
}
},
listRender: (groupedField, value) => formatDate(groupedField.settings.dateFormat, value)
};
fieldTypes.birthday = {
afterJSON: (groupedField, entity) => {
entity[getFieldKey(groupedField)] = moment(entity[getFieldKey(groupedField)]).toDate();
const key = getFieldKey(groupedField);
if (key in entity) {
entity[key] = entity[key] ? moment(entity[key]).toDate() : null;
}
},
listRender: (groupedField, value) => formatBirthday(groupedField.settings.dateFormat, value)
};
@ -153,7 +164,7 @@ function ungroupSubscription(groupedFieldsMap, entity) {
}
} else {
const values = entity[fldKey];
const values = entity[fldKey] || []; // The default (empty array) is here because create may be called with an entity that has some fields not filled in
for (const optionKey in fld.groupedOptions) {
const option = fld.groupedOptions[optionKey];
entity[option.column] = values.includes(option.column);
@ -210,11 +221,6 @@ async function _getStatusBy(context, listId, key, value) {
});
}
async function getStatusByCid(context, listId, cid) {
return await _getStatusBy(context, listId, 'cid', cid);
}
async function _getBy(context, listId, key, value, grouped) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions');
@ -387,8 +393,9 @@ async function serverValidate(context, listId, data) {
});
}
async function _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, isCreate) {
async function _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, meta, isCreate) {
enforce(entity.email, 'Email must be set');
enforce(entity.status > 0 && entity.status < SubscriptionStatus.MAX, 'Subscription status is invalid');
const existingWithKeyQuery = tx(getSubscriptionTableName(listId)).where('email', entity.email);
@ -397,7 +404,12 @@ async function _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, isCr
}
const existingWithKey = await existingWithKeyQuery.first();
if (existingWithKey) {
throw new interoperableErrors.DuplicitEmailError();
if (meta && meta.replaceOfUnsubscribedAllowed && existingWithKey.status === SubscriptionStatus.UNSUBSCRIBED) {
meta.updateNeeded = true;
meta.existing = existingWithKey;
} else {
throw new interoperableErrors.DuplicitEmailError();
}
}
enforce(entity.status >= 0 && entity.status < SubscriptionStatus.MAX, 'Invalid status');
@ -409,33 +421,68 @@ async function _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, isCr
}
}
async function create(context, listId, entity, meta = {}) {
async function _update(tx, listId, existing, filteredEntity) {
if (existing.status !== filteredEntity.status) {
filteredEntity.status_change = new Date();
}
await tx(getSubscriptionTableName(listId)).where('id', existing.id).update(filteredEntity);
let countIncrement = 0;
if (existing.status === SubscriptionStatus.SUBSCRIBED && filteredEntity.status !== SubscriptionStatus.SUBSCRIBED) {
countIncrement = -1;
} else if (existing.status !== SubscriptionStatus.SUBSCRIBED && filteredEntity.status === SubscriptionStatus.SUBSCRIBED) {
countIncrement = 1;
}
if (countIncrement) {
await tx('lists').where('id', listId).increment('subscribers', countIncrement);
}
}
async function _create(tx, listId, filteredEntity) {
const ids = await tx(getSubscriptionTableName(listId)).insert(filteredEntity);
const id = ids[0];
if (filteredEntity.status === SubscriptionStatus.SUBSCRIBED) {
await tx('lists').where('id', listId).increment('subscribers', 1);
}
return id;
}
async function create(context, listId, entity, meta /* meta is provided when called from /confirm/subscribe/:cid */) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions');
const groupedFieldsMap = await getGroupedFieldsMap(tx, listId);
const allowedKeys = getAllowedKeys(groupedFieldsMap);
await _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, true);
await _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, meta, true);
const filteredEntity = filterObject(entity, allowedKeys);
filteredEntity.cid = shortid.generate();
filteredEntity.status_change = new Date();
ungroupSubscription(groupedFieldsMap, filteredEntity);
filteredEntity.opt_in_ip = meta.ip;
filteredEntity.opt_in_country = meta.country;
filteredEntity.imported = meta.imported || false;
filteredEntity.opt_in_ip = meta && meta.ip;
filteredEntity.opt_in_country = meta && meta.country;
filteredEntity.imported = meta && !!meta.imported;
const ids = await tx(getSubscriptionTableName(listId)).insert(filteredEntity);
const id = ids[0];
if (meta && meta.updateNeeded) {
await _update(tx, listId, meta.existing, filteredEntity);
meta.cid = meta.existing.cid; // The cid is needed by /confirm/subscribe/:cid
return meta.existing.id;
} else {
filteredEntity.cid = shortid.generate();
if (entity.status === SubscriptionStatus.SUBSCRIBED) {
await tx('lists').where('id', listId).increment('subscribers', 1);
if (meta) {
meta.cid = filteredEntity.cid; // The cid is needed by /confirm/subscribe/:cid
}
return await _create(tx, listId, filteredEntity);
}
return id;
});
}
@ -458,29 +505,13 @@ async function updateWithConsistencyCheck(context, listId, entity) {
throw new interoperableErrors.ChangedError();
}
await _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, false);
await _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, null, false);
const filteredEntity = filterObject(entity, allowedKeys);
ungroupSubscription(groupedFieldsMap, filteredEntity);
if (existing.status !== entity.status) {
filteredEntity.status_change = new Date();
}
await tx(getSubscriptionTableName(listId)).where('id', entity.id).update(filteredEntity);
let countIncrement = 0;
if (existing.status === SubscriptionStatus.SUBSCRIBED && entity.status !== SubscriptionStatus.SUBSCRIBED) {
countIncrement = -1;
} else if (existing.status !== SubscriptionStatus.SUBSCRIBED && entity.status === SubscriptionStatus.SUBSCRIBED) {
countIncrement = 1;
}
if (countIncrement) {
await tx('lists').where('id', listId).increment('subscribers', countIncrement);
}
await _update(tx, listId, existing, filteredEntity);
});
}
@ -505,40 +536,52 @@ async function remove(context, listId, id) {
});
}
async function _unsubscribeAndGetTx(tx, context, listId, existingSubscription, campaignCid) {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions');
if (!(existingSubscription && existingSubscription.status === SubscriptionStatus.SUBSCRIBED)) {
throw new interoperableErrors.NotFoundError();
}
existingSubscription.status = SubscriptionStatus.UNSUBSCRIBED;
await tx(getSubscriptionTableName(listId)).where('id', existingSubscription.id).update({
status: SubscriptionStatus.UNSUBSCRIBED
});
await tx('lists').where('id', listId).decrement('subscribers', 1);
if (campaignCid) {
const campaign = await tx('campaigns').where('cid', campaignCid);
const subscriptionInCampaign = await tx(getCampaignTableName(campaign.id)).where({subscription: existingSubscription.id, list: listId});
if (!subscriptionInCampaign) {
throw new Error('Invalid campaign.')
}
if (subscriptionInCampaign.status === SubscriptionStatus.SUBSCRIBED) {
await tx('campaigns').where('id', campaign.id).increment('unsubscribed', 1);
await tx(getCampaignTableName(campaign.id)).where({subscription: existingSubscription.id, list: listId}).update({
status: SubscriptionStatus.UNSUBSCRIBED
});
}
}
return existingSubscription;
}
async function unsubscribeByIdAndGet(context, listId, subscriptionId) {
return await knex.transaction(async tx => {
const existing = await tx(getSubscriptionTableName(listId)).where('id', subscriptionId).first();
return _unsubscribeAndGetTx(tx, context, listId, existing);
});
}
async function unsubscribeByCidAndGet(context, listId, subscriptionCid, campaignCid) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions');
const existing = await tx(getSubscriptionTableName(listId)).where('cid', subscriptionCid).first();
if (!(existing && existing.status === SubscriptionStatus.SUBSCRIBED)) {
throw new interoperableErrors.NotFoundError();
}
existing.status = SubscriptionStatus.UNSUBSCRIBED;
await tx(getSubscriptionTableName(listId)).where('cid', subscriptionCid).update({
status: SubscriptionStatus.UNSUBSCRIBED
});
await tx('lists').where('id', listId).decrement('subscribers', 1);
if (campaignCid) {
const campaign = await tx('campaigns').where('cid', campaignCid);
const subscriptionInCampaign = await tx(getCampaignTableName(campaign.id)).where({subscription: existing.id, list: listId});
if (!subscriptionInCampaign) {
throw new Error('Invalid campaign.')
}
if (subscriptionInCampaign.status === SubscriptionStatus.SUBSCRIBED) {
await tx('campaigns').where('id', campaign.id).increment('unsubscribed', 1);
await tx(getCampaignTableName(campaign.id)).where({subscription: existing.id, list: listId}).update({
status: SubscriptionStatus.UNSUBSCRIBED
});
}
}
return existing;
return _unsubscribeAndGetTx(tx, context, listId, existing, campaignCid);
});
}
@ -564,8 +607,8 @@ async function updateManagedUngrouped(context, listId, entity) {
await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions');
const existing = await tx(getSubscriptionTableName(listId)).where('id', entity.id).first();
if (!existing) {
const existing = await tx(getSubscriptionTableName(listId)).where('cid', entity.cid).first();
if (!existing || existing.status !== SubscriptionStatus.SUBSCRIBED) {
throw new interoperableErrors.NotFoundError();
}
@ -583,7 +626,7 @@ async function updateManagedUngrouped(context, listId, entity) {
}
}
await tx(getSubscriptionTableName(listId)).where('id', entity.id).update(update);
await tx(getSubscriptionTableName(listId)).where('cid', entity.cid).update(update);
});
}
@ -592,7 +635,6 @@ module.exports = {
getById,
getByCid,
getByEmail,
getStatusByCid,
list,
listDTAjax,
serverValidate,
@ -600,6 +642,7 @@ module.exports = {
updateWithConsistencyCheck,
remove,
unsubscribeByCidAndGet,
unsubscribeByIdAndGet,
updateAddressAndGet,
updateManagedUngrouped
};

8447
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
{
"name": "mailtrain",
"private": true,
"version": "1.23.2",
"description": "Self hosted email newsletter app",
"main": "index.js",
@ -29,7 +28,6 @@
},
"devDependencies": {
"babel-eslint": "^8.1.2",
"bluebird": "^3.5.0",
"chai": "^4.1.2",
"eslint-config-nodemailer": "^1.2.0",
"grunt": "^1.0.1",
@ -91,6 +89,7 @@
"memory-cache": "^0.2.0",
"mjml": "3.3.5",
"mkdirp": "^0.5.1",
"moment": "^2.18.1",
"moment-timezone": "^0.5.13",
"morgan": "^1.8.2",
"multer": "^1.3.0",
@ -105,7 +104,6 @@
"npmlog": "^4.1.2",
"object-hash": "^1.1.8",
"openpgp": "^2.6.1",
"owasp-password-strength-test": "github:bures/owasp-password-strength-test",
"passport": "^0.4.0",
"passport-local": "^1.0.0",
"premailer-api": "^1.0.4",

View file

@ -39,7 +39,7 @@ router.postAsync('/subscriptions-validate/:listId', passport.loggedIn, async (re
});
router.postAsync('/subscriptions-unsubscribe/:listId/:subscriptionId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await subscriptions.unsubscribeAndGet(req.context, req.params.listId, req.params.subscriptionId);
await subscriptions.unsubscribeByIdAndGet(req.context, req.params.listId, req.params.subscriptionId);
return res.json();
});

View file

@ -7,6 +7,7 @@ const confirmations = require('../models/confirmations');
const subscriptions = require('../models/subscriptions');
const lists = require('../models/lists');
const fields = require('../models/fields');
const shares = require('../models/shares');
const settings = require('../models/settings');
const _ = require('../lib/translate')._;
const contextHelpers = require('../lib/context-helpers');
@ -127,32 +128,40 @@ async function getMjmlTemplate(template) {
return renderer;
}
function captureFlashMessages(req, res) {
return new Promise((resolve, reject) => {
res.render('subscription/capture-flash-messages', { layout: null }, (err, flash) => {
reject(err);
resolve(flash);
});
})
async function captureFlashMessages(res) {
const renderAsync = bluebird.promisify(res.render.bind(res));
return await renderAsync('subscription/capture-flash-messages', { layout: null });
}
router.getAsync('/confirm/subscribe/:cid', async (req, res) => {
const confirmation = await takeConfirmationAndValidate(req, 'subscribe', () => new interoperableErrors.InvalidConfirmationForSubscriptionError('Request invalid or already completed. If your subscription request is still pending, please subscribe again.'));
const subscription = confirmation.data;
const data = confirmation.data;
const meta = {
cid: req.params.cid,
ip: confirmation.ip,
country: geoip.lookupCountry(confirmation.ip) || null
country: geoip.lookupCountry(confirmation.ip) || null,
replaceOfUnsubscribedAllowed: true
};
const subscription = data.subscriptionData;
subscription.email = data.email;
subscription.status = SubscriptionStatus.SUBSCRIBED;
await subscriptions.create(contextHelpers.getAdminContext(), confirmation.list, subscription, meta);
try {
await subscriptions.create(contextHelpers.getAdminContext(), confirmation.list, subscription, meta);
} catch (err) {
if (err instanceof interoperableErrors.DuplicitEmailError) {
throw new interoperableErrors.DuplicitEmailError('Subscription already present'); // This is here to provide some meaningful error message.
} else {
throw err;
}
}
console.log(subscription);
const list = await lists.getById(contextHelpers.getAdminContext(), confirmation.list);
subscription.cid = meta.cid;
await mailHelpers.sendSubscriptionConfirmed(list, subscription.email, subscription);
res.redirect('/subscription/' + encodeURIComponent(list.cid) + '/subscribed-notice');
@ -187,15 +196,15 @@ router.getAsync('/confirm/unsubscribe/:cid', async (req, res) => {
router.getAsync('/:cid', passport.csrfProtection, async (req, res) => {
const list = await lists.getByCid(req.params.cid);
const list = await lists.getByCid(contextHelpers.getAdminContext(), req.params.cid);
if (!list.publicSubscribe) {
throw new interoperableErrors.SubscriptionNotAllowedError('The list does not allow public subscriptions.');
if (!list.public_subscribe) {
shares.throwPermissionDenied();
}
const ucid = req.query.cid;
const data = {};
const data = req.query;
data.layout = 'subscription/layout';
data.title = list.name;
data.cid = list.cid;
@ -203,10 +212,14 @@ router.getAsync('/:cid', passport.csrfProtection, async (req, res) => {
let subscription;
if (ucid) {
subscription = await subscriptions.getById(contextHelpers.getAdminContext(), list.id, ucid, false);
subscription = await subscriptions.getByCid(contextHelpers.getAdminContext(), list.id, ucid, false);
if (subscription) {
data.email = subscription.email;
}
}
data.customFields = fields.getRow(contextHelpers.getAdminContext(), list.id, subscription);
data.customFields = await fields.getRow(contextHelpers.getAdminContext(), list.id, subscription);
data.useEditor = true;
const configItems = await settings.get(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress']);
@ -227,7 +240,8 @@ router.getAsync('/:cid', passport.csrfProtection, async (req, res) => {
data.needsJsWarning = true;
data.flashMessages = await captureFlashMessages(res);
res.send(htmlRenderer(data));
const result = htmlRenderer(data);
res.send(result);
});
@ -241,7 +255,7 @@ router.getAsync('/:cid/widget', cors(corsOptions), async (req, res) => {
return res.status(200).json(cached);
}
const list = await lists.getByCid(req.params.cid);
const list = await lists.getByCid(contextHelpers.getAdminContext(), req.params.cid);
const configItems = await settings.get(['serviceUrl', 'pgpPrivateKey']);
@ -250,7 +264,7 @@ router.getAsync('/:cid/widget', cors(corsOptions), async (req, res) => {
cid: list.cid,
serviceUrl: configItems.serviceUrl,
hasPubkey: !!configItems.pgpPrivateKey,
customFields: fields.getRow(contextHelpers.getAdminContext(), list.id),
customFields: await fields.getRow(contextHelpers.getAdminContext(), list.id),
template: {},
layout: null,
};
@ -293,11 +307,13 @@ router.postAsync('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, as
const emailErr = await tools.validateEmail(email);
if (emailErr) {
const errMsg = tools.validateEmailGetMessage(emailErr, email);
if (req.xhr) {
throw new Error(emailErr.message);
throw new Error(errMsg);
}
req.flash('danger', emailErr.message);
req.flash('danger', errMsg);
return res.redirect('/subscription/' + encodeURIComponent(req.params.cid) + '?' + tools.queryParams(req.body));
}
@ -310,20 +326,20 @@ router.postAsync('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, as
let addressTest = !req.body.address;
let testsPass = subTimeTest && addressTest;
const list = await lists.getByCid(req.params.cid);
const list = await lists.getByCid(contextHelpers.getAdminContext(), req.params.cid);
if (!list.publicSubscribe) {
throw new interoperableErrors.SubscriptionNotAllowedError('The list does not allow public subscriptions.');
if (!list.public_subscribe) {
shares.throwPermissionDenied();
}
let subscriptionData = {};
const subscriptionData = {};
Object.keys(req.body).forEach(key => {
if (key !== 'email' && key.charAt(0) !== '_') {
subscriptionData[key] = (req.body[key] || '').toString().trim();
}
});
const subscription = subscriptions.getByEmail(list.id, email, false)
const subscription = await subscriptions.getByEmail(contextHelpers.getAdminContext(), list.id, email, false);
if (subscription && subscription.status === SubscriptionStatus.SUBSCRIBED) {
await mailHelpers.sendAlreadySubscribed(list, email, subscription);
@ -353,33 +369,36 @@ router.postAsync('/:cid/subscribe', passport.parseForm, corsOrCsrfProtection, as
});
router.getAsync('/:lcid/manage/:ucid', passport.csrfProtection, async (req, res) => {
const list = await lists.getByCid(req.params.lcid);
const list = await lists.getByCid(contextHelpers.getAdminContext(), req.params.lcid);
const subscription = await subscriptions.getByCid(contextHelpers.getAdminContext(), list.id, req.params.ucid, false);
if (!subscription || subscription.status !== SubscriptionStatus.SUBSCRIBED) {
throw new interoperableErrors.NotFoundError('Subscription not found in this list');
}
subscription.lcid = req.params.lcid;
subscription.title = list.name;
subscription.csrfToken = req.csrfToken();
subscription.layout = 'subscription/layout';
const data = {};
data.email = subscription.email;
data.cid = subscription.cid;
data.lcid = req.params.lcid;
data.title = list.name;
data.csrfToken = req.csrfToken();
data.layout = 'data/layout';
subscription.customFields = await fields.getRow(contextHelpers.getAdminContext(), list.id, subscription);
data.customFields = await fields.getRow(contextHelpers.getAdminContext(), list.id, subscription);
subscription.useEditor = true;
data.useEditor = true;
const configItems = await settings.get(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress']);
subscription.hasPubkey = !!configItems.pgpPrivateKey;
subscription.defaultAddress = configItems.defaultAddress;
subscription.defaultPostaddress = configItems.defaultPostaddress;
data.hasPubkey = !!configItems.pgpPrivateKey;
data.defaultAddress = configItems.defaultAddress;
data.defaultPostaddress = configItems.defaultPostaddress;
subscription.template = {
data.template = {
template: 'subscription/web-manage.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
};
await injectCustomFormData(req.query.fid || list.default_form, 'subscription/web-manage', subscription);
await injectCustomFormData(req.query.fid || list.default_form, 'data/web-manage', data);
const htmlRenderer = await getMjmlTemplate(data.template);
@ -392,20 +411,23 @@ router.getAsync('/:lcid/manage/:ucid', passport.csrfProtection, async (req, res)
});
router.postAsync('/:lcid/manage', passport.parseForm, passport.csrfProtection, async (req, res) => {
const list = await lists.getByCid(req.params.lcid);
const status = await subscriptions.getStatusByCid(contextHelpers.getAdminContext(), list.id, req.body.cid);
const list = await lists.getByCid(contextHelpers.getAdminContext(), req.params.lcid);
if (status !== SubscriptionStatus.SUBSCRIBED) {
throw new interoperableErrors.NotFoundError('Subscription not found in this list');
try {
await subscriptions.updateManagedUngrouped(contextHelpers.getAdminContext(), list.id, req.body);
} catch (err) {
if (err instanceof interoperableErrors.NotFoundError) {
throw new interoperableErrors.NotFoundError('Subscription not found in this list');
} else {
throw err;
}
}
await subscriptions.updateManagedUngrouped(contextHelpers.getAdminContext(), list.id, req.body)
res.redirect('/subscription/' + encodeURIComponent(req.params.lcid) + '/updated-notice');
});
router.getAsync('/:lcid/manage-address/:ucid', passport.csrfProtection, async (req, res) => {
const list = await lists.getByCid(req.params.lcid);
const list = await lists.getByCid(contextHelpers.getAdminContext(), req.params.lcid);
const subscription = await subscriptions.getByCid(contextHelpers.getAdminContext(), list.id, req.params.ucid, false);
if (!subscription || subscription.status !== SubscriptionStatus.SUBSCRIBED) {
@ -414,18 +436,21 @@ router.getAsync('/:lcid/manage-address/:ucid', passport.csrfProtection, async (r
const configItems = await settings.get(['defaultAddress', 'defaultPostaddress']);
subscription.lcid = req.params.lcid;
subscription.title = list.name;
subscription.csrfToken = req.csrfToken();
subscription.defaultAddress = configItems.defaultAddress;
subscription.defaultPostaddress = configItems.defaultPostaddress;
const data = {};
data.email = subscription.email;
data.cid = subscription.cid;
data.lcid = req.params.lcid;
data.title = list.name;
data.csrfToken = req.csrfToken();
data.defaultAddress = configItems.defaultAddress;
data.defaultPostaddress = configItems.defaultPostaddress;
subscription.template = {
data.template = {
template: 'subscription/web-manage-address.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
};
await injectCustomFormData(req.query.fid || list.default_form, 'subscription/web-manage-address', subscription);
await injectCustomFormData(req.query.fid || list.default_form, 'data/web-manage-address', subscription);
const htmlRenderer = await getMjmlTemplate(data.template);
@ -439,13 +464,13 @@ router.getAsync('/:lcid/manage-address/:ucid', passport.csrfProtection, async (r
router.postAsync('/:lcid/manage-address', passport.parseForm, passport.csrfProtection, async (req, res) => {
const list = await lists.getByCid(req.params.lcid);
const list = await lists.getByCid(contextHelpers.getAdminContext(), req.params.lcid);
const emailNew = (req.body['email-new'] || '').toString().trim();
const subscription = await subscriptions.getByCid(contextHelpers.getAdminContext(), list.id, req.body.cid, false);
if (status !== SubscriptionStatus.SUBSCRIBED) {
if (subscription.status !== SubscriptionStatus.SUBSCRIBED) {
throw new interoperableErrors.NotFoundError('Subscription not found in this list');
}
@ -455,7 +480,9 @@ router.postAsync('/:lcid/manage-address', passport.parseForm, passport.csrfProte
} else {
const emailErr = await tools.validateEmail(emailNew);
if (emailErr) {
req.flash('danger', emailErr.message);
const errMsg = tools.validateEmailGetMessage(emailErr, email);
req.flash('danger', errMsg);
} else {
const newSubscription = await subscriptions.getByEmail(contextHelpers.getAdminContext(), list.id, emailNew, false);
@ -463,8 +490,13 @@ router.postAsync('/:lcid/manage-address', passport.parseForm, passport.csrfProte
if (newSubscription && newSubscription.status === SubscriptionStatus.SUBSCRIBED) {
await mailHelpers.sendAlreadySubscribed(list, emailNew, subscription);
} else {
const data = {
subscriptionId: subscription.id,
emailNew
};
const confirmCid = await confirmations.addConfirmation(list.id, 'change-address', req.ip, data);
await mailHelpers.sendConfirmAddressChange(list, emailNew, confirmCid, subscription, sendWebResponse);
await mailHelpers.sendConfirmAddressChange(list, emailNew, confirmCid, subscription);
}
req.flash('info', _('An email with further instructions has been sent to the provided address'));
@ -476,7 +508,7 @@ router.postAsync('/:lcid/manage-address', passport.parseForm, passport.csrfProte
router.getAsync('/:lcid/unsubscribe/:ucid', passport.csrfProtection, async (req, res) => {
const list = await lists.getByCid(req.params.lcid);
const list = await lists.getByCid(contextHelpers.getAdminContext(), req.params.lcid);
const configItems = await settings.get(['defaultAddress', 'defaultPostaddress']);
@ -486,8 +518,8 @@ router.getAsync('/:lcid/unsubscribe/:ucid', passport.csrfProtection, async (req,
handleUnsubscribe(list, req.params.ucid, autoUnsubscribe, req.query.c, req.ip, res, next);
} else if (req.query.formTest ||
list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM ||
list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) {
list.unsubscription_mode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM ||
list.unsubscription_mode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) {
const subscription = await subscriptions.getByCid(contextHelpers.getAdminContext(), list.id, req.params.ucid, false);
@ -495,20 +527,22 @@ router.getAsync('/:lcid/unsubscribe/:ucid', passport.csrfProtection, async (req,
throw new interoperableErrors.NotFoundError('Subscription not found in this list');
}
subscription.lcid = req.params.lcid;
subscription.ucid = req.params.ucid;
subscription.title = list.name;
subscription.csrfToken = req.csrfToken();
subscription.campaign = req.query.c;
subscription.defaultAddress = configItems.defaultAddress;
subscription.defaultPostaddress = configItems.defaultPostaddress;
const data = {};
data.email = subscription.email;
data.lcid = req.params.lcid;
data.ucid = req.params.ucid;
data.title = list.name;
data.csrfToken = req.csrfToken();
data.campaign = req.query.c;
data.defaultAddress = configItems.defaultAddress;
data.defaultPostaddress = configItems.defaultPostaddress;
subscription.template = {
data.template = {
template: 'subscription/web-unsubscribe.mjml.hbs',
layout: 'subscription/layout.mjml.hbs'
};
await injectCustomFormData(req.query.fid || list.default_form, 'subscription/web-unsubscribe', subscription);
await injectCustomFormData(req.query.fid || list.default_form, 'subscription/web-unsubscribe', data);
const htmlRenderer = await getMjmlTemplate(data.template);
@ -526,7 +560,7 @@ router.getAsync('/:lcid/unsubscribe/:ucid', passport.csrfProtection, async (req,
router.postAsync('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtection, async (req, res) => {
const list = await lists.getByCid(req.params.lcid);
const list = await lists.getByCid(contextHelpers.getAdminContext(), req.params.lcid);
const campaignCid = (req.body.campaign || '').toString().trim() || false;
@ -535,8 +569,8 @@ router.postAsync('/:lcid/unsubscribe', passport.parseForm, passport.csrfProtecti
async function handleUnsubscribe(list, subscriptionCid, autoUnsubscribe, campaignCid, ip, res) {
if ((list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM) ||
(autoUnsubscribe && (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM)) ) {
if ((list.unsubscription_mode === lists.UnsubscriptionMode.ONE_STEP || list.unsubscription_mode === lists.UnsubscriptionMode.ONE_STEP_WITH_FORM) ||
(autoUnsubscribe && (list.unsubscription_mode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscription_mode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM)) ) {
try {
const subscription = await subscriptions.unsubscribeByCidAndGet(contextHelpers.getAdminContext(), list.id, subscriptionCid, campaignCid);
@ -558,7 +592,7 @@ async function handleUnsubscribe(list, subscriptionCid, autoUnsubscribe, campaig
throw new interoperableErrors.NotFoundError('Subscription not found in this list');
}
if (list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscriptionMode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) {
if (list.unsubscription_mode === lists.UnsubscriptionMode.TWO_STEP || list.unsubscription_mode === lists.UnsubscriptionMode.TWO_STEP_WITH_FORM) {
const data = {
subscriptionCid,
@ -638,7 +672,7 @@ router.postAsync('/publickey', passport.parseForm, async (req, res) => {
async function webNotice(type, req, res) {
const list = await lists.getByCid(req.params.cid);
const list = await lists.getByCid(contextHelpers.getAdminContext(), req.params.cid);
const configItems = await settings.get(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress', 'adminEmail']);

View file

@ -105,13 +105,6 @@ class InvalidConfirmationForUnsubscriptionError extends InteroperableError {
}
}
class SubscriptionNotAllowedError extends InteroperableError {
constructor(msg, data) {
super('SubscriptionNotAllowedError', msg, data);
this.status = 403;
}
}
const errorTypes = {
InteroperableError,
NotLoggedInError,
@ -129,8 +122,7 @@ const errorTypes = {
PermissionDeniedError,
InvalidConfirmationForSubscriptionError,
InvalidConfirmationForAddressChangeError,
InvalidConfirmationForUnsubscriptionError,
SubscriptionNotAllowedError
InvalidConfirmationForUnsubscriptionError
};
function deserialize(errorObj) {

24
shared/package-lock.json generated Normal file
View file

@ -0,0 +1,24 @@
{
"name": "mailtrain-shared",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"moment": {
"version": "2.20.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.20.1.tgz",
"integrity": "sha512-Yh9y73JRljxW5QxN08Fner68eFLxM5ynNOAw2LbIB1YAGeQzZT8QFSUvkAz609Zf+IHhhaUxqZK8dG3W/+HEvg=="
},
"moment-timezone": {
"version": "0.5.14",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.14.tgz",
"integrity": "sha1-TrOP+VOLgBCLpGekWPPtQmjM/LE=",
"requires": {
"moment": "2.20.1"
}
},
"owasp-password-strength-test": {
"version": "github:bures/owasp-password-strength-test#50bfcf0035b1468b9d03a00eaf561d4fed4973eb"
}
}
}

20
shared/package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "mailtrain-shared",
"version": "1.0.0",
"description": "Self hosted email newsletter app - shared lib",
"repository": {
"type": "git",
"url": "git://github.com/Mailtrain-org/mailtrain.git"
},
"author": "",
"license": "GPL-3.0",
"homepage": "https://mailtrain.org/",
"engines": {
"node": ">=5.0.0"
},
"dependencies": {
"moment": "^2.18.1",
"moment-timezone": "^0.5.13",
"owasp-password-strength-test": "github:bures/owasp-password-strength-test"
}
}

View file

@ -181,7 +181,7 @@
<footer class="footer">
<div class="container-fluid">
<p class="text-muted">&copy; 2016 Kreata OÜ <a href="https://mailtrain.org">Mailtrain.org</a>, <a href="mailto:info@mailtrain.org">info@mailtrain.org</a>. <a href="https://github.com/Mailtrain-org/mailtrain">{{#translate}}Source on GitHub{{/translate}}</a></p>
<p class="text-muted">&copy; 2018 <a href="https://mailtrain.org">Mailtrain.org</a>, <a href="mailto:info@mailtrain.org">info@mailtrain.org</a>. <a href="https://github.com/Mailtrain-org/mailtrain">{{#translate}}Source on GitHub{{/translate}}</a></p>
</div>
</footer>