Configuration split to lists, send configurations and server config.
This is before testing.
This commit is contained in:
parent
4fce4b6f81
commit
c12efeb97f
40 changed files with 819 additions and 311 deletions
|
@ -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&MERGE_CHECKBOX=yes&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&TYPE=birthday-us&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&'</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&'</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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/>
|
||||
|
||||
|
|
|
@ -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/>
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}/>
|
||||
|
||||
|
|
|
@ -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.')}/>
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
||||
*/
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
99
client/src/settings/Update.js
Normal file
99
client/src/settings/Update.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
21
client/src/settings/root.js
Normal file
21
client/src/settings/root.js
Normal 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
|
||||
}
|
|
@ -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
|
||||
?
|
||||
|
|
|
@ -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/>
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue