Configuration split to lists, send configurations and server config.

This is before testing.
This commit is contained in:
Tomas Bures 2018-04-22 17:33:43 +02:00
parent 4fce4b6f81
commit c12efeb97f
40 changed files with 819 additions and 311 deletions

View file

@ -50,8 +50,9 @@ const mosaicoTemplatesRest = require('./routes/rest/mosaico-templates');
const blacklistRest = require('./routes/rest/blacklist');
const editorsRest = require('./routes/rest/editors');
const filesRest = require('./routes/rest/files');
const settingsRest = require('./routes/rest/settings');
const root = require('./routes/root');
const index = require('./routes/index');
const interoperableErrors = require('./shared/interoperable-errors');
@ -259,7 +260,7 @@ function createApp(trusted) {
// Regular endpoints
useWith404Fallback('/subscription', subscription);
useWith404Fallback('/files', files);
useWith404Fallback('/mosaico', mosaico);
useWith404Fallback('/mosaico', mosaico.getRouter(trusted));
if (config.reports && config.reports.enabled === true) {
useWith404Fallback('/reports', reports);
@ -286,6 +287,7 @@ function createApp(trusted) {
app.use('/rest', blacklistRest);
app.use('/rest', editorsRest);
app.use('/rest', filesRest);
app.use('/rest', settingsRest);
if (config.reports && config.reports.enabled === true) {
app.use('/rest', reportTemplatesRest);
@ -293,7 +295,7 @@ function createApp(trusted) {
}
install404Fallback('/rest');
app.use('/', root);
app.use('/', index.getRouter(trusted));
// Error handlers
if (app.get('env') === 'development') {

View file

@ -7,6 +7,7 @@ import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'
import URL from 'url-parse';
import axios from '../lib/axios';
import { Button } from '../lib/bootstrap-components';
import { getUrl } from "../lib/urls";
@translate()
@withPageHelpers
@ -43,8 +44,6 @@ export default class API extends Component {
render() {
const t = this.props.t;
const thisUrl = new URL();
const serviceUrl = thisUrl.origin + '/'; // FIXME - use urls.js and getTrustedUrl
const accessToken = this.state.accessToken || 'ACCESS_TOKEN';
let accessTokenMsg;
@ -123,7 +122,7 @@ export default class API extends Component {
<strong>{t('Example')}</strong>
</p>
<pre>curl -XPOST {serviceUrl}api/subscribe/B16uVTdW?access_token={accessToken} \
<pre>curl -XPOST '{getUrl(`api/subscribe/B16uVTdW?access_token=${accessToken}`)}' \
--data 'EMAIL=test@example.com&amp;MERGE_CHECKBOX=yes&amp;REQUIRE_CONFIRMATION=yes'</pre>
<h3>POST /api/unsubscribe/:listId {t('Remove subscription')}</h3>
@ -150,7 +149,7 @@ export default class API extends Component {
<strong>{t('Example')}</strong>
</p>
<pre>curl -XPOST {serviceUrl}api/unsubscribe/B16uVTdW?access_token={accessToken} \
<pre>curl -XPOST '{getUrl(`api/unsubscribe/B16uVTdW?access_token=${accessToken}`)}' \
--data 'EMAIL=test@example.com'</pre>
<h3>POST /api/delete/:listId {t('Delete subscription')}</h3>
@ -177,7 +176,7 @@ export default class API extends Component {
<strong>{t('Example')}</strong>
</p>
<pre>curl -XPOST {serviceUrl}api/delete/B16uVTdW?access_token={accessToken} \
<pre>curl -XPOST '{getUrl(`api/delete/B16uVTdW?access_token=${accessToken}`)}' \
--data 'EMAIL=test@example.com'</pre>
<h3>POST /api/field/:listId {t('Add new custom field')}</h3>
@ -225,7 +224,7 @@ export default class API extends Component {
<strong>{t('Example')}</strong>
</p>
<pre>curl -XPOST {serviceUrl}api/field/B16uVTdW?access_token={accessToken} \
<pre>curl -XPOST '{getUrl(`api/field/B16uVTdW?access_token=${accessToken}`)}' \
--data 'NAME=Birthday&amp;TYPE=birthday-us&amp;VISIBLE=yes'</pre>
<h3>GET /api/blacklist/get {t('Get list of blacklisted emails')}</h3>
@ -251,7 +250,7 @@ export default class API extends Component {
<strong>{t('Example')}</strong>
</p>
<pre>curl -XGET '{serviceUrl}api/blacklist/get?access_token={accessToken}&limit=10&start=10&search=gmail' </pre>
<pre>curl -XGET '{getUrl(`api/blacklist/get?access_token=${accessToken}&limit=10&start=10&search=gmail`)}' </pre>
<h3>POST /api/blacklist/add {t('Add email to blacklist')}</h3>
@ -277,7 +276,7 @@ export default class API extends Component {
<strong>{t('Example')}</strong>
</p>
<pre>curl -XPOST '{serviceUrl}api/blacklist/add?access_token={accessToken}' \
<pre>curl -XPOST '{getUrl(`api/blacklist/add?access_token={accessToken}`)}' \
--data 'EMAIL=test@example.com&amp;'</pre>
<h3>POST /api/blacklist/delete {t('Delete email from blacklist')}</h3>
@ -304,7 +303,7 @@ export default class API extends Component {
<strong>{t('Example')}</strong>
</p>
<pre>curl -XPOST '{serviceUrl}api/blacklist/delete?access_token={accessToken}' \
<pre>curl -XPOST '{getUrl(`api/blacklist/delete?access_token=${accessToken}`)}' \
--data 'EMAIL=test@example.com&amp;'</pre>
<h3>GET /api/lists/:email {t('Get the lists a user has subscribed to')}</h3>
@ -324,7 +323,7 @@ export default class API extends Component {
<strong>{t('Example')}</strong>
</p>
<pre>curl -XGET '{{serviceUrl}}api/lists/test@example.com?access_token={{accessToken}} </pre>
<pre>curl -XGET '{getUrl(`api/lists/test@example.com?access_token=${accessToken}`)}'</pre>
</div>
);
}

View file

@ -11,6 +11,7 @@ import { withErrorHandling } from '../lib/error-handling';
import qs from 'querystringify';
import interoperableErrors from '../../../shared/interoperable-errors';
import mailtrainConfig from 'mailtrainConfig';
import {getUrl} from "../lib/urls";
@translate()
@withForm
@ -61,7 +62,7 @@ export default class Login extends Component {
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/rest/login');
if (submitSuccessful) {
const nextUrl = qs.parse(this.props.location.search).next || mailtrainConfig.urlBase;
const nextUrl = qs.parse(this.props.location.search).next || getUrl();
/* This ensures we get config for the authenticated user */
window.location = nextUrl;

View file

@ -1,11 +1,6 @@
'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
import { Section } from '../lib/page';
import Account from './Account';
import Login from './Login';
import Reset from './Forgot';

View file

@ -1,11 +1,6 @@
'use strict';
import React from "react";
import ReactDOM from "react-dom";
import {I18nextProvider} from "react-i18next";
import i18n from "../lib/i18n";
import {Section} from "../lib/page";
import List from "./List";
function getMenus(t) {

View file

@ -11,6 +11,7 @@ import {Button, DismissibleAlert} from "./bootstrap-components";
import mailtrainConfig from "mailtrainConfig";
import styles from "./styles.scss";
import {getRoutes, needsResolve, resolve, withPageHelpers} from "./page-common";
import {getBaseDir} from "./urls";
class Breadcrumb extends Component {
static propTypes = {
@ -382,7 +383,7 @@ class Section extends Component {
render() {
return (
<Router basename={mailtrainConfig.urlBase}>
<Router basename={getBaseDir()}>
<SectionContent root={this.props.root} structure={this.structure} />
</Router>
);

View file

@ -63,7 +63,7 @@ export class UntrustedContentHost extends Component {
sendMessage(type, data) {
if (this.contentNodeIsLoaded) { // This is to avoid errors "common.js:45744 Failed to execute 'postMessage' on 'DOMWindow': The target origin provided ('http://localhost:8081') does not match the recipient window's origin ('http://localhost:3000')"
this.contentNode.contentWindow.postMessage({type, data}, getSandboxUrl(''));
this.contentNode.contentWindow.postMessage({type, data}, getSandboxUrl());
}
}
@ -197,7 +197,7 @@ export class UntrustedContentRoot extends Component {
}
sendMessage(type, data) {
window.parent.postMessage({type, data}, getTrustedUrl(''));
window.parent.postMessage({type, data}, getTrustedUrl());
}
componentDidMount() {

View file

@ -2,36 +2,33 @@
import mailtrainConfig from "mailtrainConfig";
let urlBase;
let sandboxUrlBase;
if (mailtrainConfig.urlBase.startsWith('/')) {
urlBase = window.location.protocol + '//' + window.location.hostname + ':' + mailtrainConfig.port + mailtrainConfig.urlBase;
} else {
urlBase = mailtrainConfig.urlBase
}
if (mailtrainConfig.sandboxUrlBase) {
if (mailtrainConfig.urlBase.startsWith('/')) {
sandboxUrlBase = window.location.protocol + '//' + window.location.hostname + ':' + mailtrainConfig.sandboxPort + mailtrainConfig.sandboxUrlBase;
} else {
sandboxUrlBase = mailtrainConfig.sandboxUrlBase
}
} else {
const loc = document.createElement("a");
loc.href = urlBase;
sandboxUrlBase = loc.protocol + '//' + loc.hostname + ':' + mailtrainConfig.sandboxPort + loc.pathname;
}
function getTrustedUrl(path) {
return urlBase + path;
return mailtrainConfig.trustedUrlBase + (path || '');
}
function getSandboxUrl(path) {
return sandboxUrlBase + path;
return mailtrainConfig.sandboxUrlBase + (path || '');
}
function getUrl(path) {
if (mailtrainConfig.trusted) {
return getTrustedUrl(path);
} else {
return getSandboxUrl(path);
}
}
function getBaseDir() {
if (mailtrainConfig.trusted) {
return mailtrainConfig.trustedUrlBaseDir;
} else {
return mailtrainConfig.sandboxUrlBaseDir;
}
}
export {
getTrustedUrl,
getSandboxUrl
getSandboxUrl,
getUrl,
getBaseDir
}

View file

@ -46,6 +46,8 @@ export default class CUD extends Component {
form: 'default',
default_form: null,
public_subscribe: true,
contact_email: '',
homepage: '',
unsubscription_mode: UnsubscriptionMode.ONE_STEP,
namespace: mailtrainConfig.user.namespace
});
@ -170,7 +172,10 @@ export default class CUD extends Component {
</StaticField>
}
<TextArea id="description" label={t('Description')} help={t('HTML is allowed')}/>
<TextArea id="description" label={t('Description')}/>
<InputField id="contact_email" label={t('Contact email')} help={t('Contact email used in subscription forms and emails that are sent out. If not filled in, the admin email from the global settings will be used.')}/>
<InputField id="homepage" label={t('Homepage')} help={t('Homepage URL used in subscription forms and emails that are sent out. If not filled in, the default homepage from global settings will be used.')}/>
<NamespaceSelect/>

View file

@ -386,7 +386,7 @@ export default class CUD extends Component {
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')} help={t('HTML is allowed')}/>
<TextArea id="description" label={t('Description')}/>
<NamespaceSelect/>

View file

@ -10,6 +10,7 @@ import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'
import interoperableErrors from '../../../shared/interoperable-errors';
import {DeleteModalDialog} from "../lib/modals";
import mailtrainConfig from 'mailtrainConfig';
import {getGlobalNamespaceId} from "../../../shared/namespaces";
@translate()
@withForm
@ -32,7 +33,7 @@ export default class CUD extends Component {
}
isEditGlobal() {
return this.props.entity && this.props.entity.id === 1; /* Global namespace id */
return this.props.entity && this.props.entity.id === getGlobalNamespaceId();
}
removeNsIdSubtree(data) {

View file

@ -229,7 +229,7 @@ export default class CUD extends Component {
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')} help={t('HTML is allowed')}/>
<TextArea id="description" label={t('Description')}/>
<TableSelect id="report_template" label={t('Report Template')} withHeader dropdown dataUrl="/rest/report-templates-table" columns={reportTemplateColumns} selectionLabelIndex={1}/>

View file

@ -293,7 +293,7 @@ export default class CUD extends Component {
<Form stateOwner={this} onSubmitAsync={::this.submitAndLeave}>
<InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')} help={t('HTML is allowed')}/>
<TextArea id="description" label={t('Description')}/>
<Dropdown id="mime_type" label={t('Type')} options={[{key: 'text/html', label: t('HTML')}, {key: 'text/csv', label: t('CSV')}]}/>
<NamespaceSelect/>
<ACEEditor id="user_fields" height="250px" mode="json" label={t('User selectable fields')} help={t('JSON specification of user selectable fields.')}/>

View file

@ -16,6 +16,7 @@ import reports from './reports/root';
import templates from './templates/root';
import users from './users/root';
import sendConfigurations from './send-configurations/root';
import settings from './settings/root';
import {
MenuLink,
@ -31,6 +32,7 @@ import {
} from "./lib/bootstrap-components";
import {Link} from "react-router-dom";
import axios from './lib/axios';
import {getUrl} from "./lib/urls";
@translate()
@ -79,7 +81,7 @@ class Root extends Component {
<DropdownMenuItem label={t('Administration')}>
<MenuLink to="/users"><Icon icon='cog'/> {t('Users')}</MenuLink>
<MenuLink to="/namespaces"><Icon icon='cog'/> {t('Namespaces')}</MenuLink>
<MenuLink to="/settings"><Icon icon='cog'/> {t('Settings')}</MenuLink>
<MenuLink to="/settings"><Icon icon='cog'/> {t('Global Settings')}</MenuLink>
<MenuLink to="/send-configurations"><Icon icon='cog'/> {t('Send Configurations')}</MenuLink>
<MenuLink to="/blacklist"><Icon icon='ban-circle'/> {t('Blacklist')}</MenuLink>
<MenuLink to="/account/api"><Icon icon='retweet'/> {t('API')}</MenuLink>
@ -118,6 +120,7 @@ class Root extends Component {
...users.getMenus(t),
...blacklist.getMenus(t),
...account.getMenus(t),
...settings.getMenus(t),
...sendConfigurations.getMenus(t)
}
}
@ -126,7 +129,7 @@ class Root extends Component {
async logout() {
await axios.post('/rest/logout');
window.location = mailtrainConfig.urlBase;
window.location = getUrl();
}
render() {

View file

@ -1,28 +1,44 @@
'use strict';
import React, { Component } from 'react';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import { translate, Trans } from 'react-i18next';
import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../lib/page'
import {
withForm,
Trans,
translate
} from 'react-i18next';
import {
NavButton,
requiresAuthenticatedUser,
Title,
withPageHelpers
} from '../lib/page'
import {
Button,
ButtonRow,
CheckBox,
Fieldset,
Form,
FormSendMethod,
InputField,
TextArea,
Dropdown,
ButtonRow,
Button,
CheckBox,
Fieldset
withForm
} from '../lib/form';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import { validateNamespace, NamespaceSelect } from '../lib/namespace';
import {withErrorHandling} from '../lib/error-handling';
import {
NamespaceSelect,
validateNamespace
} from '../lib/namespace';
import {DeleteModalDialog} from "../lib/modals";
import { getMailerTypes, mailerTypesOrder } from "./helpers";
import {getMailerTypes} from "./helpers";
import {
getSystemSendConfigurationId,
MailerType
} from "../../../shared/send-configurations";
import mailtrainConfig from 'mailtrainConfig';
import {MailerType} from "../../../shared/send-configurations";
@translate()
@withForm
@ -35,14 +51,6 @@ export default class CUD extends Component {
this.mailerTypes = getMailerTypes(props.t);
this.typeOptions = [];
for (const type of mailerTypesOrder) {
this.typeOptions.push({
key: type,
label: this.mailerTypes[type].typeName
});
}
this.state = {};
this.initForm({
@ -68,7 +76,8 @@ export default class CUD extends Component {
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, data => {
this.mailerTypes[data.type].afterLoad(data);
this.mailerTypes[data.mailer_type].afterLoad(data);
data.verpEnabled = !!data.verp_hostname;
});
} else {
@ -79,6 +88,8 @@ export default class CUD extends Component {
from_email_overridable: false,
from_name_overridable: false,
subject_overridable: false,
verpEnabled: false,
verp_hostname: '',
mailer_type: MailerType.ZONE_MTA,
...this.mailerTypes[MailerType.ZONE_MTA].initData()
});
@ -100,6 +111,13 @@ export default class CUD extends Component {
state.setIn(['mailer_type', 'error'], null);
}
if (state.getIn(['verpEnabled', 'value']) && !state.getIn(['verp_hostname', 'value'])) {
state.setIn(['verp_hostname', 'error'], t('VERP hostname must not be empty'));
} else {
state.setIn(['verp_hostname', 'error'], null);
}
validateNamespace(t, state);
}
@ -119,7 +137,10 @@ export default class CUD extends Component {
this.setFormStatusMessage('info', t('Saving ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
this.mailerTypes[data.type].beforeSave(data);
this.mailerTypes[data.mailer_type].beforeSave(data);
if (!data.verpEnabled) {
data.verp_hostname = null;
}
});
if (submitSuccessful) {
@ -133,14 +154,17 @@ export default class CUD extends Component {
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const canDelete = isEdit && this.props.entity.permissions.includes('delete');
const canDelete = isEdit && this.props.entity.permissions.includes('delete') && this.props.entity.id !== getSystemSendConfigurationId();
const typeKey = this.getFormValue('mailer_type');
console.log(typeKey);
let mailerForm = null;
if (typeKey) {
mailerForm = this.mailerTypes[typeKey].getForm(this);
}
const verpEnabled = this.getFormValue('verpEnabled');
return (
<div>
{canDelete &&
@ -159,7 +183,7 @@ export default class CUD extends Component {
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')} help={t('HTML is allowed')}/>
<TextArea id="description" label={t('Description')}/>
<NamespaceSelect/>
<Fieldset label={t('Email Header')}>
@ -171,9 +195,27 @@ export default class CUD extends Component {
<CheckBox id="subject_overridable" text={t('Overridable')}/>
</Fieldset>
<Fieldset label={t('Mailer Settings')}>
<Dropdown id="type" label={t('Type')} options={this.typeOptions}/>
{mailerForm}
{mailerForm}
{/* TODO - add "Check mail config" button */}
<Fieldset label={t('VERP Bounce Handling')}>
<Trans><p>Mailtrain is able to use VERP based routing to detect bounces. In this case the message is sent to the recipient using a custom VERP address as the return path of the message. If the message is not accepted a bounce email is sent to this special VERP address and thus a bounce is detected.</p></Trans>
<Trans><p>To get VERP working you need to set up a DNS MX record that points to your Mailtrain hostname. You must also ensure that Mailtrain VERP interface is available from port 25 of your server (port 25 usually requires root user privileges). This way if anyone tries to send email to someuser@verp-hostname then the email should end up to this server.</p></Trans>
<Trans><p className="text-warning">VERP usually only works if you are using your own SMTP server. Regular relay services (SES, SparkPost, Gmail etc.) tend to remove the VERP address from the message.</p></Trans>
{mailtrainConfig.verpEnabled ?
<div>
<CheckBox id="verpEnabled" text={t('verpEnabled')}/>
{verpEnabled && <InputField id="verp_hostname" label={t('Server hostname')} placeholder={t('The VERP server hostname, eg. bounces.example.com')} help={t('VERP bounce handling server hostname. This hostname is used in the SMTP envelope FROM address and the MX DNS records should point to this server')}/>}
</div>
:
<Trans><p>VERP bounce handling server is not enabled. Modify your server configuration file and restart server to enable it.</p></Trans>
}
<InputField id="from_email" label={t('Default "from" email')}/>
<CheckBox id="from_email_overridable" text={t('Overridable')}/>
<InputField id="from_name" label={t('Default "from" name')}/>
<CheckBox id="from_name_overridable" text={t('Overridable')}/>
<InputField id="subject" label={t('Subject')}/>
<CheckBox id="subject_overridable" text={t('Overridable')}/>
</Fieldset>
<ButtonRow>
@ -186,16 +228,4 @@ export default class CUD extends Component {
</div>
);
}
/*
<Fieldset label={t('GPG Signing')}>
<Trans><p>Only messages that are encrypted can be signed. Subsribers who have not set up a GPG public key in their profile receive normal email messages. Users with GPG key set receive encrypted messages and if you have signing key also set, the messages are signed with this key.</p></Trans>
<Trans><p className="text-warning">Do not use sensitive keys here. The private key and passphrase are not encrypted in the database.</p></Trans>
<InputField id="gpg_signing_key_passphrase" label={t('Private key passphrase')} placeholder={t('Passphrase for the key if set')} help={t('Only fill this if your private key is encrypted with a passphrase')}/>
<TextArea id="gpg_signing_key" label={t('GPG private key')} placeholder={t('Begins with \'-----BEGIN PGP PRIVATE KEY BLOCK-----\'')} help={t('This value is optional. If you do not provide a private key GPG encrypted messages are sent without signing.')}/>
</Fieldset>
*/
}

View file

@ -3,6 +3,14 @@
import React from "react";
import {MailerType} from "../../../shared/send-configurations";
import {
CheckBox,
Dropdown,
Fieldset,
InputField,
TextArea
} from "../lib/form";
import {Trans} from "react-i18next";
export const mailerTypesOrder = [
MailerType.ZONE_MTA,
@ -10,52 +18,239 @@ export const mailerTypesOrder = [
MailerType.AWS_SES
];
function getInitCommon() {
return {
maxConnections: '5',
throttling: '',
logTransactions: false
};
}
function getInitGenericSMTP() {
return {
...getInitCommon(),
smtpHostname: '',
smtpPort: '',
smtpEncryption: 'NONE',
smtpUseAuth: false,
smtpUser: '',
smtpPassword: '',
smtpAllowSelfSigned: false,
smtpMaxMessages: '100'
};
}
function afterLoadCommon(data) {
data.maxConnections = data.mailer_settings.maxConnections;
data.throttling = data.mailer_settings.throttling || '';
data.logTransaction = data.mailer_settings.logTransactions;
}
function afterLoadGenericSMTP(data) {
afterLoadCommon(data);
data.smtpHostname = data.mailer_settings.hostname;
data.smtpPort = data.mailer_settings.port || '';
data.smtpEncryption = data.mailer_settings.encryption;
data.smtpUseAuth = data.mailer_settings.useAuth;
data.smtpUser = data.mailer_settings.user;
data.smtpPassword = data.mailer_settings.password;
data.smtpAllowSelfSigned = data.mailer_settings.allowSelfSigned;
data.smtpMaxMessages = data.mailer_settings.maxMessages;
}
function beforeSaveCommon(data) {
data.mailer_settings = {};
data.mailer_settings.maxConnections = Number(data.maxConnections);
data.mailer_settings.throttling = Number(data.throttling);
data.mailer_settings.logTransactions = data.logTransaction;
}
function beforeSaveGenericSMTP(data) {
beforeSaveCommon(data);
data.mailer_settings.hostname = data.smtpHostname;
data.mailer_settings.port = Number(data.smtpPort);
data.mailer_settings.encryption = data.smtpEncryption;
data.mailer_settings.useAuth = data.smtpUseAuth;
data.mailer_settings.user = data.smtpUser;
data.mailer_settings.password = data.smtpPassword;
data.mailer_settings.allowSelfSigned = data.smtpAllowSelfSigned;
data.mailer_settings.maxMessages = Number(data.smtpMaxMessages);
}
export function getMailerTypes(t) {
const mailerTypes = {};
function clearBeforeSend(data) {
function initFieldsIfMissing(mutState, mailerType) {
const initVals = mailerTypes[mailerType].initData();
for (const key in initVals) {
if (!mutState.hasIn([key])) {
mutState.setIn([key, 'value'], initVals[key]);
}
}
}
function clearBeforeSave(data) {
for (const mailerKey in mailerTypes) {
const initVals = mailerTypes[mailerKey].initData();
for (const fieldKey in initVals) {
delete data[fieldKey];
}
}
}
const typeOptions = [
{ key: MailerType.GENERIC_SMTP, label: t('Generic SMTP')},
{ key: MailerType.ZONE_MTA, label: t('Zone MTA')},
{ key: MailerType.AWS_SES, label: t('Amazon SES')}
];
const smtpEncryptionOptions = [
{ key: 'NONE', label: t('Do not use encryption')},
{ key: 'TLS', label: t('Use TLS usually selected for port 465')},
{ key: 'STARTTLS', label: t('Use STARTTLS usually selected for port 587 and 25')}
];
const sesRegionOptions = [
{ key: 'us-east-1', label: t('US-EAST-1')},
{ key: 'us-west-2', label: t('US-WEST-2')},
{ key: 'eu-west-1', label: t('EU-WEST-1')}
];
mailerTypes[MailerType.GENERIC_SMTP] = {
typeName: t('Generic SMTP'),
getForm: owner => null,
getForm: owner =>
<div>
<Fieldset label={t('Mailer Settings')}>
<Dropdown id="mailer_type" label={t('Mailer type')} options={typeOptions}/>
<InputField id="smtpHostname" label={t('Hostname')} placeholder={t('Hostname, eg. smtp.example.com')}/>
<InputField id="smtpPort" label={t('Port')} placeholder={t('Port, eg. 465. Autodetected if left blank')}/>
<Dropdown id="smtpEncryption" label={t('Encryption')} options={smtpEncryptionOptions}/>
<CheckBox id="smtpUseAuth" text={t('Enable SMTP authentication')}/>
{ owner.getFormValue('smtpUseAuth') &&
<div>
<InputField id="smtpUser" label={t('Username')} placeholder={t('Username, eg. myaccount@example.com')}/>
<InputField id="smtpPassword" label={t('Password')} placeholder={t('Username, eg. myaccount@example.com')}/>
</div>
}
</Fieldset>
<Fieldset label={t('Advanced Mailer Settings')}>
<CheckBox id="logTransactions" text={t('Log SMTP transactions')}/>
<CheckBox id="smtpAllowSelfSigned" text={t('Allow self-signed certificates')}/>
<InputField id="maxConnections" label={t('Max connections')} placeholder={t('The count of max connections, eg. 10')} help={t('The count of maximum simultaneous connections to make against the SMTP server (defaults to 5). This limit is per sending process.')}/>
<InputField id="smtpMaxMessages" label={t('Max messages')} placeholder={t('The count of max messages, eg. 100')} help={t('The number of messages to send through a single connection before the connection is closed and reopened (defaults to 100)')}/>
<InputField id="throttling" label={t('Throttling')} placeholder={t('Messages per hour eg. 1000')} help={t('Maximum number of messages to send in an hour. Leave empty or zero for no throttling. If your provider uses a different speed limit (messages/minute or messages/second) then convert this limit into messages/hour (1m/s => 3600m/h). This limit is per sending process.')}/>
</Fieldset>
</div>,
initData: () => ({
...getInitGenericSMTP()
}),
afterLoad: data => {
afterLoadGenericSMTP(data);
},
beforeSave: data => {
clearBeforeSend(data);
beforeSaveGenericSMTP(data);
clearBeforeSave(data);
},
afterTypeChange: mutState => {
// mutState.setIn(['type', 'value'], '');
initFieldsIfMissing(mutState, MailerType.GENERIC_SMTP);
}
};
mailerTypes[MailerType.ZONE_MTA] = {
typeName: t('Zone MTA'),
getForm: owner => null,
getForm: owner =>
<div>
<Fieldset label={t('Mailer Settings')}>
<Dropdown id="mailer_type" label={t('Mailer type')} options={typeOptions}/>
<InputField id="smtpHostname" label={t('Hostname')} placeholder={t('Hostname, eg. smtp.example.com')}/>
<InputField id="smtpPort" label={t('Port')} placeholder={t('Port, eg. 465. Autodetected if left blank')}/>
<Dropdown id="smtpEncryption" label={t('Encryption')} options={smtpEncryptionOptions}/>
<CheckBox id="smtpUseAuth" text={t('Enable SMTP authentication')}/>
{ owner.getFormValue('smtpUseAuth') &&
<div>
<InputField id="smtpUser" label={t('Username')} placeholder={t('Username, eg. myaccount@example.com')}/>
<InputField id="smtpPassword" label={t('Password')} placeholder={t('Username, eg. myaccount@example.com')}/>
</div>
}
</Fieldset>
<Fieldset label={t('DKIM Signing')}>
<Trans><p>If you are using ZoneMTA then Mailtrain can provide a DKIM key for signing all outgoing messages. Other services usually provide their own means to DKIM sign your messages.</p></Trans>
<Trans><p className="text-warning">Do not use sensitive keys here. The private key is not encrypted in the database.</p></Trans>
<InputField id="dkimApiKey" label={t('ZoneMTA DKIM API key')} help={t('Secret value known to ZoneMTA for requesting DKIM key information. If this value was generated by the Mailtrain installation script then you can keep it as it is.')}/>
<InputField id="dkimDomain" label={t('DKIM domain')} help={t('Leave blank to use the sender email address domain.')}/>
<InputField id="dkimSelector" label={t('DKIM key selector')} help={t('Signing is disabled without a valid selector value.')}/>
<TextArea id="dkimPrivateKey" label={t('DKIM private key')} placeholder={t('Begins with "-----BEGIN RSA PRIVATE KEY-----"')} help={t('Signing is disabled without a valid private key.')}/>
</Fieldset>
<Fieldset label={t('Advanced Mailer Settings')}>
<CheckBox id="logTransactions" text={t('Log SMTP transactions')}/>
<CheckBox id="smtpAllowSelfSigned" text={t('Allow self-signed certificates')}/>
<InputField id="maxConnections" label={t('Max connections')} placeholder={t('The count of max connections, eg. 10')} help={t('The count of maximum simultaneous connections to make against the SMTP server (defaults to 5). This limit is per sending process.')}/>
<InputField id="smtpMaxMessages" label={t('Max messages')} placeholder={t('The count of max messages, eg. 100')} help={t('The number of messages to send through a single connection before the connection is closed and reopened (defaults to 100)')}/>
<InputField id="throttling" label={t('Throttling')} placeholder={t('Messages per hour eg. 1000')} help={t('Maximum number of messages to send in an hour. Leave empty or zero for no throttling. If your provider uses a different speed limit (messages/minute or messages/second) then convert this limit into messages/hour (1m/s => 3600m/h). This limit is per sending process.')}/>
</Fieldset>
</div>,
initData: () => ({
...getInitGenericSMTP(),
dkimApiKey: '',
dkimDomain: '',
dkimSelector: '',
dkimPrivateKey: ''
}),
afterLoad: data => {
afterLoadGenericSMTP(data);
data.dkimApiKey = data.mailer_settings.dkimApiKey;
data.dkimDomain = data.mailer_settings.dkimDomain;
data.dkimSelector = data.mailer_settings.dkimSelector;
data.dkimPrivateKey = data.mailer_settings.dkimPrivateKey;
},
beforeSave: data => {
clearBeforeSend(data);
beforeSaveGenericSMTP(data);
data.mailer_settings.dkimApiKey = data.dkimApiKey;
data.mailer_settings.dkimDomain = data.dkimDomain;
data.mailer_settings.dkimSelector = data.dkimSelector;
data.mailer_settings.dkimPrivateKey = data.dkimPrivateKey;
clearBeforeSave(data);
},
afterTypeChange: mutState => {
initFieldsIfMissing(mutState, MailerType.ZONE_MTA);
}
};
mailerTypes[MailerType.AWS_SES] = {
typeName: t('Amazon SES'),
getForm: owner => null,
getForm: owner =>
<div>
<Fieldset label={t('Mailer Settings')}>
<Dropdown id="mailer_type" label={t('Mailer type')} options={typeOptions}/>
<InputField id="sesKey" label={t('Access key')} placeholder={t('AWS access key ID')}/>
<InputField id="sesSecret" label={t('Port')} placeholder={t('AWS secret access key')}/>
<Dropdown id="sesRegion" label={t('Region')} options={sesRegionOptions}/>
</Fieldset>
<Fieldset label={t('Advanced Mailer Settings')}>
<CheckBox id="logTransactions" text={t('Log SMTP transactions')}/>
<InputField id="maxConnections" label={t('Max connections')} placeholder={t('The count of max connections, eg. 10')} help={t('The count of maximum simultaneous connections to make against the SMTP server (defaults to 5). This limit is per sending process.')}/>
<InputField id="throttling" label={t('Throttling')} placeholder={t('Messages per hour eg. 1000')} help={t('Maximum number of messages to send in an hour. Leave empty or zero for no throttling. If your provider uses a different speed limit (messages/minute or messages/second) then convert this limit into messages/hour (1m/s => 3600m/h). This limit is per sending process.')}/>
</Fieldset>
</div>,
initData: () => ({
...getInitCommon(),
sesKey: '',
sesSecret: '',
sesRegion: ''
}),
afterLoad: data => {
afterLoadCommon(data);
data.sesKey = data.mailer_settings.key;
data.sesSecret = data.mailer_settings.secret;
data.sesRegion = data.mailer_settings.region;
},
beforeSave: data => {
clearBeforeSend(data);
beforeSaveCommon(data);
data.mailer_settings.key = data.sesKey;
data.mailer_settings.secret = data.sesSecret;
data.mailer_settings.region = data.sesRegion;
clearBeforeSave(data);
},
afterTypeChange: mutState => {
initFieldsIfMissing(mutState, MailerType.AWS_SES);
}
};

View file

@ -0,0 +1,99 @@
'use strict';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { translate, Trans } from 'react-i18next';
import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../lib/page';
import {
withForm,
Form,
FormSendMethod,
InputField,
TextArea,
TableSelect,
ButtonRow,
Button,
Dropdown,
StaticField,
CheckBox,
Fieldset
} from '../lib/form';
import { withErrorHandling } from '../lib/error-handling';
import { DeleteModalDialog } from '../lib/modals';
import { validateNamespace, NamespaceSelect } from '../lib/namespace';
import { UnsubscriptionMode } from '../../../shared/lists';
import styles from "../lib/styles.scss";
import mailtrainConfig from 'mailtrainConfig';
@translate()
@withForm
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class Update extends Component {
constructor(props) {
super(props);
this.state = {};
this.initForm();
}
static propTypes = {
entity: PropTypes.object
}
componentDidMount() {
this.getFormValuesFromEntity(this.props.entity);
}
localValidateFormValues(state) {
const t = this.props.t;
}
async submitHandler() {
const t = this.props.t;
this.disableForm();
this.setFormStatusMessage('info', t('Saving ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.PUT, '/rest/settings');
if (submitSuccessful) {
this.navigateToWithFlashMessage('/settings', 'success', t('Global settings saved'));
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
}
}
render() {
const t = this.props.t;
return (
<div>
<Title>{t('Global Settings')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="adminEmail" label={t('Admin email')} help={t('This email is used as the main contact and as a default email address if no email address is specified in list settings.')}/>
<InputField id="defaultHomepage" label={t('Default homepage URL')} help={t('This URL will be used in list subscription forms if no homepage is specified in list settings.')}/>
<InputField id="uaCode" label={t('Tracking ID')} placeholder={t('UA-XXXXX-XX')} help={t('Enter Google Analytics tracking code')}/>
<TextArea id="shoutout" label={t('Frontpage shout out')} help={t('HTML code shown in the front page header section')}/>
<Fieldset label={t('GPG Signing')}>
<Trans><p>Only messages that are encrypted can be signed. Subsribers who have not set up a GPG public key in their profile receive normal email messages. Users with GPG key set receive encrypted messages and if you have signing key also set, the messages are signed with this key.</p></Trans>
<Trans><p className="text-warning">Do not use sensitive keys here. The private key and passphrase are not encrypted in the database.</p></Trans>
<InputField id="pgpPassphrase" label={t('Private key passphrase')} placeholder={t('Passphrase for the key if set')} help={t('Only fill this if your private key is encrypted with a passphrase')}/>
<TextArea id="pgpPrivateKey" label={t('GPG private key')} placeholder={t('Begins with \'-----BEGIN PGP PRIVATE KEY BLOCK-----\'')} help={t('This value is optional. If you do not provide a private key GPG encrypted messages are sent without signing.')}/>
</Fieldset>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -0,0 +1,21 @@
'use strict';
import React from "react";
import Update from "./Update";
function getMenus(t) {
return {
'settings': {
title: t('Global Settings'),
link: '/settings',
resolve: {
configItems: params => `/rest/settings`
},
panelRender: props => <Update entity={props.resolved.configItems} />
}
};
}
export default {
getMenus
}

View file

@ -281,7 +281,7 @@ export default class CUD extends Component {
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')} help={t('HTML is allowed')}/>
<TextArea id="description" label={t('Description')}/>
{isEdit
?

View file

@ -166,7 +166,7 @@ export default class CUD extends Component {
<Form stateOwner={this} onSubmitAsync={::this.submitAndLeave}>
<InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')} help={t('HTML is allowed')}/>
<TextArea id="description" label={t('Description')}/>
<Dropdown id="type" label={t('Type')} options={this.typeOptions}/>
<NamespaceSelect/>

View file

@ -52,13 +52,11 @@ port=3000
sandboxPort=8081
# HTTP interface to listen on
host="0.0.0.0"
# URL Base (must end with slash). It can be either relative (starting with slash) or absolute (starting with http:// or https://).
# If it is relative, an absolute URL will be constructed automatically based on the domain where it is served and the "port" config parameter
urlBase="/"
# URL Base for sandbox urls (must end with slash). It can be either relative (starting with slash) or absolute (starting with http:// or https://)
# If it is relative, an absolute URL will be constructed automatically based on the domain where it is served and the "port" config parameter
# If not given at all, it is automatically constructed based on urlBase and sandboxPort.
# sandboxUrlBase="/"
# URL base for trusted urls. It must be absolute (starting with http:// or https://). If Mailtrain is served on
# a non-standard port (e.g. 3000), the URL must also specify the port.
trustedUrlBase="http://localhost:3000"
# URL base for sandbox urls. It must be absolute (starting with http:// or https://) and contain the sandbox port.
sandboxUrlBase="http://localhost:8081"
# Secret for signing the session ID cookie
secret="a cat"

View file

@ -5,18 +5,20 @@ const config = require('config');
const permissions = require('./permissions');
const forms = require('../models/forms');
const shares = require('../models/shares');
const urls = require('./urls');
async function getAnonymousConfig(context) {
async function getAnonymousConfig(context, trusted) {
return {
authMethod: passport.authMethod,
isAuthMethodLocal: passport.isAuthMethodLocal,
externalPasswordResetLink: config.ldap.passwordresetlink,
language: config.language || 'en',
isAuthenticated: !!context.user,
urlBase: config.www.urlBase,
sandboxUrlBase: config.www.sandboxUrlBase,
port: config.www.port,
sandboxPort: config.www.sandboxPort
trustedUrlBase: urls.getTrustedUrl(),
trustedUrlBaseDir: urls.getTrustedUrlBaseDir(),
sandboxUrlBase: urls.getSandboxUrl(),
sandboxUrlBaseDir: urls.getSandboxUrlBaseDir(),
trusted
}
}
@ -29,7 +31,8 @@ async function getAuthenticatedConfig(context) {
namespace: context.user.namespace
},
globalPermissions: shares.getGlobalPermissions(context),
editors: config.editors
editors: config.editors,
verpEnabled: config.verp.enabled
}
}

View file

@ -171,6 +171,15 @@ function getTemplate(template, callback) {
}
function createMailer(callback) {
// FIXME
module.exports.transport = {
on: () => {},
isIdle: () => true,
checkThrottling: next => next()
};
return callback(null, module.exports.transport);
getSettings(['smtpHostname', 'smtpPort', 'smtpEncryption', 'smtpUser', 'smtpPass', 'smtpLog', 'smtpDisableAuth', 'smtpMaxConnections', 'smtpMaxMessages', 'smtpSelfSigned', 'pgpPrivateKey', 'pgpPassphrase', 'smtpThrottling', 'mailTransport', 'sesKey', 'sesSecret', 'sesRegion'], (err, configItems) => {
if (err) {
return callback(err);

View file

@ -29,55 +29,43 @@ async function sendSubscriptionConfirmed(list, email, subscription) {
unsubscribeUrl: '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid
};
await _sendMail(list, email, 'subscription_confirmed', _('%s: Subscription Confirmed'), relativeUrls, {}, subscription);
await _sendMail(list, email, 'subscription_confirmed', _('%s: Subscription Confirmed'), relativeUrls, subscription);
}
async function sendAlreadySubscribed(list, email, subscription) {
const mailOpts = {
ignoreDisableConfirmations: true
};
const relativeUrls = {
preferencesUrl: '/subscription/' + list.cid + '/manage/' + subscription.cid,
unsubscribeUrl: '/subscription/' + list.cid + '/unsubscribe/' + subscription.cid
};
await _sendMail(list, email, 'already_subscribed', _('%s: Email Address Already Registered'), relativeUrls, mailOpts, subscription);
await _sendMail(list, email, 'already_subscribed', _('%s: Email Address Already Registered'), relativeUrls, subscription);
}
async function sendConfirmAddressChange(list, email, cid, subscription) {
const mailOpts = {
ignoreDisableConfirmations: true
};
const relativeUrls = {
confirmUrl: '/subscription/confirm/change-address/' + cid
};
await _sendMail(list, email, 'confirm_address_change', _('%s: Please Confirm Email Change in Subscription'), relativeUrls, mailOpts, subscription);
await _sendMail(list, email, 'confirm_address_change', _('%s: Please Confirm Email Change in Subscription'), relativeUrls, subscription);
}
async function sendConfirmSubscription(list, email, cid, subscription) {
const mailOpts = {
ignoreDisableConfirmations: true
};
const relativeUrls = {
confirmUrl: '/subscription/confirm/subscribe/' + cid
};
await _sendMail(list, email, 'confirm_subscription', _('%s: Please Confirm Subscription'), relativeUrls, mailOpts, subscription);
await _sendMail(list, email, 'confirm_subscription', _('%s: Please Confirm Subscription'), relativeUrls, subscription);
}
async function sendConfirmUnsubscription(list, email, cid, subscription) {
const mailOpts = {
ignoreDisableConfirmations: true
};
const relativeUrls = {
confirmUrl: '/subscription/confirm/unsubscribe/' + cid
};
await _sendMail(list, email, 'confirm_unsubscription', _('%s: Please Confirm Unsubscription'), relativeUrls, mailOpts, subscription);
await _sendMail(list, email, 'confirm_unsubscription', _('%s: Please Confirm Unsubscription'), relativeUrls, subscription);
}
async function sendUnsubscriptionConfirmed(list, email, subscription) {
const relativeUrls = {
subscribeUrl: '/subscription/' + list.cid + '?cid=' + subscription.cid
};
await _sendMail(list, email, 'unsubscription_confirmed', _('%s: Unsubscription Confirmed'), relativeUrls, {}, subscription);
await _sendMail(list, email, 'unsubscription_confirmed', _('%s: Unsubscription Confirmed'), relativeUrls, subscription);
}
function getDisplayName(flds, subscription) {
@ -110,7 +98,7 @@ function getDisplayName(flds, subscription) {
}
}
async function _sendMail(list, email, template, subject, relativeUrls, mailOpts, subscription) {
async function _sendMail(list, email, template, subject, relativeUrls, subscription) {
console.log(subscription);
const flds = await fields.list(contextHelpers.getAdminContext(), list.id);
@ -122,16 +110,12 @@ async function _sendMail(list, email, template, subject, relativeUrls, mailOpts,
}
}
const configItems = await settings.get(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'defaultPostaddress', 'serviceUrl', 'disableConfirmations']);
const configItems = await settings.get(['defaultHomepage', 'defaultFrom', 'defaultAddress', 'serviceUrl']);
if (!mailOpts.ignoreDisableConfirmations && configItems.disableConfirmations) {
return;
}
const data = {
title: list.name,
homepage: configItems.defaultHomepage || configItems.serviceUrl,
contactAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress
};
for (let relativeUrlKey in relativeUrls) {

29
lib/urls.js Normal file
View file

@ -0,0 +1,29 @@
'use strict';
const config = require('config');
const url = require('url');
function getTrustedUrl(path) {
return config.www.trustedUrlBase + (path || '');
}
function getSandboxUrl(path) {
return config.www.sandboxUrlBase + (path || '');
}
function getTrustedUrlBaseDir() {
const mailtrainUrl = url.parse(getTrustedUrl());
return mailtrainUrl.pathname;
}
function getSandboxUrlBaseDir() {
const mailtrainUrl = url.parse(getSandboxUrl());
return mailtrainUrl.pathname;
}
module.exports = {
getTrustedUrl,
getSandboxUrl,
getTrustedUrlBaseDir,
getSandboxUrlBaseDir
};

View file

@ -13,7 +13,7 @@ const segments = require('./segments');
const UnsubscriptionMode = require('../shared/lists').UnsubscriptionMode;
const allowedKeys = new Set(['name', 'description', 'default_form', 'public_subscribe', 'unsubscription_mode', 'namespace']);
const allowedKeys = new Set(['name', 'description', 'default_form', 'public_subscribe', 'unsubscription_mode', 'contact_email', 'homepage', 'namespace']);
function hash(entity) {
return hasher.hash(filterObject(entity, allowedKeys));

View file

@ -7,9 +7,9 @@ const { enforce, filterObject } = require('../lib/helpers');
const interoperableErrors = require('../shared/interoperable-errors');
const shares = require('./shares');
const namespaceHelpers = require('../lib/namespace-helpers');
const {MailerType} = require('../shared/send-configurations');
const {MailerType, getSystemSendConfigurationId} = require('../shared/send-configurations');
const allowedKeys = new Set(['name', 'description', 'from_email', 'from_email_overridable', 'from_name', 'from_name_overridable', 'subject', 'subject_overridable', 'mailer_type', 'mailer_settings', 'namespace']);
const allowedKeys = new Set(['name', 'description', 'from_email', 'from_email_overridable', 'from_name', 'from_name_overridable', 'subject', 'subject_overridable', 'verp_hostname', 'mailer_type', 'mailer_settings', 'namespace']);
function hash(entity) {
@ -95,6 +95,10 @@ async function updateWithConsistencyCheck(context, entity) {
}
async function remove(context, id) {
if (id === getSystemSendConfigurationId()) {
shares.throwPermissionDenied();
}
await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'sendConfiguration', id, 'delete');

View file

@ -1,17 +1,23 @@
'use strict';
const knex = require('../lib/knex');
const { filterObject } = require('../lib/helpers');
const hasher = require('node-object-hash')();
const shares = require('./shares');
const allowedKeys = new Set(['adminEmail', 'uaCode', 'pgpPassphrase', 'pgpPrivateKey', 'defaultHomepage']);
const allowedKeys = new Set(['adminEmail', 'uaCode', 'shoutout', 'pgpPassphrase', 'pgpPrivateKey', 'defaultHomepage']);
// defaultHomepage is used as a default to list.homepage - if the list.homepage is not filled in
function hash(entity) {
return hasher.hash(filterObject(entity, allowedKeys));
}
async function get(context, keyOrKeys) {
shares.enforceGlobalPermission(context, 'manageSettings');
let keys;
if (!keyOrKeys) {
keys = allowedKeys.values();
keys = [...allowedKeys.values()];
} else if (!Array.isArray(keyOrKeys)) {
keys = [ keys ];
} else {
@ -25,7 +31,7 @@ async function get(context, keyOrKeys) {
settings[row.key] = row.value;
}
if (!Array.isArray(keyOrKeys)) {
if (!Array.isArray(keyOrKeys) && keyOrKeys) {
return settings[keyOrKeys];
} else {
return settings;
@ -48,6 +54,7 @@ async function set(context, data) {
}
module.exports = {
hash,
get,
set
};

View file

@ -1,6 +1,5 @@
'use strict';
let _ = require('../lib/translate')._;
const knex = require('../lib/knex');
const config = require('config');
const { enforce } = require('../lib/helpers');
@ -8,6 +7,7 @@ const dtHelpers = require('../lib/dt-helpers');
const permissions = require('../lib/permissions');
const interoperableErrors = require('../shared/interoperable-errors');
const log = require('npmlog');
const {getGlobalNamespaceId} = require('../shared/namespaces');
// TODO: This would really benefit from some permission cache connected to rebuildPermissions
// A bit of the problem is that the cache would have to expunged as the result of other processes modifying entites/permissions
@ -190,7 +190,7 @@ async function rebuildPermissionsTx(tx, restriction) {
const usersWithRoleInRootNamespaceQuery = tx('users')
.leftJoin(namespaceEntityType.sharesTable, {
'users.id': `${namespaceEntityType.sharesTable}.user`,
[`${namespaceEntityType.sharesTable}.entity`]: 1 /* Global namespace id */
[`${namespaceEntityType.sharesTable}.entity`]: getGlobalNamespaceId()
})
.select(['users.id', 'users.role as userRole', `${namespaceEntityType.sharesTable}.role`]);
if (restriction.userId) {
@ -204,8 +204,8 @@ async function rebuildPermissionsTx(tx, restriction) {
if (roleConf) {
const desiredRole = roleConf.rootNamespaceRole;
if (desiredRole && user.role !== desiredRole) {
await tx(namespaceEntityType.sharesTable).where({ user: user.id, entity: 1 /* Global namespace id */ }).del();
await tx(namespaceEntityType.sharesTable).insert({ user: user.id, entity: 1 /* Global namespace id */, role: desiredRole, auto: 1 });
await tx(namespaceEntityType.sharesTable).where({ user: user.id, entity: getGlobalNamespaceId() }).del();
await tx(namespaceEntityType.sharesTable).insert({ user: user.id, entity: getGlobalNamespaceId(), role: desiredRole, auto: 1 });
}
}
}
@ -393,7 +393,7 @@ async function regenerateRoleNamesTable() {
function throwPermissionDenied() {
throw new interoperableErrors.PermissionDeniedError(_('Permission denied'));
throw new interoperableErrors.PermissionDeniedError('Permission denied');
}
async function removeDefaultShares(tx, user) {
@ -409,7 +409,7 @@ async function removeDefaultShares(tx, user) {
}
if (roleConf.rootNamespaceRole) {
await tx(namespaceEntityType.sharesTable).where({ user: user.id, entity: 1 /* Global namespace id */ }).del();
await tx(namespaceEntityType.sharesTable).where({ user: user.id, entity: getGlobalNamespaceId() }).del();
}
}
}

32
routes/index.js Normal file
View file

@ -0,0 +1,32 @@
'use strict';
const passport = require('../lib/passport');
const _ = require('../lib/translate')._;
const clientHelpers = require('../lib/client-helpers');
const routerFactory = require('../lib/router-async');
function getRouter(trusted) {
const router = routerFactory.create();
if (trusted) {
router.getAsync('/*', passport.csrfProtection, async (req, res) => {
const mailtrainConfig = await clientHelpers.getAnonymousConfig(req.context, trusted);
if (req.user) {
Object.assign(mailtrainConfig, await clientHelpers.getAuthenticatedConfig(req.context));
}
res.render('root', {
reactCsrfToken: req.csrfToken(),
mailtrainConfig: JSON.stringify(mailtrainConfig)
});
});
}
return router;
}
module.exports = {
getRouter
};

View file

@ -1,7 +1,7 @@
'use strict';
const config = require('config');
const router = require('../lib/router-async').create();
const routerFactory = require('../lib/router-async');
const passport = require('../lib/passport');
const clientHelpers = require('../lib/client-helpers');
const gm = require('gm').subClass({
@ -110,73 +110,84 @@ function sanitizeSize(val, min, max, defaultVal, allowNull) {
return val;
}
router.getAsync('/img/:type/:fileId', passport.loggedIn, async (req, res) => {
const method = req.query.method;
const params = req.query.params;
let [width, height] = params.split(',');
let image;
if (method === 'placeholder') {
width = sanitizeSize(width, 1, 2048, 600, false);
height = sanitizeSize(height, 1, 2048, 300, false);
image = await placeholderImage(width, height);
} else {
width = sanitizeSize(width, 1, 2048, 600, false);
height = sanitizeSize(height, 1, 2048, 300, true);
// TODO - validate that one has the rights to read this ???
image = await resizedImage(req.query.src, method, width, height);
function getRouter(trusted) {
const router = routerFactory.create();
if (!trusted) {
router.getAsync('/img/:type/:fileId', passport.loggedIn, async (req, res) => {
const method = req.query.method;
const params = req.query.params;
let [width, height] = params.split(',');
let image;
if (method === 'placeholder') {
width = sanitizeSize(width, 1, 2048, 600, false);
height = sanitizeSize(height, 1, 2048, 300, false);
image = await placeholderImage(width, height);
} else {
width = sanitizeSize(width, 1, 2048, 600, false);
height = sanitizeSize(height, 1, 2048, 300, true);
// TODO - validate that one has the rights to read this ???
image = await resizedImage(req.query.src, method, width, height);
}
res.set('Content-Type', 'image/' + image.format);
image.stream.pipe(res);
});
fileHelpers.installUploadHandler(router, '/upload/:type/:entityId', true);
router.getAsync('/upload/:type/:fileId', passport.loggedIn, async (req, res) => {
const entries = await files.list(req.context, req.params.type, req.params.fileId);
const filesOut = [];
for (const entry of entries) {
filesOut.push({
name: entry.originalname,
url: `/files/${req.params.type}/${req.params.fileId}/${entry.filename}`,
size: entry.size,
thumbnailUrl: `/files/${req.params.type}/${req.params.fileId}/${entry.filename}` // TODO - use smaller thumbnails
})
}
res.json({
files: filesOut
});
});
router.getAsync('/editor', passport.csrfProtection, async (req, res) => {
const resourceType = req.query.type;
const resourceId = req.query.id;
const mailtrainConfig = await clientHelpers.getAnonymousConfig(req.context, trusted);
let languageStrings = null;
if (config.language && config.language !== 'en') {
const lang = config.language.split('_')[0];
try {
const file = path.join(__dirname, '..', 'client', 'public', 'mosaico', 'lang', 'mosaico-' + lang + '.json');
languageStrings = await fsReadFile(file, 'utf8');
} catch (err) {
}
}
res.render('mosaico/root', {
layout: 'mosaico/layout',
editorConfig: config.mosaico,
languageStrings: languageStrings,
reactCsrfToken: req.csrfToken(),
mailtrainConfig: JSON.stringify(mailtrainConfig)
});
});
}
res.set('Content-Type', 'image/' + image.format);
image.stream.pipe(res);
});
return router;
}
fileHelpers.installUploadHandler(router, '/upload/:type/:entityId', true);
router.getAsync('/upload/:type/:fileId', passport.loggedIn, async (req, res) => {
const entries = await files.list(req.context, req.params.type, req.params.fileId);
const filesOut = [];
for (const entry of entries) {
filesOut.push({
name: entry.originalname,
url: `/files/${req.params.type}/${req.params.fileId}/${entry.filename}`,
size: entry.size,
thumbnailUrl: `/files/${req.params.type}/${req.params.fileId}/${entry.filename}` // TODO - use smaller thumbnails
})
}
res.json({
files: filesOut
});
});
router.getAsync('/editor', passport.csrfProtection, async (req, res) => {
const resourceType = req.query.type;
const resourceId = req.query.id;
const mailtrainConfig = await clientHelpers.getAnonymousConfig(req.context);
let languageStrings = null;
if (config.language && config.language !== 'en') {
const lang = config.language.split('_')[0];
try {
const file = path.join(__dirname, '..', 'client', 'public', 'mosaico', 'lang', 'mosaico-' + lang + '.json');
languageStrings = await fsReadFile(file, 'utf8');
} catch (err) {
}
}
res.render('mosaico/root', {
layout: 'mosaico/layout',
editorConfig: config.mosaico,
languageStrings: languageStrings,
reactCsrfToken: req.csrfToken(),
mailtrainConfig: JSON.stringify(mailtrainConfig)
});
});
module.exports = router;
module.exports = {
getRouter
};

22
routes/rest/settings.js Normal file
View file

@ -0,0 +1,22 @@
'use strict';
const passport = require('../../lib/passport');
const settings = require('../../models/settings');
const router = require('../../lib/router-async').create();
router.getAsync('/settings', passport.loggedIn, async (req, res) => {
const configItems = await settings.get(req.context);
configItems.hash = settings.hash(configItems);
return res.json(configItems);
});
router.putAsync('/settings', passport.loggedIn, passport.csrfProtection, async (req, res) => {
const configItems = req.body;
await settings.set(req.context, configItems);
return res.json();
});
module.exports = router;

View file

@ -1,22 +0,0 @@
'use strict';
const passport = require('../lib/passport');
const _ = require('../lib/translate')._;
const clientHelpers = require('../lib/client-helpers');
const router = require('../lib/router-async').create();
router.getAsync('/*', passport.csrfProtection, async (req, res) => {
const mailtrainConfig = await clientHelpers.getAnonymousConfig(req.context);
if (req.user) {
Object.assign(mailtrainConfig, await clientHelpers.getAuthenticatedConfig(req.context));
}
res.render('root', {
reactCsrfToken: req.csrfToken(),
mailtrainConfig: JSON.stringify(mailtrainConfig)
});
});
module.exports = router;

View file

@ -205,10 +205,9 @@ async function _renderSubscribe(req, res, list, subscription) {
data.customFields = await fields.forHbs(contextHelpers.getAdminContext(), list.id, subscription);
data.useEditor = true;
const configItems = await settings.get(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress']);
const configItems = await settings.get(['pgpPrivateKey', 'defaultAddress']);
data.hasPubkey = !!configItems.pgpPrivateKey;
data.defaultAddress = configItems.defaultAddress;
data.defaultPostaddress = configItems.defaultPostaddress;
data.template = {
template: 'subscription/web-subscribe.mjml.hbs',
@ -407,10 +406,9 @@ router.getAsync('/:lcid/manage/:ucid', passport.csrfProtection, async (req, res)
data.useEditor = true;
const configItems = await settings.get(['pgpPrivateKey', 'defaultAddress', 'defaultPostaddress']);
const configItems = await settings.get(['pgpPrivateKey', 'defaultAddress']);
data.hasPubkey = !!configItems.pgpPrivateKey;
data.defaultAddress = configItems.defaultAddress;
data.defaultPostaddress = configItems.defaultPostaddress;
data.template = {
template: 'subscription/web-manage.mjml.hbs',
@ -454,7 +452,7 @@ router.getAsync('/:lcid/manage-address/:ucid', passport.csrfProtection, async (r
throw new interoperableErrors.NotFoundError('Subscription not found in this list');
}
const configItems = await settings.get(['defaultAddress', 'defaultPostaddress']);
const configItems = await settings.get(['defaultAddress']);
const data = {};
data.email = subscription.email;
@ -463,7 +461,6 @@ router.getAsync('/:lcid/manage-address/:ucid', passport.csrfProtection, async (r
data.title = list.name;
data.csrfToken = req.csrfToken();
data.defaultAddress = configItems.defaultAddress;
data.defaultPostaddress = configItems.defaultPostaddress;
data.template = {
template: 'subscription/web-manage-address.mjml.hbs',
@ -538,7 +535,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(contextHelpers.getAdminContext(), req.params.lcid);
const configItems = await settings.get(['defaultAddress', 'defaultPostaddress']);
const configItems = await settings.get(['defaultAddress']);
const autoUnsubscribe = req.query.auto === 'yes';
@ -563,7 +560,6 @@ router.getAsync('/:lcid/unsubscribe/:ucid', passport.csrfProtection, async (req,
data.csrfToken = req.csrfToken();
data.campaign = req.query.c;
data.defaultAddress = configItems.defaultAddress;
data.defaultPostaddress = configItems.defaultPostaddress;
data.template = {
template: 'subscription/web-unsubscribe.mjml.hbs',
@ -702,14 +698,13 @@ router.postAsync('/publickey', passport.parseForm, async (req, res) => {
async function webNotice(type, req, res) {
const list = await lists.getByCid(contextHelpers.getAdminContext(), req.params.cid);
const configItems = await settings.get(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'defaultPostaddress', 'adminEmail']);
const configItems = await settings.get(['defaultHomepage', 'serviceUrl', 'defaultAddress', 'adminEmail']);
const data = {
title: list.name,
homepage: configItems.defaultHomepage || configItems.serviceUrl,
defaultAddress: configItems.defaultAddress,
defaultPostaddress: configItems.defaultPostaddress,
contactAddress: configItems.defaultAddress,
template: {
template: 'subscription/web-' + type + '-notice.mjml.hbs',

View file

@ -1,3 +1,5 @@
const {getGlobalNamespaceId} = require('../../../shared/namespaces');
exports.up = (knex, Promise) => (async() => {
const entityTypesAddNamespace = ['list', 'custom_form', 'template', 'campaign', 'report', 'report_template', 'user'];
await knex.schema.createTable('namespaces', table => {
@ -8,7 +10,7 @@ exports.up = (knex, Promise) => (async() => {
});
await knex('namespaces').insert({
id: 1, /* Global namespace id */
id: getGlobalNamespaceId(),
name: 'Root',
description: 'Root namespace'
});
@ -19,7 +21,7 @@ exports.up = (knex, Promise) => (async() => {
});
await knex(`${entityType}s`).update({
namespace: 1 /* Global namespace id */
namespace: getGlobalNamespaceId()
});
await knex.schema.table(`${entityType}s`, table => {

View file

@ -1,41 +0,0 @@
exports.up = (knex, Promise) => (async() => {
await knex.schema.createTable('send_configurations', table => {
table.increments('id').primary();
table.string('name');
table.text('description');
table.string('from_email');
table.boolean('from_email_overridable').defaultTo(false);
table.string('from_name');
table.boolean('from_name_overridable').defaultTo(false);
table.string('subject');
table.boolean('subject_overridable').defaultTo(false);
table.string('mailer_type');
table.text('mailer_settings', 'longtext');
table.timestamp('created').defaultTo(knex.fn.now());
table.integer('namespace').unsigned().references('namespaces.id');
});
await knex.schema.createTable(`shares_send_configuration`, table => {
table.integer('entity').unsigned().notNullable().references(`send_configurations.id`).onDelete('CASCADE');
table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
table.string('role', 128).notNullable();
table.boolean('auto').defaultTo(false);
table.primary(['entity', 'user']);
});
await knex.schema.createTable(`permissions_send_configuration`, table => {
table.integer('entity').unsigned().notNullable().references(`send_configurations.id`).onDelete('CASCADE');
table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
table.string('operation', 128).notNullable();
table.primary(['entity', 'user', 'operation']);
});
})();
exports.down = (knex, Promise) => (async() => {
await knex.schema
.dropTable('shares_send_configuration')
.dropTable('permissions_send_configuration')
.dropTable('send_configurations')
;
})();

View file

@ -0,0 +1,122 @@
const { MailerType, getSystemSendConfigurationId } = require('../../../shared/send-configurations');
const {getGlobalNamespaceId} = require('../../../shared/namespaces');
exports.up = (knex, Promise) => (async() => {
await knex.schema.createTable('send_configurations', table => {
table.increments('id').primary();
table.string('name');
table.text('description');
table.string('from_email');
table.boolean('from_email_overridable').defaultTo(false);
table.string('from_name');
table.boolean('from_name_overridable').defaultTo(false);
table.string('subject');
table.boolean('subject_overridable').defaultTo(false);
table.string('verp_hostname'); // VERP is not used if verp_hostname is null
table.string('mailer_type');
table.text('mailer_settings', 'longtext');
table.timestamp('created').defaultTo(knex.fn.now());
table.integer('namespace').unsigned().references('namespaces.id');
});
await knex.schema.createTable(`shares_send_configuration`, table => {
table.integer('entity').unsigned().notNullable().references(`send_configurations.id`).onDelete('CASCADE');
table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
table.string('role', 128).notNullable();
table.boolean('auto').defaultTo(false);
table.primary(['entity', 'user']);
});
await knex.schema.createTable(`permissions_send_configuration`, table => {
table.integer('entity').unsigned().notNullable().references(`send_configurations.id`).onDelete('CASCADE');
table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
table.string('operation', 128).notNullable();
table.primary(['entity', 'user', 'operation']);
});
await knex.schema.table('lists', table => {
table.string('contact_email');
table.string('homepage');
});
const settingsRows = await knex('settings').select(['key', 'value']);
const settings = {};
for (const row of settingsRows) {
settings[row.key] = row.value;
}
await knex('lists').update({contact_email: settings.defaultAddress});
await knex('lists').update({homepage: settings.defaultHomepage});
let mailer_settings;
let mailer_type;
if (settings.mailTransport === 'ses') {
mailer_type = MailerType.AWS_SES;
mailer_settings = {
key: settings.sesKey,
secret: settings.sesSecret,
region: settings.sesSecret,
maxConnections: Number(settings.smtpMaxConnections),
throttling: Number(settings.smtpThrottling),
logTransactions: !!settings.smtpLog
};
} else {
mailer_type = MailerType.GENERIC_SMTP;
mailer_settings = {
hostname: settings.smtpHostname,
port: Number(settings.smtpPort),
encryption: settings.smtpEncryption,
useAuth: !settings.smtpDisableAuth,
user: settings.smtpUser,
password: settings.smtpPass,
allowSelfSigned: settings.smtpSelfSigned,
maxConnections: Number(settings.smtpMaxConnections),
maxMessages: Number(settings.smtpMaxMessages),
throttling: Number(settings.smtpThrottling),
logTransactions: !!settings.smtpLog
};
if (settings.dkimApiKey) {
mailer_type = MailerType.ZONE_MTA;
mailer_settings.dkimApiKey = settings.dkimApiKey;
mailer_settings.dkimDomain = settings.dkimDomain;
mailer_settings.dkimSelector = settings.dkimSelector;
mailer_settings.dkimPrivateKey = settings.dkimPrivateKey;
}
}
await knex('send_configurations').insert({
id: getSystemSendConfigurationId(),
name: 'System',
description: 'Send configuration used to deliver system emails',
from_email: settings.defaultAddress,
from_email_overridable: true,
from_name: settings.defaultFrom,
from_name_overridable: true,
subject: settings.defaultSubject,
subject_overridable: true,
verp_hostname: settings.verpUse ? settings.verpHostname : null,
mailer_type,
mailer_settings: JSON.stringify(mailer_settings),
namespace: getGlobalNamespaceId()
});
await knex('settings').del();
await knex('settings').insert([
{ key: 'uaCode', value: settings.uaCode },
{ key: 'shoutout', value: settings.shoutout },
{ key: 'adminEmail', value: settings.adminEmail },
{ key: 'defaultHomepage', value: settings.defaultHomepage },
{ key: 'pgpPassphrase', value: settings.pgpPassphrase },
{ key: 'pgpPrivateKey', value: settings.pgpPrivateKey }
]);
})();
exports.down = (knex, Promise) => (async() => {
await knex.schema
.dropTable('shares_send_configuration')
.dropTable('permissions_send_configuration')
.dropTable('send_configurations')
;
})();

View file

@ -1,5 +0,0 @@
exports.up = (knex, Promise) => (async() => {
})();
exports.down = (knex, Promise) => (async() => {
})();

9
shared/namespaces.js Normal file
View file

@ -0,0 +1,9 @@
'use strict';
function getGlobalNamespaceId() {
return 1;
}
module.exports = {
getGlobalNamespaceId
};

View file

@ -1,12 +1,17 @@
'use strict';
const MailerType = {
GENERIC_SMTP: 0,
ZONE_MTA: 1,
AWS_SES: 2,
GENERIC_SMTP: 'generic_smtp',
ZONE_MTA: 'zone_mta',
AWS_SES: 'aws_ses',
MAX: 3
};
function getSystemSendConfigurationId() {
return 1;
}
module.exports = {
MailerType
MailerType,
getSystemSendConfigurationId
};