Implemented basic support for GDPR

This commit is contained in:
Tomas Bures 2018-11-22 00:02:14 +03:00
parent 9f9cbc4c2b
commit 92ca1c0f28
21 changed files with 271 additions and 105 deletions

View file

@ -69,7 +69,8 @@ export default class CUD extends Component {
'web_unsubscribed_notice', 'web_unsubscribed_notice',
'mail_unsubscription_confirmed_html', 'mail_unsubscription_confirmed_html',
'mail_unsubscription_confirmed_text', 'mail_unsubscription_confirmed_text',
'web_manual_unsubscribe_notice' 'web_manual_unsubscribe_notice',
'web_privacy_policy_notice'
]; ];
this.initForm({ this.initForm({
@ -206,6 +207,11 @@ export default class CUD extends Component {
label: t('webManualUnsubscribeNotice'), label: t('webManualUnsubscribeNotice'),
mode: 'html', mode: 'html',
help: helpMjmlGeneral help: helpMjmlGeneral
},
web_privacy_policy_notice: {
label: t('Privacy policy'),
mode: 'html',
help: helpMjmlGeneral
} }
}; };
@ -254,6 +260,12 @@ export default class CUD extends Component {
'web_manual_unsubscribe_notice' 'web_manual_unsubscribe_notice'
] ]
}, },
gdpr: {
label: t('Data protection'),
options: [
'web_privacy_policy_notice'
]
},
}; };
} }

View file

@ -39,6 +39,8 @@ import axios from "../../lib/axios";
import {getUrl} from "../../lib/urls"; import {getUrl} from "../../lib/urls";
import listStyles from "../styles.scss"; import listStyles from "../styles.scss";
import styles from "../../lib/styles.scss"; import styles from "../../lib/styles.scss";
import interoperableErrors
from "../../../../shared/interoperable-errors";
function truncate(str, len, ending = '...') { function truncate(str, len, ending = '...') {
@ -185,6 +187,10 @@ export default class CUD extends Component {
} }
async submitHandler() { async submitHandler() {
await this.save();
}
async save(runAfterSave) {
const t = this.props.t; const t = this.props.t;
const isEdit = !!this.props.entity; const isEdit = !!this.props.entity;
@ -275,6 +281,16 @@ export default class CUD extends Component {
if (!isEdit) { if (!isEdit) {
this.navigateTo(`/lists/${this.props.list.id}/imports/${submitResponse}/edit`); this.navigateTo(`/lists/${this.props.list.id}/imports/${submitResponse}/edit`);
} else { } else {
try {
await axios.post(getUrl(`rest/import-start/${this.props.list.id}/${this.props.entity.id}`));
} catch (err) {
if (err instanceof interoperableErrors.InvalidStateError) {
// Just mask the fact that it's not possible to start anything and refresh instead.
} else {
throw err;
}
}
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/imports/${this.props.entity.id}/status`, 'success', t('importSaved')); this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/imports/${this.props.entity.id}/status`, 'success', t('importSaved'));
} }
@ -389,11 +405,12 @@ export default class CUD extends Component {
} }
} }
let saveButtonLabel; const saveButtons = []
if (!isEdit) { if (!isEdit) {
saveButtonLabel = t('saveAndEditSettings'); saveButtons.push(<Button key="default" type="submit" className="btn-primary" icon="ok" label={t('saveAndEditSettings')}/>);
} else { } else {
saveButtonLabel = t('save'); saveButtons.push(<Button key="default" type="submit" className="btn-primary" icon="ok" label={t('save')}/>);
saveButtons.push(<Button key="saveAndRun" className="btn-primary" icon="ok" label={t('Save and Run')} onClickAsync={async () => await this.save(true)}/>);
} }
return ( return (
@ -427,7 +444,7 @@ export default class CUD extends Component {
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={saveButtonLabel}/> {saveButtons}
{isEdit && <NavButton className="btn-danger" icon="remove" label={t('delete')} linkTo={`/lists/${this.props.list.id}/imports/${this.props.entity.id}/delete`}/>} {isEdit && <NavButton className="btn-danger" icon="remove" label={t('delete')} linkTo={`/lists/${this.props.list.id}/imports/${this.props.entity.id}/delete`}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>

View file

@ -96,13 +96,13 @@ export function getRuleHelpers(t, fields) {
}; };
// FXIME - the localization here is still wrong // FXIME - the localization here is still wrong
function getRelativeDateTreeLabel(rule, textFragment) { function getRelativeDateTreeLabel(rule, variants) {
if (rule.value === 0) { if (rule.value === 0) {
return t(/*ignore*/'Date in column ' + textFragment + ' the current date', {colName: ruleHelpers.getColumnName(rule.column)}) return t(variants[0], {colName: ruleHelpers.getColumnName(rule.column)})
} else if (rule.value > 0) { } else if (rule.value > 0) {
return t(/*ignore*/'Date in column ' + textFragment + ' {{value}}-th day after the current date', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}); return t(variants[1], {colName: ruleHelpers.getColumnName(rule.column), value: rule.value});
} else { } else {
return t(/*ignore*/'Date in column ' + textFragment + ' {{value}}-th day before the current date', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}); return t(variants[2], {colName: ruleHelpers.getColumnName(rule.column), value: rule.value});
} }
} }
@ -129,48 +129,23 @@ export function getRuleHelpers(t, fields) {
}, },
eqTodayPlusDays: { eqTodayPlusDays: {
dropdownLabel: t('onXthDayBeforeafterCurrentDate'), dropdownLabel: t('onXthDayBeforeafterCurrentDate'),
/* treeLabel: rule => getRelativeDateTreeLabel(rule, 'is', [tMark('dateInColumnIsTheCurrentDate'), tMark('dateInColumnIsValuethDayAfterTheCurrent'), tMark('dateInColumnIsValuethDayBeforeTheCurrent')]),
tMark('dateInColumnIsTheCurrentDate')
tMark('dateInColumnIsValuethDayAfterTheCurrent')
tMark('dateInColumnIsValuethDayBeforeTheCurrent')
*/
treeLabel: rule => getRelativeDateTreeLabel(rule, 'is'),
}, },
ltTodayPlusDays: { ltTodayPlusDays: {
dropdownLabel: t('beforeXthDayBeforeafterCurrentDate'), dropdownLabel: t('beforeXthDayBeforeafterCurrentDate'),
/* treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnIsBeforeTheCurrentDate'), tMark('dateInColumnIsBeforeValuethDayAfterThe'), tMark('dateInColumnIsBeforeValuethDayBeforeThe')]),
tMark('dateInColumnIsBeforeTheCurrentDate')
tMark('dateInColumnIsBeforeValuethDayAfterThe')
tMark('dateInColumnIsBeforeValuethDayBeforeThe')
*/
treeLabel: rule => getRelativeDateTreeLabel(rule, 'is before'),
}, },
leTodayPlusDays: { leTodayPlusDays: {
dropdownLabel: t('beforeOrOnXthDayBeforeafterCurrentDate'), dropdownLabel: t('beforeOrOnXthDayBeforeafterCurrentDate'),
/* treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnIsBeforeOrOnTheCurrentDate'), tMark('dateInColumnIsBeforeOrOnValuethDayAfter'), tMark('dateInColumnIsBeforeOrOnValuethDayBefore')]),
tMark('dateInColumnIsBeforeOrOnTheCurrentDate')
tMark('dateInColumnIsBeforeOrOnValuethDayAfter')
tMark('dateInColumnIsBeforeOrOnValuethDayBefore')
*/
treeLabel: rule => getRelativeDateTreeLabel(rule, 'is before or on'),
}, },
gtTodayPlusDays: { gtTodayPlusDays: {
dropdownLabel: t('afterXthDayBeforeafterCurrentDate'), dropdownLabel: t('afterXthDayBeforeafterCurrentDate'),
/* treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnIsAfterTheCurrentDate'), tMark('dateInColumnIsAfterValuethDayAfterThe'), tMark('dateInColumnIsAfterValuethDayAfterThe')]),
tMark('dateInColumnIsAfterTheCurrentDate')
tMark('dateInColumnIsAfterValuethDayAfterThe')
tMark('dateInColumnIsAfterValuethDayAfterThe')
*/
treeLabel: rule => getRelativeDateTreeLabel(rule, 'is after'),
}, },
geTodayPlusDays: { geTodayPlusDays: {
dropdownLabel: t('afterOrOnXthDayBeforeafterCurrentDate'), dropdownLabel: t('afterOrOnXthDayBeforeafterCurrentDate'),
/* treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnIsAfterOrOnTheCurrentDate'), tMark('dateInColumnIsAfterOrOnValuethDayAfter'), tMark('dateInColumnIsAfterOrOnValuethDayAfter')]),
tMark('dateInColumnIsAfterOrOnTheCurrentDate')
tMark('dateInColumnIsAfterOrOnValuethDayAfter')
tMark('dateInColumnIsAfterOrOnValuethDayAfter')
*/
treeLabel: rule => getRelativeDateTreeLabel(rule, 'is after or on'),
} }
}; };

View file

@ -1,3 +1,7 @@
.mapping { .mapping {
margin-top: 30px; margin-top: 30px;
}
.erased {
color: #808080;
} }

View file

@ -13,14 +13,18 @@ import {
withForm withForm
} from '../../lib/form'; } from '../../lib/form';
import {Icon, Button} from "../../lib/bootstrap-components"; import {Icon, Button} from "../../lib/bootstrap-components";
import axios from '../../lib/axios'; import axios, {HTTPMethod} from '../../lib/axios';
import {getFieldTypes, getSubscriptionStatusLabels} from './helpers'; import {getFieldTypes, getSubscriptionStatusLabels} from './helpers';
import {getUrl, getPublicUrl} from "../../lib/urls"; import {getUrl, getPublicUrl} from "../../lib/urls";
import { import {
DeleteModalDialog,
RestActionModalDialog,
tableDeleteDialogAddDeleteButton, tableDeleteDialogAddDeleteButton,
tableDeleteDialogInit, tableDeleteDialogInit,
tableDeleteDialogRender tableDeleteDialogRender
} from "../../lib/modals"; } from "../../lib/modals";
import listStyles from "../styles.scss";
import styles from '../../lib/styles.scss';
@withTranslation() @withTranslation()
@withForm @withForm
@ -87,7 +91,7 @@ export default class List extends Component {
const columns = [ const columns = [
{ data: 1, title: t('id'), render: data => <code>{data}</code> }, { data: 1, title: t('id'), render: data => <code>{data}</code> },
{ data: 2, title: t('email') }, { data: 2, title: t('email'), render: data => data === null ? <span className={listStyles.erased}>{t('[ERASED]')}</span> : data },
{ data: 3, title: t('status'), render: (data, display, rowData) => this.subscriptionStatusLabels[data] + (rowData[5] ? ', ' + t('blacklisted') : '') }, { data: 3, title: t('status'), render: (data, display, rowData) => this.subscriptionStatusLabels[data] + (rowData[5] ? ', ' + t('blacklisted') : '') },
{ data: 4, title: t('created'), render: data => data ? moment(data).fromNow() : '' } { data: 4, title: t('created'), render: data => data ? moment(data).fromNow() : '' }
]; ];
@ -112,27 +116,30 @@ export default class List extends Component {
columns.push({ columns.push({
actions: data => { actions: data => {
const actions = []; const actions = [];
const id = data[0];
const email = data[2];
const status = data[3];
actions.push({ actions.push({
label: <Icon icon="edit" title={t('edit')}/>, label: <Icon icon="edit" title={t('edit')}/>,
link: `/lists/${this.props.list.id}/subscriptions/${data[0]}/edit` link: `/lists/${this.props.list.id}/subscriptions/${id}/edit`
}); });
if (data[3] === SubscriptionStatus.SUBSCRIBED) { if (email && status === SubscriptionStatus.SUBSCRIBED) {
actions.push({ actions.push({
label: <Icon icon="off" title={t('unsubscribe')}/>, label: <Icon icon="off" title={t('unsubscribe')}/>,
action: () => this.unsubscribeSubscription(data[0]) action: () => this.unsubscribeSubscription(id)
}); });
} }
if (!data[5]) { if (email && !data[5]) {
actions.push({ actions.push({
label: <Icon icon="ban-circle" title={t('blacklist')}/>, label: <Icon icon="ban-circle" title={t('blacklist')}/>,
action: () => this.blacklistSubscription(data[2]) action: () => this.blacklistSubscription(email)
}); });
} }
tableDeleteDialogAddDeleteButton(actions, this, null, data[0], data[2]); tableDeleteDialogAddDeleteButton(actions, this, null, id, email);
return actions; return actions;
} }

View file

@ -49,7 +49,11 @@ import axios
import {getUrl} from "./lib/urls"; import {getUrl} from "./lib/urls";
import {langCodes} from "../../shared/langs"; import {langCodes} from "../../shared/langs";
const topLevelMenuKeys = ['lists', 'templates', 'campaigns', 'reports']; const topLevelMenuKeys = ['lists', 'templates', 'campaigns'];
if (mailtrainConfig.reportsEnabmed) {
topLevelMenuKeys.push('reports');
}
@withTranslation() @withTranslation()
class Root extends Component { class Root extends Component {

View file

@ -14,6 +14,20 @@
# Process title visible in monitoring logs and process listing # Process title visible in monitoring logs and process listing
title: mailtrain title: mailtrain
# GDPR related settings (https://en.wikipedia.org/wiki/General_Data_Protection_Regulation)
gdpr:
# If enabled, after a user unsubscribes or complains, his/her data are removed from the subscription.
# Only hashed email remains to prevent resubscription via import
deleteDataAfterUnsubscribe:
enabled: true
secondsAfterUnsubscribe: 86400 # 1 day
# If enabled, after the time below, an entry of a subscriber that unsubscribed or complained
# is completely removed from the subscribers list (including the hashed email)
deleteSubscriptionAfterUnsubscribe:
enabled: true
secondsAfterUnsubscribe: 2592000 # 30 days
# Enabled HTML editors # Enabled HTML editors
editors: editors:
- grapesjs - grapesjs
@ -178,7 +192,7 @@ reports:
# performing network operations and in generating XSS attacks as part of the report. # performing network operations and in generating XSS attacks as part of the report.
# The bottom line is that if people who are creating report templates or have write access to the DB cannot be trusted, # The bottom line is that if people who are creating report templates or have write access to the DB cannot be trusted,
# then it's safer to switch off the reporting functionality below. # then it's safer to switch off the reporting functionality below.
enabled: false enabled: true
testServer: testServer:
# Starts a vanity server that redirects all mail to /dev/null # Starts a vanity server that redirects all mail to /dev/null

View file

@ -6,6 +6,7 @@ const appBuilder = require('./app-builder');
const translate = require('./lib/translate'); const translate = require('./lib/translate');
const http = require('http'); const http = require('http');
const triggers = require('./services/triggers'); const triggers = require('./services/triggers');
const gdprCleanup = require('./services/gdpr-cleanup');
const importer = require('./lib/importer'); const importer = require('./lib/importer');
const feedcheck = require('./lib/feedcheck'); const feedcheck = require('./lib/feedcheck');
const verpServer = require('./services/verp-server'); const verpServer = require('./services/verp-server');
@ -105,6 +106,7 @@ dbcheck(err => { // Check if database needs upgrading before starting the server
feedcheck.spawn(() => { feedcheck.spawn(() => {
senders.spawn(() => { senders.spawn(() => {
triggers.start(); triggers.start();
gdprCleanup.start();
postfixBounceServer(async () => { postfixBounceServer(async () => {
(async () => { (async () => {

View file

@ -41,7 +41,8 @@ async function getAuthenticatedConfig(context) {
globalPermissions, globalPermissions,
editors: config.editors, editors: config.editors,
mosaico: config.mosaico, mosaico: config.mosaico,
verpEnabled: config.verp.enabled verpEnabled: config.verp.enabled,
reportsEnabled: config.reports.enabled
} }
} }

View file

@ -34,6 +34,8 @@ async function search(context, offset, limit, search) {
} }
async function add(context, email) { async function add(context, email) {
enforce(email, 'Email has to be set');
return await knex.transaction(async tx => { return await knex.transaction(async tx => {
shares.enforceGlobalPermission(context, 'manageBlacklist'); shares.enforceGlobalPermission(context, 'manageBlacklist');
@ -45,11 +47,15 @@ async function add(context, email) {
} }
async function remove(context, email) { async function remove(context, email) {
enforce(email, 'Email has to be set');
shares.enforceGlobalPermission(context, 'manageBlacklist'); shares.enforceGlobalPermission(context, 'manageBlacklist');
await knex('blacklist').where('email', email).del(); await knex('blacklist').where('email', email).del();
} }
async function isBlacklisted(email) { async function isBlacklisted(email) {
enforce(email, 'Email has to be set');
const existing = await knex('blacklist').where('email', email).first(); const existing = await knex('blacklist').where('email', email).first();
return !!existing; return !!existing;
} }

View file

@ -9,7 +9,10 @@ const shares = require('./shares');
const namespaceHelpers = require('../lib/namespace-helpers'); const namespaceHelpers = require('../lib/namespace-helpers');
const fs = require('fs-extra'); const fs = require('fs-extra');
const path = require('path'); const path = require('path');
const mjml = require('mjml'); const mjml = require('mjml');
const mjml2html = mjml.default;
const lists = require('./lists'); const lists = require('./lists');
const dependencyHelpers = require('../lib/dependency-helpers'); const dependencyHelpers = require('../lib/dependency-helpers');
@ -43,7 +46,8 @@ const allowedFormKeys = new Set([
'web_unsubscribed_notice', 'web_unsubscribed_notice',
'mail_unsubscription_confirmed_html', 'mail_unsubscription_confirmed_html',
'mail_unsubscription_confirmed_text', 'mail_unsubscription_confirmed_text',
'web_manual_unsubscribe_notice' 'web_manual_unsubscribe_notice',
'web_privacy_policy_notice'
]); ]);
const hashKeys = new Set([...formAllowedKeys, ...allowedFormKeys]); const hashKeys = new Set([...formAllowedKeys, ...allowedFormKeys]);
@ -84,11 +88,15 @@ async function _getById(tx, id) {
} }
async function getById(context, id) { async function getById(context, id, withPermissions = true) {
return await knex.transaction(async tx => { return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'customForm', id, 'view'); await shares.enforceEntityPermissionTx(tx, context, 'customForm', id, 'view');
const entity = await _getById(tx, id); const entity = await _getById(tx, id);
entity.permissions = await shares.getPermissionsTx(tx, context, 'customForm', id);
if (withPermissions) {
entity.permissions = await shares.getPermissionsTx(tx, context, 'customForm', id);
}
return entity; return entity;
}); });
} }
@ -219,7 +227,7 @@ function checkForMjmlErrors(form) {
let compiled; let compiled;
try { try {
compiled = mjml(source); compiled = mjml2html(source);
} catch (err) { } catch (err) {
return err; return err;
} }
@ -230,7 +238,7 @@ function checkForMjmlErrors(form) {
const errors = {}; const errors = {};
for (const key in form) { for (const key in form) {
if (key.startsWith('mail_') || key.startsWith('web_')) { if ((key.startsWith('mail_') && key.endsWith('_html')) || key.startsWith('web_')) {
const template = form[key]; const template = form[key];
const errs = hasMjmlError(template); const errs = hasMjmlError(template);

View file

@ -121,7 +121,7 @@ async function create(context, entity) {
await knex.schema.raw('CREATE TABLE `subscription__' + id + '` (\n' + await knex.schema.raw('CREATE TABLE `subscription__' + id + '` (\n' +
' `id` int(10) unsigned NOT NULL AUTO_INCREMENT,\n' + ' `id` int(10) unsigned NOT NULL AUTO_INCREMENT,\n' +
' `cid` varchar(255) CHARACTER SET ascii NOT NULL,\n' + ' `cid` varchar(255) CHARACTER SET ascii NOT NULL,\n' +
' `email` varchar(255) CHARACTER SET utf8 NOT NULL,\n' + ' `email` varchar(255) CHARACTER SET utf8 DEFAULT NULL,\n' +
' `hash_email` varchar(255) CHARACTER SET ascii NOT NULL,\n' + ' `hash_email` varchar(255) CHARACTER SET ascii NOT NULL,\n' +
' `source_email` int(10) unsigned,\n' + // This references imports if the source is an import, 0 means some import in version 1, NULL if the source is via subscription or edit of the subscription ' `source_email` int(10) unsigned,\n' + // This references imports if the source is an import, 0 means some import in version 1, NULL if the source is via subscription or edit of the subscription
' `opt_in_ip` varchar(100) DEFAULT NULL,\n' + ' `opt_in_ip` varchar(100) DEFAULT NULL,\n' +
@ -134,8 +134,9 @@ async function create(context, entity) {
' `latest_click` timestamp NULL DEFAULT NULL,\n' + ' `latest_click` timestamp NULL DEFAULT NULL,\n' +
' `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n' + ' `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,\n' +
' PRIMARY KEY (`id`),\n' + ' PRIMARY KEY (`id`),\n' +
' UNIQUE KEY `email` (`email`),\n' + ' UNIQUE KEY `hash_email` (`hash_email`),\n' +
' UNIQUE KEY `cid` (`cid`),\n' + ' UNIQUE KEY `cid` (`cid`),\n' +
' KEY `email` (`email`),\n' +
' KEY `status` (`status`),\n' + ' KEY `status` (`status`),\n' +
' KEY `subscriber_tz` (`tz`),\n' + ' KEY `subscriber_tz` (`tz`),\n' +
' KEY `is_test` (`is_test`),\n' + ' KEY `is_test` (`is_test`),\n' +

View file

@ -1,5 +1,6 @@
'use strict'; 'use strict';
const config = require('config');
const knex = require('../lib/knex'); const knex = require('../lib/knex');
const hasher = require('node-object-hash')(); const hasher = require('node-object-hash')();
const shortid = require('shortid'); const shortid = require('shortid');
@ -7,7 +8,7 @@ const dtHelpers = require('../lib/dt-helpers');
const interoperableErrors = require('../../shared/interoperable-errors'); const interoperableErrors = require('../../shared/interoperable-errors');
const shares = require('./shares'); const shares = require('./shares');
const fields = require('./fields'); const fields = require('./fields');
const { SubscriptionStatus, getFieldColumn } = require('../../shared/lists'); const { SubscriptionSource, SubscriptionStatus, getFieldColumn } = require('../../shared/lists');
const segments = require('./segments'); const segments = require('./segments');
const { enforce, filterObject } = require('../lib/helpers'); const { enforce, filterObject } = require('../lib/helpers');
const moment = require('moment'); const moment = require('moment');
@ -475,7 +476,7 @@ async function _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, meta
} }
const existingWithKey = await existingWithKeyQuery.first(); const existingWithKey = await existingWithKeyQuery.first();
if (existingWithKey) { if (existingWithKey) {
if (meta && (meta.updateAllowed || (meta.updateOfUnsubscribedAllowed && existingWithKey.status === SubscriptionStatus.UNSUBSCRIBED))) { if (existingWithKey.email === null || meta.updateAllowed || (meta.updateOfUnsubscribedAllowed && existingWithKey.status === SubscriptionStatus.UNSUBSCRIBED)) {
meta.update = true; meta.update = true;
meta.existing = existingWithKey; meta.existing = existingWithKey;
} else { } else {
@ -486,12 +487,12 @@ async function _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, meta
// The same for import where we need to subscribed only those (existing and new) that have not been unsubscribed already. // The same for import where we need to subscribed only those (existing and new) that have not been unsubscribed already.
// In the case, the subscription is existing, we should not change the status. If it does not exist, we are fine with changing the status to SUBSCRIBED // In the case, the subscription is existing, we should not change the status. If it does not exist, we are fine with changing the status to SUBSCRIBED
if (meta && meta.subscribeIfNoExisting && !entity.status) { if (meta.subscribeIfNoExisting && !entity.status) {
entity.status = SubscriptionStatus.SUBSCRIBED; entity.status = SubscriptionStatus.SUBSCRIBED;
} }
} }
if ((isCreate && !(meta && meta.update)) || 'status' in entity) { if ((isCreate && !meta.update) || 'status' in entity) {
enforce(entity.status >= SubscriptionStatus.MIN && entity.status <= SubscriptionStatus.MAX, 'Invalid status'); enforce(entity.status >= SubscriptionStatus.MIN && entity.status <= SubscriptionStatus.MAX, 'Invalid status');
} }
@ -532,26 +533,69 @@ function updateSourcesAndHashEmail(subscription, source, groupedFieldsMap) {
} }
} }
async function _update(tx, listId, existing, filteredEntity) {
function purgeSensitiveData(subscription, groupedFieldsMap) {
subscription.email = null;
for (const fldCol in groupedFieldsMap) {
const fld = groupedFieldsMap[fldCol];
const fieldType = fields.getFieldType(fld.type);
if (fieldType.grouped) {
for (const optionKey in fld.groupedOptions) {
const option = fld.groupedOptions[optionKey];
subscription[option.column] = null;
subscription['source_' + option.column] = SubscriptionSource.ERASED;
}
} else {
subscription[fldCol] = null;
subscription['source_' + fldCol] = SubscriptionSource.ERASED;
}
}
}
async function _update(tx, listId, groupedFieldsMap, existing, filteredEntity) {
if ('status' in filteredEntity) { if ('status' in filteredEntity) {
if (existing.status !== filteredEntity.status) { if (existing.status !== filteredEntity.status) {
filteredEntity.status_change = new Date(); filteredEntity.status_change = new Date();
} }
} }
console.log(filteredEntity); if (filteredEntity.status === SubscriptionStatus.UNSUBSCRIBED || filteredEntity.status === SubscriptionStatus.COMPLAINED) {
await tx(getSubscriptionTableName(listId)).where('id', existing.id).update(filteredEntity); if (existing.unsubscribed === null) {
filteredEntity.unsubscribed = new Date();
if ('status' in 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) { if (config.gdpr.deleteSubscriptionAfterUnsubscribe.enabled && config.gdpr.deleteSubscriptionAfterUnsubscribe.secondsAfterUnsubscribe === 0) {
await tx('lists').where('id', listId).increment('subscribers', countIncrement); filteredEntity = null;
} else if (config.gdpr.deleteDataAfterUnsubscribe.enabled && config.gdpr.deleteDataAfterUnsubscribe.secondsAfterUnsubscribe === 0) {
purgeSensitiveData(filteredEntity, groupedFieldsMap);
}
} else if (filteredEntity.status === SubscriptionStatus.SUBSCRIBED) {
filteredEntity.unsubscribed = null;
}
if (filteredEntity) {
await tx(getSubscriptionTableName(listId)).where('id', existing.id).update(filteredEntity);
if ('status' in 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);
}
}
} else {
await tx(getSubscriptionTableName(listId)).where('id', existing.id).del();
if (existing.status === SubscriptionStatus.SUBSCRIBED) {
await tx('lists').where('id', listId).decrement('subscribers', 1);
} }
} }
} }
@ -586,20 +630,17 @@ async function createTxWithGroupedFieldsMap(tx, context, listId, groupedFieldsMa
updateSourcesAndHashEmail(filteredEntity, source, groupedFieldsMap); updateSourcesAndHashEmail(filteredEntity, source, groupedFieldsMap);
filteredEntity.opt_in_ip = meta && meta.ip; filteredEntity.opt_in_ip = meta.ip;
filteredEntity.opt_in_country = meta && meta.country; filteredEntity.opt_in_country = meta.country;
if (meta && meta.update) { // meta.update is set by _validateAndPreprocess if (meta.update) { // meta.update is set by _validateAndPreprocess
await _update(tx, listId, meta.existing, filteredEntity); await _update(tx, listId, groupedFieldsMap, meta.existing, filteredEntity);
meta.cid = meta.existing.cid; // The cid is needed by /confirm/subscribe/:cid meta.cid = meta.existing.cid; // The cid is needed by /confirm/subscribe/:cid
return meta.existing.id; return meta.existing.id;
} else { } else {
filteredEntity.cid = shortid.generate(); filteredEntity.cid = shortid.generate();
meta.cid = filteredEntity.cid; // The cid is needed by /confirm/subscribe/:cid
if (meta) {
meta.cid = filteredEntity.cid; // The cid is needed by /confirm/subscribe/:cid
}
return await _create(tx, listId, filteredEntity); return await _create(tx, listId, filteredEntity);
} }
} }
@ -630,7 +671,7 @@ async function updateWithConsistencyCheck(context, listId, entity, source) {
throw new interoperableErrors.ChangedError(); throw new interoperableErrors.ChangedError();
} }
await _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, null, false); await _validateAndPreprocess(tx, listId, groupedFieldsMap, entity, {}, false);
const filteredEntity = filterObject(entity, allowedKeys); const filteredEntity = filterObject(entity, allowedKeys);
@ -638,7 +679,7 @@ async function updateWithConsistencyCheck(context, listId, entity, source) {
updateSourcesAndHashEmail(filteredEntity, source, groupedFieldsMap); updateSourcesAndHashEmail(filteredEntity, source, groupedFieldsMap);
await _update(tx, listId, existing, filteredEntity); await _update(tx, listId, groupedFieldsMap, existing, filteredEntity);
}); });
} }
@ -674,15 +715,13 @@ async function removeByEmailAndGet(context, listId, email) {
async function _changeStatusTx(tx, context, listId, existing, newStatus) { async function _changeStatusTx(tx, context, listId, existing, newStatus) {
enforce(newStatus !== SubscriptionStatus.SUBSCRIBED); enforce(newStatus !== SubscriptionStatus.SUBSCRIBED);
const groupedFieldsMap = await getGroupedFieldsMapTx(tx, listId);
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions'); await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSubscriptions');
await tx(getSubscriptionTableName(listId)).where('id', existing.id).update({ await _update(tx, listId, groupedFieldsMap, existing, {
status: newStatus status: newStatus
}); });
if (existing.status === SubscriptionStatus.SUBSCRIBED) {
await tx('lists').where('id', listId).decrement('subscribers', 1);
}
} }
async function _unsubscribeExistingAndGetTx(tx, context, listId, existing) { async function _unsubscribeExistingAndGetTx(tx, context, listId, existing) {
@ -824,4 +863,5 @@ module.exports.unsubscribeByEmailAndGetTx = unsubscribeByEmailAndGetTx;
module.exports.updateAddressAndGet = updateAddressAndGet; module.exports.updateAddressAndGet = updateAddressAndGet;
module.exports.updateManaged = updateManaged; module.exports.updateManaged = updateManaged;
module.exports.getListsWithEmail = getListsWithEmail; module.exports.getListsWithEmail = getListsWithEmail;
module.exports.changeStatusTx = changeStatusTx; module.exports.changeStatusTx = changeStatusTx;
module.exports.purgeSensitiveData = purgeSensitiveData;

View file

@ -23,7 +23,7 @@ router.getAsync('/subscriptions/:listId/:subscriptionId', passport.loggedIn, asy
}); });
router.postAsync('/subscriptions/:listId', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.postAsync('/subscriptions/:listId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
return res.json(await subscriptions.create(req.context, castToInteger(req.params.listId), req.body, SubscriptionSource.ADMIN_FORM)); return res.json(await subscriptions.create(req.context, castToInteger(req.params.listId), req.body, SubscriptionSource.ADMIN_FORM, {}));
}); });
router.putAsync('/subscriptions/:listId/:subscriptionId', passport.loggedIn, passport.csrfProtection, async (req, res) => { router.putAsync('/subscriptions/:listId/:subscriptionId', passport.loggedIn, passport.csrfProtection, async (req, res) => {

View file

@ -82,7 +82,7 @@ async function injectCustomFormData(customFormId, viewKey, data) {
return; return;
} }
const form = await forms.getById(contextHelpers.getAdminContext(), customFormId); const form = await forms.getById(contextHelpers.getAdminContext(), customFormId, false);
data.template.template = form[viewKey] || data.template.template; data.template.template = form[viewKey] || data.template.template;
data.template.layout = form.layout || data.template.layout; data.template.layout = form.layout || data.template.layout;
@ -539,7 +539,6 @@ router.getAsync('/:lcid/unsubscribe/:ucid', passport.csrfProtection, async (req,
data.isWeb = true; data.isWeb = true;
data.needsJsWarning = true; data.needsJsWarning = true;
data.isManagePreferences = true;
data.flashMessages = await captureFlashMessages(res); data.flashMessages = await captureFlashMessages(res);
res.send(htmlRenderer(data)); res.send(htmlRenderer(data));
@ -626,6 +625,11 @@ router.getAsync('/:cid/manual-unsubscribe-notice', async (req, res) => {
await webNotice('manual-unsubscribe', req, res); await webNotice('manual-unsubscribe', req, res);
}); });
router.getAsync('/:cid/privacy-policy', async (req, res) => {
await webNotice('privacy-policy', req, res);
});
router.postAsync('/publickey', passport.parseForm, async (req, res) => { router.postAsync('/publickey', passport.parseForm, async (req, res) => {
const configItems = await settings.get(contextHelpers.getAdminContext(), ['pgpPassphrase', 'pgpPrivateKey']); const configItems = await settings.get(contextHelpers.getAdminContext(), ['pgpPassphrase', 'pgpPrivateKey']);
@ -683,8 +687,6 @@ async function webNotice(type, req, res) {
const htmlRenderer = await tools.getTemplate(data.template); const htmlRenderer = await tools.getTemplate(data.template);
data.isWeb = true; data.isWeb = true;
data.isConfirmNotice = true; // FIXME: Not sure what this does. Check it in a browser with disabled JS
data.isManagePreferences = true;
data.flashMessages = await captureFlashMessages(res); data.flashMessages = await captureFlashMessages(res);
res.send(htmlRenderer(data)); res.send(htmlRenderer(data));

View file

@ -0,0 +1,58 @@
'use strict';
const config = require('config');
const log = require('../lib/log');
const knex = require('../lib/knex');
const subscriptions = require('../models/subscriptions');
const { SubscriptionStatus } = require('../../shared/lists');
const contextHelpers = require('../lib/context-helpers');
const checkPeriod = 60 * 1000;
async function run() {
while (true) {
await knex.transaction(async tx => {
const currentTs = Date.now();
const lsts = await tx('lists').select(['id']);
for (const list of lsts) {
if (config.gdpr.deleteSubscriptionAfterUnsubscribe.enabled) {
await tx(subscriptions.getSubscriptionTableName(list.id))
.whereIn('status', [SubscriptionStatus.UNSUBSCRIBED, SubscriptionStatus.COMPLAINED])
.where('unsubscribed', '<=', new Date(currentTs - config.gdpr.deleteSubscriptionAfterUnsubscribe.secondsAfterUnsubscribe * 1000))
.del();
}
if (config.gdpr.deleteDataAfterUnsubscribe.enabled) {
const groupedFieldsMap = await subscriptions.getGroupedFieldsMapTx(tx, list.id);
const purgedEntity = {};
subscriptions.purgeSensitiveData(purgedEntity, groupedFieldsMap);
await tx(subscriptions.getSubscriptionTableName(list.id))
.whereNotNull('email')
.whereIn('status', [SubscriptionStatus.UNSUBSCRIBED, SubscriptionStatus.COMPLAINED])
.where('unsubscribed', '<=', new Date(currentTs - config.gdpr.deleteDataAfterUnsubscribe.secondsAfterUnsubscribe * 1000))
.update(purgedEntity);
}
}
});
const nextCycle = new Promise(resolve => {
setTimeout(resolve, checkPeriod);
});
await nextCycle;
}
}
function start() {
if (config.gdpr.deleteDataAfterUnsubscribe.enabled || config.gdpr.deleteDataAfterUnsubscribe.enabled) {
log.info('GDPR', 'Starting GDPR cleanup service');
run().catch(err => {
log.error('GDPR', err);
});
}
}
module.exports.start = start;

View file

@ -11,8 +11,8 @@ const { SubscriptionStatus } = require('../../shared/lists');
const links = require('../models/links'); const links = require('../models/links');
const contextHelpers = require('../lib/context-helpers'); const contextHelpers = require('../lib/context-helpers');
const triggerCheckPeriod = 15 * 1000; const triggerCheckPeriod = 30 * 1000;
const triggerFirePeriod = 60 * 1000; const triggerFirePeriod = 120 * 1000;
async function run() { async function run() {

View file

@ -241,6 +241,7 @@ async function migrateSubscriptions(knex) {
const lists = await knex('lists'); const lists = await knex('lists');
for (const list of lists) { for (const list of lists) {
await knex.schema.raw('ALTER TABLE `subscription__' + list.id + '` ADD `unsubscribed` timestamp NULL DEFAULT NULL');
await knex.schema.raw('ALTER TABLE `subscription__' + list.id + '` ADD `source_email` int(10) unsigned DEFAULT NULL'); await knex.schema.raw('ALTER TABLE `subscription__' + list.id + '` ADD `source_email` int(10) unsigned DEFAULT NULL');
await knex.schema.raw('ALTER TABLE `subscription__' + list.id + '` ADD `hash_email` varchar(255) CHARACTER SET ascii'); await knex.schema.raw('ALTER TABLE `subscription__' + list.id + '` ADD `hash_email` varchar(255) CHARACTER SET ascii');
@ -286,6 +287,10 @@ async function migrateSubscriptions(knex) {
} }
await knex.schema.raw('ALTER TABLE `subscription__' + list.id + '` MODIFY `hash_email` varchar(255) CHARACTER SET ascii NOT NULL'); await knex.schema.raw('ALTER TABLE `subscription__' + list.id + '` MODIFY `hash_email` varchar(255) CHARACTER SET ascii NOT NULL');
await knex.schema.raw('ALTER TABLE `subscription__' + list.id + '` DROP KEY `email`');
await knex.schema.raw('ALTER TABLE `subscription__' + list.id + '` MODIFY `email` varchar(255) CHARACTER SET utf8 DEFAULT NULL');
await knex.schema.raw('ALTER TABLE `subscription__' + list.id + '` ADD UNIQUE KEY `hash_email` (`hash_email`)');
await knex.schema.raw('ALTER TABLE `subscription__' + list.id + '` ADD KEY `email` (`email`)');
await knex.schema.table('subscription__' + list.id, table => { await knex.schema.table('subscription__' + list.id, table => {
table.dropColumn('imported'); table.dropColumn('imported');
@ -1128,8 +1133,10 @@ async function migrateTriggers(knex) {
table.dropPrimary(); table.dropPrimary();
}); });
// For some reason, .first() from Knex didn't work. So add the column directly via SQL to be able to specify that it should be the first in the table.
await knex.schema.raw('ALTER TABLE `queued` ADD `id` int(10) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST');
await knex.schema.table('queued', table => { await knex.schema.table('queued', table => {
table.increments('id').first().primary();
table.renameColumn('subscriber', 'subscription'); table.renameColumn('subscriber', 'subscription');
table.renameColumn('source', 'trigger'); table.renameColumn('source', 'trigger');
}); });

View file

@ -1,11 +1,5 @@
{{{flashMessages}}} {{{flashMessages}}}
{{#if isConfirmNotice}}
<div class="alert alert-warning js-warning" role="alert">
<strong>Warning!</strong> If JavaScript was not enabled then no confirmation message was sent
</div>
{{/if}}
{{#if needsJsWarning}} {{#if needsJsWarning}}
<div class="alert alert-danger js-warning" role="alert"> <div class="alert alert-danger js-warning" role="alert">
<strong>Warning!</strong> <strong>Warning!</strong>

View file

@ -0,0 +1,13 @@
<mj-section>
<mj-column>
<mj-text mj-class="h3">
Privacy Policy
</mj-text>
<mj-text mj-class="p">
How we use your data ...
</mj-text>
<mj-button mj-class="button" href="{{homepage}}">
Return to our website
</mj-button>
</mj-column>
</mj-section>

View file

@ -28,7 +28,8 @@ const SubscriptionSource = {
SUBSCRIPTION_FORM: -2, SUBSCRIPTION_FORM: -2,
API: -3, API: -3,
NOT_IMPORTED_V1: -4, NOT_IMPORTED_V1: -4,
IMPORTED_V1: -5 IMPORTED_V1: -5,
ERASED: -6
}; };
function getFieldColumn(field) { function getFieldColumn(field) {