Add settings UI and "hot reload" support to the app.

This is a major breaking change that moves away from having the
entire app configuration in external TOML files to settings being
in the database with a UI to update them dynamically.

The app loads all config into memory (app settings, SMTP conf)
on boot. "Hot" replacing them is complex and it's a fair tradeoff
to instead just restart the application as it is practically
instant.

A new `settings` table stores arbitrary string keys with a JSONB
value field which happens to support arbitrary types. After every
settings update, the app gracefully releases all resources
(HTTP server, DB pool, SMTP pool etc.) and restarts itself,
occupying the same PID. If there are any running campaigns, the
auto-restart doesn't happen and the user is prompted to invoke
it manually with a one-click button once all running campaigns
have been paused.
This commit is contained in:
Kailash Nadh 2020-07-08 16:30:14 +05:30
parent d294c95c9b
commit 942eb7c3d8
27 changed files with 1148 additions and 377 deletions

View file

@ -20,7 +20,7 @@ deps:
# Build steps.
.PHONY: build
build:
go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}'"
go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}'"
.PHONY: build-frontend
build-frontend:

View file

@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"syscall"
"time"
"github.com/jmoiron/sqlx/types"
"github.com/labstack/echo"
@ -14,7 +16,8 @@ type configScript struct {
RootURL string `json:"rootURL"`
FromEmail string `json:"fromEmail"`
Messengers []string `json:"messengers"`
MediaProvider string `json:"media_provider"`
MediaProvider string `json:"mediaProvider"`
NeedsRestart bool `json:"needsRestart"`
}
// handleGetConfigScript returns general configuration as a Javascript
@ -28,11 +31,16 @@ func handleGetConfigScript(c echo.Context) error {
Messengers: app.manager.GetMessengerNames(),
MediaProvider: app.constants.MediaProvider,
}
)
app.Lock()
out.NeedsRestart = app.needsRestart
app.Unlock()
var (
b = bytes.Buffer{}
j = json.NewEncoder(&b)
)
b.Write([]byte(`var CONFIG = `))
_ = j.Encode(out)
return c.Blob(http.StatusOK, "application/javascript", b.Bytes())
@ -67,3 +75,13 @@ func handleGetDashboardCounts(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{out})
}
// handleReloadApp restarts the app.
func handleReloadApp(c echo.Context) error {
app := c.Get("app").(*App)
go func() {
<-time.After(time.Millisecond * 500)
app.sigChan <- syscall.SIGHUP
}()
return c.JSON(http.StatusOK, okResp{true})
}

View file

@ -1,199 +1,12 @@
[app]
# Interface and port where the app will run its webserver.
address = "0.0.0.0:9000"
# Public root URL of the listmonk installation that'll be used
# in the messages for linking to images, unsubscribe page etc.
root = "https://listmonk.mysite.com"
# (Optional) full URL to the static logo to be displayed on
# user facing view such as the unsubscription page.
# eg: https://mysite.com/images/logo.svg
logo_url = "https://listmonk.mysite.com/public/static/logo.png"
# (Optional) full URL to the static favicon to be displayed on
# user facing view such as the unsubscription page.
# eg: https://mysite.com/images/favicon.png
favicon_url = "https://listmonk.mysite.com/public/static/favicon.png"
# The default 'from' e-mail for outgoing e-mail campaigns.
from_email = "listmonk <from@mail.com>"
# List of e-mail addresses to which admin notifications such as
# import updates, campaign completion, failure etc. should be sent.
# To disable notifications, set an empty list, eg: notify_emails = []
notify_emails = ["admin1@mysite.com", "admin2@mysite.com"]
# Maximum concurrent workers that will attempt to send messages
# simultaneously. This should ideally depend on the number of CPUs
# available, and should be based on the maximum number of messages
# a target SMTP server will accept.
concurrency = 5
# Maximum number of messages to be sent out per second per worker.
# If concurrency = 10 and message_rate = 10, then up to 10x10=100 messages
# may be pushed out every second. This, along with concurrency, should be
# tweaked to keep the net messages going out per second under the target
# SMTP's rate limits, if any.
message_rate = 5
# The number of errors (eg: SMTP timeouts while e-mailing) a running
# campaign should tolerate before it is paused for manual
# investigation or intervention. Set to 0 to never pause.
max_send_errors = 1000
# The number of subscribers to pull from the databse in a single iteration.
# Each iteration pulls subscribers from the database, sends messages to them,
# and then moves on to the next iteration to pull the next batch.
# This should ideally be higher than the maximum achievable throughput (concurrency * message_rate)
batch_size = 1000
[privacy]
# Allow subscribers to unsubscribe from all mailing lists and mark themselves
# as blacklisted?
allow_blacklist = false
# Allow subscribers to export data recorded on them?
allow_export = false
# Items to include in the data export.
# profile Subscriber's profile including custom attributes
# subscriptions Subscriber's subscription lists (private list names are masked)
# campaign_views Campaigns the subscriber has viewed and the view counts
# link_clicks Links that the subscriber has clicked and the click counts
exportable = ["profile", "subscriptions", "campaign_views", "link_clicks"]
# Allow subscribers to delete themselves from the database?
# This deletes the subscriber and all their subscriptions.
# Their association to campaign views and link clicks are also
# removed while views and click counts remain (with no subscriber
# associated to them) so that stats and analytics aren't affected.
allow_wipe = false
# Interface and port where the app will run its webserver.
address = "0.0.0.0:9000"
# Database.
[db]
host = "db"
port = 5432
user = "listmonk"
password = "listmonk"
database = "listmonk"
ssl_mode = "disable"
# Maximum active and idle connections to pool.
max_open = 50
max_idle = 10
# SMTP servers.
[smtp]
[smtp.my0]
enabled = true
host = "my.smtp.server"
port = 25
# "cram", "plain", or "login". Empty string for no auth.
auth_protocol = "cram"
username = "xxxxx"
password = ""
# Format to send e-mails in: html|plain|both.
email_format = "both"
# Optional. Some SMTP servers require a FQDN in the hostname.
# By default, HELLOs go with "localhost". Set this if a custom
# hostname should be used.
hello_hostname = ""
# Maximum concurrent connections to the SMTP server.
max_conns = 10
# Time to wait for new activity on a connection before closing
# it and removing it from the pool.
idle_timeout = "15s"
# Message send / wait timeout.
wait_timeout = "5s"
# The number of times a message should be retried if sending fails.
max_msg_retries = 2
# Enable STARTTLS.
tls_enabled = true
tls_skip_verify = false
# One or more optional custom headers to be attached to all e-mails
# sent from this SMTP server. Uncomment the line to enable.
# email_headers = { "X-Sender" = "listmonk", "X-Custom-Header" = "listmonk" }
[smtp.postal]
enabled = false
host = "my.smtp.server2"
port = 25
# cram or plain.
auth_protocol = "plain"
username = "xxxxx"
password = ""
# Format to send e-mails in: html|plain|both.
email_format = "both"
# Optional. Some SMTP servers require a FQDN in the hostname.
# By default, HELLOs go with "localhost". Set this if a custom
# hostname should be used.
hello_hostname = ""
# Maximum concurrent connections to the SMTP server.
max_conns = 10
# Time to wait for new activity on a connection before closing
# it and removing it from the pool.
idle_timeout = "15s"
# Message send / wait timeout.
wait_timeout = "5s"
# The number of times a message should be retried if sending fails.
max_msg_retries = 2
# Enable STARTTLS.
tls_enabled = true
tls_skip_verify = false
[upload]
# File storage backend. "filesystem" or "s3".
provider = "filesystem"
[upload.s3]
# (Optional). AWS Access Key and Secret Key for the user to access the bucket.
# Leaving it empty would default to use instance IAM role.
aws_access_key_id = ""
aws_secret_access_key = ""
# AWS Region where S3 bucket is hosted.
aws_default_region = "ap-south-1"
# Bucket name.
bucket = ""
# Path where the files will be stored inside bucket. Default is "/".
bucket_path = "/"
# Optional full URL to the bucket. eg: https://files.mycustom.com
bucket_url = ""
# "private" or "public".
bucket_type = "public"
# (Optional) Specify TTL (in seconds) for the generated presigned URL.
# Expiry value is used only if the bucket is private.
expiry = 86400
[upload.filesystem]
# Path to the uploads directory where media will be uploaded.
upload_path="./uploads"
# Upload URI that's visible to the outside world.
# The media uploaded to upload_path will be made available publicly
# under this URI, for instance, list.yoursite.com/uploads.
upload_uri = "/uploads"
host = "db"
port = 5432
user = "listmonk"
password = "listmonk"
database = "listmonk"
ssl_mode = "disable"

View file

@ -63,9 +63,9 @@
icon="file-image-outline" label="Templates"></b-menu-item>
</b-menu-item><!-- campaigns -->
<!-- <b-menu-item :to="{name: 'settings'}" tag="router-link"
<b-menu-item :to="{name: 'settings'}" tag="router-link"
:active="activeItem.settings"
icon="cog-outline" label="Settings"></b-menu-item> -->
icon="cog-outline" label="Settings"></b-menu-item>
</b-menu-list>
</b-menu>
</div>
@ -75,6 +75,18 @@
<!-- body //-->
<div class="main">
<div class="global-notices" v-if="serverConfig.needsRestart">
<div v-if="serverConfig.needsRestart" class="notification is-danger">
Settings have changed. Pause all running campaigns and restart the app
&mdash;
<b-button class="is-primary" size="is-small"
@click="$utils.confirm(
'Ensure running campaigns are paused. Restart?', reloadApp)">
Restart
</b-button>
</div>
</div>
<router-view :key="$route.fullPath" />
</div>
@ -82,8 +94,8 @@
<div class="has-text-centered">
<h1 class="title">Oops</h1>
<p>
Can't connect to the listmonk backend.<br />
Make sure it is running and refresh this page.
Can't connect to the backend.<br />
Make sure the server is running and refresh this page.
</p>
</div>
</b-loading>
@ -92,6 +104,7 @@
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
export default Vue.extend({
name: 'App',
@ -115,17 +128,35 @@ export default Vue.extend({
},
},
methods: {
reloadApp() {
this.$api.reloadApp().then(() => {
this.$utils.toast('Reloading app ...');
// Poll until there's a 200 response, waiting for the app
// to restart and come back up.
const pollId = setInterval(() => {
clearInterval(pollId);
this.$utils.toast('Reload complete');
document.location.reload();
}, 500);
});
},
},
computed: {
...mapState(['serverConfig']),
version() {
return process.env.VUE_APP_VERSION;
},
},
mounted() {
// Lists is required across different views. On app load, fetch the lists
// and have them in the store.
this.$api.getLists();
},
computed: {
version() {
return process.env.VUE_APP_VERSION;
},
},
});
</script>

View file

@ -9,22 +9,6 @@ const http = axios.create({
baseURL: process.env.BASE_URL,
withCredentials: false,
responseType: 'json',
// transformResponse: [
// // Apply the defaut transformations as well.
// ...axios.defaults.transformResponse,
// (resp) => {
// if (!resp) {
// return resp;
// }
// // There's an error message.
// if ('message' in resp && resp.message !== '') {
// return resp;
// }
// return humps.camelizeKeys(resp.data);
// },
// ],
// Override the default serializer to switch params from becoming []id=a&[]id=b ...
// in GET and DELETE requests to id=a&id=b.
@ -47,12 +31,13 @@ http.interceptors.response.use((resp) => {
store.commit('setLoading', { model: resp.config.loading, status: false });
}
let data = {};
if (resp.data && resp.data.data) {
if (typeof resp.data.data === 'object') {
data = humps.camelizeKeys(resp.data.data);
} else {
data = resp.data.data;
let data = { ...resp.data.data };
if (!resp.config.preserveCase) {
if (resp.data && resp.data.data) {
if (typeof resp.data.data === 'object') {
// Transform field case.
data = humps.camelizeKeys(resp.data.data);
}
}
}
@ -75,11 +60,13 @@ http.interceptors.response.use((resp) => {
msg = err.toString();
}
Toast.open({
message: msg,
type: 'is-danger',
queue: false,
});
if (!err.config.disableToast) {
Toast.open({
message: msg,
type: 'is-danger',
queue: false,
});
}
return Promise.reject(err);
});
@ -88,6 +75,12 @@ http.interceptors.response.use((resp) => {
// loading: modelName (set's the loading status in the global store: eg: store.loading.lists = true)
// store: modelName (set's the API response in the global store. eg: store.lists: { ... } )
// Health check endpoint that does not throw a toast.
export const getHealth = () => http.get('/api/health',
{ disableToast: true });
export const reloadApp = () => http.post('/api/admin/reload');
// Dashboard
export const getDashboardCounts = () => http.get('/api/dashboard/counts',
{ loading: models.dashboard });
@ -197,3 +190,10 @@ export const makeTemplateDefault = async (id) => http.put(`/api/templates/${id}/
export const deleteTemplate = async (id) => http.delete(`/api/templates/${id}`,
{ loading: models.templates });
// Settings.
export const getSettings = async () => http.get('/api/settings',
{ loading: models.settings, preserveCase: true });
export const updateSettings = async (data) => http.put('/api/settings', data,
{ loading: models.settings });

View file

@ -77,6 +77,7 @@ section {
}
}
/* Two column sidebar+body layout */
#app {
display: flex;
@ -126,6 +127,20 @@ section {
}
}
/* Global notices */
.global-notices {
margin-bottom: 30px;
}
.notification {
padding: 10px 15px;
&.is-danger {
background: $white-ter;
color: $black;
border-left: 5px solid $red;
font-weight: bold;
}
}
/* HTML code editor */
.html-editor {
position: relative;
@ -166,6 +181,11 @@ section {
display: none;
}
/* Toasts */
.notices .toast {
animation: none;
}
/* Fix for button primary colour. */
.button.is-primary {
background: $primary;
@ -453,6 +473,20 @@ section.campaign {
}
}
/* Settings */
.settings {
.disabled {
opacity: 0.30;
}
.tab-content {
padding-top: 30px;
}
.box {
margin-bottom: 30px;
}
}
/* C3 charting lib */
.c3 {
.c3-chart-lines .c3-line {
stroke-width: 2px;

View file

@ -1,10 +1,15 @@
export const models = Object.freeze({
// This is the config loaded from /api/config.js directly onto the page
// via a <script> tag.
serverConfig: 'serverConfig',
dashboard: 'dashboard',
lists: 'lists',
subscribers: 'subscribers',
campaigns: 'campaigns',
templates: 'templates',
media: 'media',
settings: 'settings',
});
// Ad-hoc URIs that are used outside of vuex requests.

View file

@ -7,6 +7,7 @@ import router from './router';
import store from './store';
import * as api from './api';
import utils from './utils';
import { models } from './constants';
Vue.use(Buefy, {});
Vue.config.productionTip = false;
@ -16,7 +17,10 @@ Vue.prototype.$api = api;
Vue.prototype.$utils = utils;
// window.CONFIG is loaded from /api/config.js directly in a <script> tag.
Vue.prototype.$serverConfig = humps.camelizeKeys(window.CONFIG);
if (window.CONFIG) {
store.commit('setModelResponse',
{ model: models.serverConfig, data: humps.camelizeKeys(window.CONFIG) });
}
new Vue({
router,

View file

@ -71,6 +71,12 @@ const routes = [
meta: { title: 'Campaign', group: 'campaigns' },
component: () => import(/* webpackChunkName: "main" */ '../views/Campaign.vue'),
},
{
path: '/settings',
name: 'settings',
meta: { title: 'Settings', group: 'settings' },
component: () => import(/* webpackChunkName: "main" */ '../views/Settings.vue'),
},
];
const router = new VueRouter({

View file

@ -41,6 +41,8 @@ export default new Vuex.Store({
[models.campaigns]: (state) => state[models.campaigns],
[models.media]: (state) => state[models.media],
[models.templates]: (state) => state[models.templates],
[models.settings]: (state) => state[models.settings],
[models.serverConfig]: (state) => state[models.serverConfig],
},
modules: {

View file

@ -86,12 +86,12 @@ export default class utils {
});
};
static toast = (msg, typ) => {
static toast = (msg, typ, duration) => {
Toast.open({
message: msg,
type: !typ ? 'is-success' : typ,
queue: false,
duration: 3000,
duration: duration || 3000,
});
};
}

View file

@ -61,7 +61,6 @@ export default Vue.extend({
methods: {
getPublicLists(lists) {
console.log(lists.filter((l) => l.type === 'public'));
return lists.filter((l) => l.type === 'public');
},
},

View file

@ -3,7 +3,7 @@
<h1 class="title is-4">Media
<span v-if="media.length > 0">({{ media.length }})</span>
<span class="has-text-grey-light"> / {{ $serverConfig.mediaProvider }}</span>
<span class="has-text-grey-light"> / {{ serverConfig.mediaProvider }}</span>
</h1>
<b-loading :active="isProcessing || loading.media"></b-loading>
@ -141,7 +141,7 @@ export default Vue.extend({
},
computed: {
...mapState(['media', 'loading']),
...mapState(['media', 'serverConfig', 'loading']),
isProcessing() {
if (this.toUpload > 0 && this.uploaded < this.toUpload) {

View file

@ -0,0 +1,423 @@
<template>
<section class="settings">
<b-loading :is-full-page="true" v-if="isLoading" active />
<header class="columns">
<div class="column is-half">
<h1 class="title is-4">Settings</h1>
</div>
<div class="column has-text-right">
<b-button :disabled="!hasFormChanged"
type="is-primary" icon-left="content-save-outline"
@click="onSubmit" class="isSaveEnabled">Save changes</b-button>
</div>
</header>
<hr />
<section class="wrap-small">
<form @submit.prevent="onSubmit">
<b-tabs type="is-boxed" :animated="false">
<b-tab-item label="General">
<div class="items">
<b-field label="Logo URL"
message="(Optional) full URL to the static logo to be displayed on
user facing view such as the unsubscription page.">
<b-input v-model="form['app.logo_url']" name="app.logo_url"
placeholder='https://listmonk.yoursite.com/logo.png' :maxlength="300" />
</b-field>
<b-field label="Favicon URL"
message="(Optional) full URL to the static favicon to be displayed on
user facing view such as the unsubscription page.">
<b-input v-model="form['app.favicon_url']" name="app.favicon_url"
placeholder='https://listmonk.yoursite.com/favicon.png' :maxlength="300" />
</b-field>
<hr />
<b-field label="Default 'from' email"
message="(Optional) full URL to the static logo to be displayed on
user facing view such as the unsubscription page.">
<b-input v-model="form['app.from_email']" name="app.from_email"
placeholder='Listmonk <noreply@listmonk.yoursite.com>'
pattern="(.+?)\s<(.+?)@(.+?)>" :maxlength="300" />
</b-field>
<b-field label="Admin notification e-mails"
message="Comma separated list of e-mail addresses to which admin
notifications such as import updates, campaign completion,
failure etc. should be sent.">
<b-taginput v-model="form['app.notify_emails']" name="app.notify_emails"
:before-adding="(v) => v.match(/(.+?)@(.+?)/)"
placeholder='you@yoursite.com' />
</b-field>
</div>
</b-tab-item><!-- general -->
<b-tab-item label="Performance">
<div class="items">
<b-field label="Concurrency"
message="Maximum concurrent worker (threads) that will attempt to send messages
simultaneously.">
<b-numberinput v-model="form['app.concurrency']"
name="app.concurrency" type="is-light"
placeholder="5" min="1" max="10000" />
</b-field>
<b-field label="Message rate"
message="Maximum number of messages to be sent out per second
per worker in a second. If concurrency = 10 and message_rate = 10,
then up to 10x10=100 messages may be pushed out every second.
This, along with concurrency, should be tweaked to keep the
net messages going out per second under the target
message servers rate limits if any.">
<b-numberinput v-model="form['app.message_rate']"
name="app.message_rate" type="is-light"
placeholder="5" min="1" max="100000" />
</b-field>
<b-field label="Batch size"
message="The number of subscribers to pull from the databse in a single iteration.
Each iteration pulls subscribers from the database, sends messages to them,
and then moves on to the next iteration to pull the next batch.
This should ideally be higher than the maximum achievable
throughput (concurrency * message_rate).">
<b-numberinput v-model="form['app.batch_size']"
name="app.batch_size" type="is-light"
placeholder="1000" min="1" max="100000" />
</b-field>
<b-field label="Maximum error threshold"
message="The number of errors (eg: SMTP timeouts while e-mailing) a running
campaign should tolerate before it is paused for manual
investigation or intervention. Set to 0 to never pause.">
<b-numberinput v-model="form['app.max_send_errors']"
name="app.max_send_errors" type="is-light"
placeholder="1999" min="0" max="100000" />
</b-field>
</div>
</b-tab-item><!-- performance -->
<b-tab-item label="Privacy">
<div class="items">
<b-field label="Allow blacklisting"
message="Allow subscribers to unsubscribe from all mailing lists and mark
themselves as blacklisted?">
<b-switch v-model="form['privacy.allow_blacklist']"
name="privacy.allow_blacklist" />
</b-field>
<b-field label="Allow exporting"
message="Allow subscribers to export data colected on them?">
<b-switch v-model="form['privacy.allow_export']"
name="privacy.allow_export" />
</b-field>
<b-field label="Allow wiping"
message="Allow subscribers to delete themselves from the database?
This deletes the subscriber and all their subscriptions.
Their association to campaign views and link clicks are also
removed while views and click counts remain (with no subscriber
associated to them) so that stats and analytics aren't affected.">
<b-switch v-model="form['privacy.allow_wipe']"
name="privacy.allow_wipe" />
</b-field>
</div>
</b-tab-item><!-- privacy -->
<b-tab-item label="Media uploads">
<div class="items">
<b-field label="Provider">
<b-select v-model="form['upload.provider']" name="upload.provider">
<option value="filesystem">filesystem</option>
<option value="s3">s3</option>
</b-select>
</b-field>
<div class="block" v-if="form['upload.provider'] === 'filesystem'">
<b-field label="Upload path"
message="Path to the directory where media will be uploaded.">
<b-input v-model="form['upload.filesystem.upload_path']"
name="app.upload_path" placeholder='/home/listmonk/uploads'
:maxlength="200" />
</b-field>
<b-field label="Upload URI"
message="Upload URI that's visible to the outside world.
The media uploaded to upload_path will be publicly accessible
under {root_url}/{}, for instance, https://listmonk.yoursite.com/uploads.">
<b-input v-model="form['upload.filesystem.upload_uri']"
name="app.upload_uri" placeholder='/uploads' :maxlength="200" />
</b-field>
</div><!-- filesystem -->
<div class="block" v-if="form['upload.provider'] === 's3'">
<b-field label="AWS access key">
<b-input v-model="form['upload.s3.aws_access_key_id']"
name="upload.s3.aws_access_key_id" :maxlength="200" />
</b-field>
<b-field label="AWS access secret">
<b-input v-model="form['upload.s3.aws_secret_access_key']"
name="upload.s3.aws_secret_access_key" type="password" :maxlength="200" />
</b-field>
<b-field label="Region">
<b-input v-model="form['upload.s3.aws_default_region']"
name="upload.s3.aws_default_region"
:maxlength="200" placeholder="ap-south-1" />
</b-field>
<b-field label="Bucket">
<b-input v-model="form['upload.s3.bucket']"
name="upload.s3.bucket" :maxlength="200" placeholder="" />
</b-field>
<b-field label="Bucket path"
message="Path inside the bucket to upload files. Default is /">
<b-input v-model="form['upload.s3.bucket']"
name="upload.s3.bucket_path" :maxlength="200" placeholder="/" />
</b-field>
<b-field label="Bucket type">
<b-select v-model="form['upload.s3.bucket_type']"
name="upload.s3.bucket_type">
<option value="private">private</option>
<option value="public">public</option>
</b-select>
</b-field>
<b-field label="Upload expiry"
message="(Optional) Specify TTL (in seconds) for the generated presigned URL.
Only applicable for private buckets
(s, m, h, d for seconds, minutes, hours, days).">
<b-input v-model="form['upload.s3.expiry']"
name="upload.s3.expiry"
placeholder="14d" :pattern="regDuration" :maxlength="10" />
</b-field>
</div><!-- s3 -->
</div>
</b-tab-item><!-- media -->
<b-tab-item label="SMTP">
<div class="items mail-servers">
<div class="block box" v-for="(item, n) in form.smtp" :key="n">
<div class="columns">
<div class="column is-2">
<b-field label="Enabled">
<b-switch v-model="item.enabled" name="enabled"
:native-value="true" />
</b-field>
<b-field v-if="form.smtp.length > 1">
<a @click.prevent="$utils.confirm(null, () => removeSMTP(n))"
href="#" class="is-size-7">
<b-icon icon="trash-can-outline" size="is-small" /> Delete
</a>
</b-field>
</div><!-- first column -->
<div class="column" :class="{'disabled': !item.enabled}">
<div class="columns">
<div class="column is-8">
<b-field label="Host"
message="SMTP server's host address.">
<b-input v-model="item.host" name="host"
placeholder='smtp.yourmailserver.net' :maxlength="200" />
</b-field>
</div>
<div class="column">
<b-field label="Port"
message="SMTP server's port.">
<b-numberinput v-model="item.port" name="port" type="is-light"
controls-position="compact"
placeholder="25" min="1" max="65535" />
</b-field>
</div>
</div><!-- host -->
<div class="columns">
<div class="column is-2">
<b-field label="Auth protocol">
<b-select v-model="item.auth_protocol" name="auth_protocol">
<option value="none">none</option>
<option value="cram">cram</option>
<option value="plain">plain</option>
<option value="login">login</option>
</b-select>
</b-field>
</div>
<div class="column">
<b-field grouped>
<b-field label="Username" expanded>
<b-input v-model="item.username"
:disabled="item.auth_protocol === 'none'"
name="username" placeholder="mysmtp" :maxlength="200" />
</b-field>
<b-field label="Password" expanded
message="Enter a value to change. Otherwise, leave empty.">
<b-input v-model="item.password"
:disabled="item.auth_protocol === 'none'"
name="password" type="password" placeholder="Enter to change"
:maxlength="200" />
</b-field>
</b-field>
</div>
</div><!-- auth -->
<hr />
<div class="columns">
<div class="column is-6">
<b-field label="HELO hostname"
message="Optional. Some SMTP servers require a FQDN in the hostname.
By default, HELLOs go with 'localhost'. Set this if a custom
hostname should be used.">
<b-input v-model="item.hello_hostname"
name="hello_hostname" placeholder="" :maxlength="200" />
</b-field>
</div>
<div class="column">
<b-field grouped>
<b-field label="TLS" expanded
message="Enable STARTTLS.">
<b-switch v-model="item.tls_enabled" name="item.tls_enabled" />
</b-field>
<b-field label="Skip TLS verification" expanded
message="Skip hostname check on the TLS certificate.">
<b-switch v-model="item.tls_skip_verify"
:disabled="!item.tls_enabled" name="item.tls_skip_verify" />
</b-field>
</b-field>
</div>
</div><!-- TLS -->
<hr />
<div class="columns">
<div class="column is-3">
<b-field label="Max. connections"
message="Maximum concurrent connections to the SMTP server.">
<b-numberinput v-model="item.max_conns" name="max_conns" type="is-light"
controls-position="compact"
placeholder="25" min="1" max="65535" />
</b-field>
</div>
<div class="column is-3">
<b-field label="Retries"
message="The number of times a message should be retried
if sending fails.">
<b-numberinput v-model="item.max_msg_retries" name="max_msg_retries"
type="is-light"
controls-position="compact"
placeholder="2" min="1" max="1000" />
</b-field>
</div>
<div class="column is-3">
<b-field label="Idle timeout"
message="Time to wait for new activity on a connection before closing
it and removing it from the pool (s for second, m for minute).">
<b-input v-model="item.idle_timeout" name="idle_timeout"
placeholder="15s" :pattern="regDuration" :maxlength="10" />
</b-field>
</div>
<div class="column is-3">
<b-field label="Wait timeout"
message="Time to wait for new activity on a connection before closing
it and removing it from the pool (s for second, m for minute).">
<b-input v-model="item.wait_timeout" name="wait_timeout"
placeholder="5s" :pattern="regDuration" :maxlength="10" />
</b-field>
</div>
</div>
</div>
</div><!-- second container column -->
</div><!-- block -->
</div><!-- mail-servers -->
<b-button @click="addSMTP" icon-left="plus" type="is-primary">Add new</b-button>
</b-tab-item><!-- mail servers -->
</b-tabs>
</form>
</section>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import store from '../store';
import { models } from '../constants';
export default Vue.extend({
data() {
return {
regDuration: '[0-9]+(ms|s|m|h)',
isLoading: true,
// formCopy is a stringified copy of the original settings against which
// form is compared to detect changes.
formCopy: '',
form: {},
};
},
methods: {
addSMTP() {
const [data] = JSON.parse(JSON.stringify(this.form.smtp.slice(-1)));
this.form.smtp.push(data);
},
removeSMTP(i) {
this.form.smtp.splice(i, 1);
},
onSubmit() {
this.isLoading = true;
this.$api.updateSettings(this.form).then((data) => {
if (data.needsRestart) {
// Update the 'needsRestart' flag on the global serverConfig state
// as there are running campaigns and the app couldn't auto-restart.
store.commit('setModelResponse',
{ model: models.serverConfig, data: { ...this.serverConfig, needsRestart: true } });
this.getSettings();
return;
}
this.$utils.toast('Settings saved. Reloading app ...');
// Poll until there's a 200 response, waiting for the app
// to restart and come back up.
const pollId = setInterval(() => {
this.$api.getHealth().then(() => {
clearInterval(pollId);
this.getSettings();
});
}, 500);
}, () => {
this.isLoading = false;
});
},
getSettings() {
this.$api.getSettings().then((data) => {
this.form = data;
this.formCopy = JSON.stringify(data);
this.isLoading = false;
});
},
},
computed: {
...mapState(['serverConfig', 'loading']),
hasFormChanged() {
if (!this.formCopy) {
return false;
}
return JSON.stringify(this.form) !== this.formCopy;
},
},
beforeRouteLeave(to, from, next) {
if (this.hasFormChanged) {
this.$utils.confirm('Discard changes?', () => next(true));
return;
}
next(true);
},
mounted() {
this.getSettings();
},
});
</script>

6
go.mod
View file

@ -1,4 +1,5 @@
module github.com/knadh/listmonk
go 1.13
require (
@ -7,12 +8,13 @@ require (
github.com/jaytaylor/html2text v0.0.0-20200220170450-61d9dc4d7195
github.com/jmoiron/sqlx v1.2.0
github.com/knadh/goyesql/v2 v2.1.1
github.com/knadh/koanf v0.8.1
github.com/knadh/koanf v0.12.0
github.com/knadh/smtppool v0.2.0
github.com/knadh/stuffbin v1.1.0
github.com/labstack/echo v3.3.10+incompatible
github.com/labstack/gommon v0.3.0 // indirect
github.com/lib/pq v1.3.0
github.com/nats-io/nats-server/v2 v2.1.7 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/olekukonko/tablewriter v0.0.4 // indirect
github.com/rhnvrm/simples3 v0.5.0
@ -21,4 +23,4 @@ require (
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b
jaytaylor.com/html2text v0.0.0-20200220170450-61d9dc4d7195
)
)

45
go.sum
View file

@ -8,6 +8,8 @@ github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44am
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwnTLB6vQiq+o=
@ -15,6 +17,14 @@ github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaL
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jaytaylor/html2text v0.0.0-20200220170450-61d9dc4d7195 h1:j0UEFmS7wSjAwKEIkgKBn8PRDfjcuggzr93R9wk53nQ=
@ -29,6 +39,8 @@ github.com/knadh/goyesql/v2 v2.1.1 h1:Orp5ldaxPM4ozKHfu1m7p6iolJFXDGOpF3/jyOgO6l
github.com/knadh/goyesql/v2 v2.1.1/go.mod h1:pMzCA130/ZhEIoMmSmbEFXor3A2dxl5L+JllAc/l64s=
github.com/knadh/koanf v0.8.1 h1:4VLACWqrkWRQIup3ooq6lOnaSbOJSNO+YVXnJn/NPZ8=
github.com/knadh/koanf v0.8.1/go.mod h1:kVvmDbXnBtW49Czi4c1M+nnOWF0YSNZ8BaKvE/bCO1w=
github.com/knadh/koanf v0.12.0 h1:xQo0Y43CbzOix0tTeE+plIcfs1pTuaUI1/SsvDl2ROI=
github.com/knadh/koanf v0.12.0/go.mod h1:31bzRSM7vS5Vm9LNLo7B2Re1zhLOZT6EQKeodixBikE=
github.com/knadh/smtppool v0.1.1 h1:pSi1Gc5TXOaN/Z/YiqfZbk/vd9dqzXzAfQiss0QSGQU=
github.com/knadh/smtppool v0.1.1/go.mod h1:3DJHouXAgPDBz0kC50HukOsdapYSwIEfJGwuip46oCA=
github.com/knadh/smtppool v0.2.0 h1:+llTWRljNIVg05MMu9TiefELTNwblexjsd1ALAPXZUs=
@ -59,12 +71,27 @@ github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.2.2 h1:dxe5oCinTXiTIcfgmZecdCzPmAJKd46KsCWc35r0TV4=
github.com/mitchellh/mapstructure v1.2.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/nats-io/jwt v0.3.2 h1:+RB5hMpXUUA2dfxuhBTEkMOrYmM+gKIZYS1KjSostMI=
github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
github.com/nats-io/nats-server v1.4.1 h1:Ul1oSOGNV/L8kjr4v6l2f9Yet6WY+LevH1/7cRZ/qyA=
github.com/nats-io/nats-server/v2 v2.1.7 h1:jCoQwDvRYJy3OpOTHeYfvIPLP46BMeDmH7XEJg/r42I=
github.com/nats-io/nats-server/v2 v2.1.7/go.mod h1:rbRrRE/Iv93O/rUvZ9dh4NfT0Cm9HWjW/BqOWLGgYiE=
github.com/nats-io/nats.go v1.10.0/go.mod h1:AjGArbfyR50+afOUotNX2Xs5SYHf+CoOa5HH1eEl2HE=
github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nkeys v0.1.4 h1:aEsHIssIk6ETN5m2/MD8Y4B2X7FfXrBAUdkyRvbVYzA=
github.com/nats-io/nkeys v0.1.4/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=
github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA=
github.com/pelletier/go-toml v1.4.0 h1:u3Z1r+oOXJIkxqw34zVhyPgjBsm6X2wn21NWs/HfSeg=
github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo=
github.com/pelletier/go-toml v1.7.0 h1:7utD74fnzVc/cpcyy8sjrlFr5vYpypUixARcHIMIGuI=
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rhnvrm/simples3 v0.5.0 h1:X+WX0hqoKScdoJAw/G3GArfZ6Ygsn8q+6MdocTMKXOw=
@ -86,22 +113,39 @@ github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8W
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 h1:3zb4D3T4G8jdExgVU/95+vQXfpEPiMdCaZgmGVxjNHM=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24 h1:R8bzl0244nw47n1xKs1MUMAaTNgjavKcN/aX2Ss3+Fo=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d h1:nc5K6ox/4lTFbMVSL9WRR81ixkcwXThoiF6yf+R9scA=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
@ -110,5 +154,6 @@ gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b h1:P+3+n9hUbqSD
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b/go.mod h1:0LRKfykySnChgQpG3Qpk+bkZFWazQ+MMfc5oldQCwnY=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
jaytaylor.com/html2text v0.0.0-20200220170450-61d9dc4d7195 h1:tj3Wzc08ekoAl8zEsLhT+5EmZ9TE/qpTTTi4oZjOPMw=
jaytaylor.com/html2text v0.0.0-20200220170450-61d9dc4d7195/go.mod h1:OxvTsCwKosqQ1q7B+8FwXqg4rKZ/UG9dUW+g/VL2xH4=

View file

@ -37,10 +37,15 @@ var reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[
// registerHandlers registers HTTP handlers.
func registerHTTPHandlers(e *echo.Echo) {
e.GET("/", handleIndexPage)
e.GET("/api/health", handleHealthCheck)
e.GET("/api/config.js", handleGetConfigScript)
e.GET("/api/dashboard/charts", handleGetDashboardCharts)
e.GET("/api/dashboard/counts", handleGetDashboardCounts)
e.GET("/api/settings", handleGetSettings)
e.PUT("/api/settings", handleUpdateSettings)
e.POST("/api/admin/reload", handleReloadApp)
e.GET("/api/subscribers/:id", handleGetSubscriber)
e.GET("/api/subscribers/:id/export", handleExportSubscriberData)
e.POST("/api/subscribers", handleCreateSubscriber)
@ -140,6 +145,11 @@ func handleIndexPage(c echo.Context) error {
return c.String(http.StatusOK, string(b))
}
// handleHealthCheck is a healthcheck endpoint that returns a 200 response.
func handleHealthCheck(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true})
}
// validateUUID middleware validates the UUID string format for a given set of params.
func validateUUID(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
return func(c echo.Context) error {

207
init.go
View file

@ -1,17 +1,25 @@
package main
import (
"encoding/json"
"fmt"
"html/template"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/types"
"github.com/knadh/goyesql/v2"
goyesqlx "github.com/knadh/goyesql/v2/sqlx"
"github.com/knadh/koanf"
"github.com/knadh/koanf/maps"
"github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/listmonk/internal/manager"
"github.com/knadh/listmonk/internal/media"
"github.com/knadh/listmonk/internal/media/providers/filesystem"
@ -20,12 +28,74 @@ import (
"github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/stuffbin"
"github.com/labstack/echo"
flag "github.com/spf13/pflag"
)
const (
queryFilePath = "queries.sql"
)
// constants contains static, constant config values required by the app.
type constants struct {
RootURL string `koanf:"root"`
LogoURL string `koanf:"logo_url"`
FaviconURL string `koanf:"favicon_url"`
FromEmail string `koanf:"from_email"`
NotifyEmails []string `koanf:"notify_emails"`
Privacy struct {
AllowBlacklist bool `koanf:"allow_blacklist"`
AllowExport bool `koanf:"allow_export"`
AllowWipe bool `koanf:"allow_wipe"`
Exportable map[string]bool `koanf:"-"`
} `koanf:"privacy"`
UnsubURL string
LinkTrackURL string
ViewTrackURL string
OptinURL string
MessageURL string
MediaProvider string
}
func initFlags() {
f := flag.NewFlagSet("config", flag.ContinueOnError)
f.Usage = func() {
// Register --help handler.
fmt.Println(f.FlagUsages())
os.Exit(0)
}
// Register the commandline flags.
f.StringSlice("config", []string{"config.toml"},
"path to one or more config files (will be merged in order)")
f.Bool("install", false, "run first time installation")
f.Bool("version", false, "current version of the build")
f.Bool("new-config", false, "generate sample config file")
f.String("static-dir", "", "(optional) path to directory with static files")
f.Bool("yes", false, "assume 'yes' to prompts, eg: during --install")
if err := f.Parse(os.Args[1:]); err != nil {
lo.Fatalf("error loading flags: %v", err)
}
if err := ko.Load(posflag.Provider(f, ".", ko), nil); err != nil {
lo.Fatalf("error loading config: %v", err)
}
}
// initConfigFiles loads the given config files into the koanf instance.
func initConfigFiles(files []string, ko *koanf.Koanf) {
for _, f := range files {
lo.Printf("reading config: %s", f)
if err := ko.Load(file.Provider(f), toml.Parser()); err != nil {
if os.IsNotExist(err) {
lo.Fatal("config file not found. If there isn't one yet, run --new-config to generate one.")
}
lo.Fatalf("error loadng config from file: %v.", err)
}
}
}
// initFileSystem initializes the stuffbin FileSystem to provide
// access to bunded static assets to the app.
func initFS(staticDir string) stuffbin.FileSystem {
@ -87,7 +157,6 @@ func initFS(staticDir string) stuffbin.FileSystem {
// initDB initializes the main DB connection pool and parse and loads the app's
// SQL queries into a prepared query map.
func initDB() *sqlx.DB {
var dbCfg dbConf
if err := ko.Unmarshal("db", &dbCfg); err != nil {
lo.Fatalf("error loading db config: %v", err)
@ -98,7 +167,6 @@ func initDB() *sqlx.DB {
if err != nil {
lo.Fatalf("error connecting to DB: %v", err)
}
return db
}
@ -127,27 +195,22 @@ func initQueries(sqlFile string, db *sqlx.DB, fs stuffbin.FileSystem, prepareQue
return qMap, &q
}
// constants contains static, constant config values required by the app.
type constants struct {
RootURL string `koanf:"root"`
LogoURL string `koanf:"logo_url"`
FaviconURL string `koanf:"favicon_url"`
FromEmail string `koanf:"from_email"`
NotifyEmails []string `koanf:"notify_emails"`
Privacy struct {
AllowBlacklist bool `koanf:"allow_blacklist"`
AllowExport bool `koanf:"allow_export"`
AllowWipe bool `koanf:"allow_wipe"`
Exportable map[string]bool `koanf:"-"`
} `koanf:"privacy"`
// initSettings loads settings from the DB.
func initSettings(q *Queries) {
var s types.JSONText
if err := q.GetSettings.Get(&s); err != nil {
lo.Fatalf("error reading settings from DB: %s", pqErrMsg(err))
}
UnsubURL string
LinkTrackURL string
ViewTrackURL string
OptinURL string
MessageURL string
MediaProvider string
// Setting keys are dot separated, eg: app.favicon_url. Unflatten them into
// nested maps {app: {favicon_url}}.
var out map[string]interface{}
if err := json.Unmarshal(s, &out); err != nil {
lo.Fatalf("error unmarshalling settings from DB: %v", err)
}
if err := ko.Load(confmap.Provider(out, "."), nil); err != nil {
lo.Fatalf("error parsing settings from DB: %v", err)
}
}
func initConstants() *constants {
@ -159,6 +222,7 @@ func initConstants() *constants {
if err := ko.Unmarshal("privacy", &c.Privacy); err != nil {
lo.Fatalf("error loading app config: %v", err)
}
c.RootURL = strings.TrimRight(c.RootURL, "/")
c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable"))
c.MediaProvider = ko.String("upload.provider")
@ -227,31 +291,35 @@ func initImporter(q *Queries, db *sqlx.DB, app *App) *subimporter.Importer {
func initMessengers(m *manager.Manager) messenger.Messenger {
var (
mapKeys = ko.MapKeys("smtp")
srv = make([]messenger.Server, 0, len(mapKeys))
servers = make([]messenger.Server, 0, len(mapKeys))
)
items := ko.Slices("smtp")
if len(items) == 0 {
lo.Fatalf("no SMTP servers found in config")
}
// Load the default SMTP messengers.
for _, name := range mapKeys {
if !ko.Bool(fmt.Sprintf("smtp.%s.enabled", name)) {
lo.Printf("skipped SMTP: %s", name)
for _, item := range items {
if !item.Bool("enabled") {
continue
}
// Read the SMTP config.
s := messenger.Server{Name: name}
if err := ko.UnmarshalWithConf("smtp."+name, &s, koanf.UnmarshalConf{Tag: "json"}); err != nil {
var s messenger.Server
if err := item.UnmarshalWithConf("", &s, koanf.UnmarshalConf{Tag: "json"}); err != nil {
lo.Fatalf("error loading SMTP: %v", err)
}
srv = append(srv, s)
lo.Printf("loaded SMTP: %s (%s@%s)", s.Name, s.Username, s.Host)
servers = append(servers, s)
lo.Printf("loaded SMTP: %s@%s", item.String("username"), item.String("host"))
}
if len(srv) == 0 {
lo.Fatalf("no SMTP servers found in config")
if len(servers) == 0 {
lo.Fatalf("no SMTP servers enabled in settings")
}
// Initialize the default e-mail messenger.
msgr, err := messenger.NewEmailer(srv...)
msgr, err := messenger.NewEmailer(servers...)
if err != nil {
lo.Fatalf("error loading e-mail messenger: %v", err)
}
@ -266,28 +334,31 @@ func initMessengers(m *manager.Manager) messenger.Messenger {
func initMediaStore() media.Store {
switch provider := ko.String("upload.provider"); provider {
case "s3":
var opts s3.Opts
ko.Unmarshal("upload.s3", &opts)
uplder, err := s3.NewS3Store(opts)
var o s3.Opts
ko.Unmarshal("upload.s3", &o)
up, err := s3.NewS3Store(o)
if err != nil {
lo.Fatalf("error initializing s3 upload provider %s", err)
}
return uplder
lo.Println("media upload provider: s3")
return up
case "filesystem":
var opts filesystem.Opts
ko.Unmarshal("upload.filesystem", &opts)
opts.RootURL = ko.String("app.root")
opts.UploadPath = filepath.Clean(opts.UploadPath)
opts.UploadURI = filepath.Clean(opts.UploadURI)
uplder, err := filesystem.NewDiskStore(opts)
var o filesystem.Opts
ko.Unmarshal("upload.filesystem", &o)
o.RootURL = ko.String("app.root")
o.UploadPath = filepath.Clean(o.UploadPath)
o.UploadURI = filepath.Clean(o.UploadURI)
up, err := filesystem.NewDiskStore(o)
if err != nil {
lo.Fatalf("error initializing filesystem upload provider %s", err)
}
return uplder
lo.Println("media upload provider: filesystem")
return up
default:
lo.Fatalf("unknown provider. please select one of either filesystem or s3")
lo.Fatalf("unknown provider. select filesystem or s3")
}
return nil
}
@ -312,7 +383,7 @@ func initNotifTemplates(path string, fs stuffbin.FileSystem, cs *constants) *tem
}
// initHTTPServer sets up and runs the app's main HTTP server and blocks forever.
func initHTTPServer(app *App) {
func initHTTPServer(app *App) *echo.Echo {
// Initialize the HTTP server.
var srv = echo.New()
srv.HideBanner = true
@ -349,5 +420,47 @@ func initHTTPServer(app *App) {
registerHTTPHandlers(srv)
// Start the server.
srv.Logger.Fatal(srv.Start(ko.String("app.address")))
go func() {
if err := srv.Start(ko.String("app.address")); err != nil {
if strings.Contains(err.Error(), "Server closed") {
lo.Println("HTTP server shut down")
} else {
lo.Fatalf("error starting HTTP server: %v", err)
}
}
}()
return srv
}
func awaitReload(sigChan chan os.Signal, closerWait chan bool, closer func()) chan bool {
// The blocking signal handler that main() waits on.
out := make(chan bool)
// Respawn a new process and exit the running one.
respawn := func() {
if err := syscall.Exec(os.Args[0], os.Args, os.Environ()); err != nil {
lo.Fatalf("error spawning process: %v", err)
}
os.Exit(0)
}
// Listen for reload signal.
go func() {
for range sigChan {
lo.Println("reloading on signal ...")
go closer()
select {
case <-closerWait:
// Wait for the closer to finish.
respawn()
case <-time.After(time.Second * 3):
// Or timeout and force close.
respawn()
}
}
}()
return out
}

View file

@ -184,6 +184,13 @@ func (m *Manager) HasMessenger(id string) bool {
return ok
}
// HasRunningCampaigns checks if there are any active campaigns.
func (m *Manager) HasRunningCampaigns() bool {
m.campsMutex.Lock()
defer m.campsMutex.Unlock()
return len(m.camps) > 0
}
// Run is a blocking function (that should be invoked as a goroutine)
// that scans the data source at regular intervals for pending campaigns,
// and queues them for processing. The process queue fetches batches of
@ -230,7 +237,11 @@ func (m *Manager) messageWorker() {
for {
select {
// Campaign message.
case msg := <-m.campMsgQueue:
case msg, ok := <-m.campMsgQueue:
if !ok {
return
}
// Pause on hitting the message rate.
if numMsg >= m.cfg.MessageRate {
time.Sleep(time.Second)
@ -250,7 +261,10 @@ func (m *Manager) messageWorker() {
}
// Arbitrary message.
case msg := <-m.msgQueue:
case msg, ok := <-m.msgQueue:
if !ok {
return
}
err := m.messengers[msg.Messenger].Push(
msg.From, msg.To, msg.Subject, msg.Body, nil)
if err != nil {
@ -291,6 +305,13 @@ func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap {
}
}
// Close closes and exits the campaign manager.
func (m *Manager) Close() {
close(m.subFetchQueue)
close(m.campMsgErrorQueue)
close(m.msgQueue)
}
// scanCampaigns is a blocking function that periodically scans the data source
// for campaigns to process and dispatches them to the manager.
func (m *Manager) scanCampaigns(tick time.Duration) {
@ -323,7 +344,10 @@ func (m *Manager) scanCampaigns(tick time.Duration) {
// Aggregate errors from sending messages to check against the error threshold
// after which a campaign is paused.
case e := <-m.campMsgErrorQueue:
case e, ok := <-m.campMsgErrorQueue:
if !ok {
return
}
if m.cfg.MaxSendErrors < 1 {
continue
}

View file

@ -15,14 +15,14 @@ const amznS3PublicURL = "https://%s.s3.%s.amazonaws.com%s"
// Opts represents AWS S3 specific params
type Opts struct {
AccessKey string `koanf:"aws_access_key_id"`
SecretKey string `koanf:"aws_secret_access_key"`
Region string `koanf:"aws_default_region"`
Bucket string `koanf:"bucket"`
BucketPath string `koanf:"bucket_path"`
BucketURL string `koanf:"bucket_url"`
BucketType string `koanf:"bucket_type"`
Expiry int `koanf:"expiry"`
AccessKey string `koanf:"aws_access_key_id"`
SecretKey string `koanf:"aws_secret_access_key"`
Region string `koanf:"aws_default_region"`
Bucket string `koanf:"bucket"`
BucketPath string `koanf:"bucket_path"`
BucketURL string `koanf:"bucket_url"`
BucketType string `koanf:"bucket_type"`
Expiry time.Duration `koanf:"expiry"`
}
// Client implements `media.Store` for S3 provider
@ -83,7 +83,7 @@ func (c *Client) Get(name string) string {
ObjectKey: makeBucketPath(c.opts.BucketPath, name),
Method: "GET",
Timestamp: time.Now(),
ExpirySeconds: c.opts.Expiry,
ExpirySeconds: int(c.opts.Expiry.Seconds()),
})
return url
}

View file

@ -15,7 +15,6 @@ const emName = "email"
// Server represents an SMTP server's credentials.
type Server struct {
Name string
Username string `json:"username"`
Password string `json:"password"`
AuthProtocol string `json:"auth_protocol"`
@ -33,16 +32,14 @@ type Server struct {
// Emailer is the SMTP e-mail messenger.
type Emailer struct {
servers map[string]*Server
serverNames []string
numServers int
servers []*Server
}
// NewEmailer creates and returns an e-mail Messenger backend.
// It takes multiple SMTP configurations.
func NewEmailer(servers ...Server) (*Emailer, error) {
e := &Emailer{
servers: make(map[string]*Server),
servers: make([]*Server, 0, len(servers)),
}
for _, srv := range servers {
@ -77,11 +74,9 @@ func NewEmailer(servers ...Server) (*Emailer, error) {
}
s.pool = pool
e.servers[s.Name] = &s
e.serverNames = append(e.serverNames, s.Name)
e.servers = append(e.servers, &s)
}
e.numServers = len(e.serverNames)
return e, nil
}
@ -92,14 +87,16 @@ func (e *Emailer) Name() string {
// Push pushes a message to the server.
func (e *Emailer) Push(fromAddr string, toAddr []string, subject string, m []byte, atts []Attachment) error {
var key string
// If there are more than one SMTP servers, send to a random
// one from the list.
if e.numServers > 1 {
key = e.serverNames[rand.Intn(e.numServers)]
var (
ln = len(e.servers)
srv *Server
)
if ln > 1 {
srv = e.servers[rand.Intn(ln)]
} else {
key = e.serverNames[0]
srv = e.servers[0]
}
// Are there attachments?
@ -122,7 +119,6 @@ func (e *Emailer) Push(fromAddr string, toAddr []string, subject string, m []byt
return err
}
srv := e.servers[key]
em := smtppool.Email{
From: fromAddr,
To: toAddr,
@ -155,3 +151,11 @@ func (e *Emailer) Push(fromAddr string, toAddr []string, subject string, m []byt
func (e *Emailer) Flush() error {
return nil
}
// Close closes the SMTP pools.
func (e *Emailer) Close() error {
for _, s := range e.servers {
s.pool.Close()
}
return nil
}

View file

@ -8,6 +8,7 @@ type Messenger interface {
Name() string
Push(fromAddr string, toAddr []string, subject string, message []byte, atts []Attachment) error
Flush() error
Close() error
}
// Attachment represents a file or blob attachment that can be

129
main.go
View file

@ -1,25 +1,25 @@
package main
import (
"context"
"fmt"
"html/template"
"log"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf"
"github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/listmonk/internal/manager"
"github.com/knadh/listmonk/internal/media"
"github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/stuffbin"
flag "github.com/spf13/pflag"
)
// App contains the "global" components that are
@ -35,47 +35,38 @@ type App struct {
media media.Store
notifTpls *template.Template
log *log.Logger
// Channel for passing reload signals.
sigChan chan os.Signal
// Global variable that stores the state indicating that a restart is required
// after a settings update.
needsRestart bool
sync.Mutex
}
var (
// Global logger.
lo = log.New(os.Stdout, "", log.Ldate|log.Ltime|log.Lshortfile)
// Global configuration reader.
ko = koanf.New(".")
fs stuffbin.FileSystem
db *sqlx.DB
queries *Queries
buildString string
)
func init() {
f := flag.NewFlagSet("config", flag.ContinueOnError)
f.Usage = func() {
// Register --help handler.
fmt.Println(f.FlagUsages())
os.Exit(0)
}
// Register the commandline flags.
f.StringSlice("config", []string{"config.toml"},
"path to one or more config files (will be merged in order)")
f.Bool("install", false, "run first time installation")
f.Bool("version", false, "current version of the build")
f.Bool("new-config", false, "generate sample config file")
f.String("static-dir", "", "(optional) path to directory with static files")
f.Bool("yes", false, "assume 'yes' to prompts, eg: during --install")
if err := f.Parse(os.Args[1:]); err != nil {
lo.Fatalf("error loading flags: %v", err)
}
initFlags()
// Display version.
if v, _ := f.GetBool("version"); v {
if ko.Bool("version") {
fmt.Println(buildString)
os.Exit(0)
}
// Generate new config.
if ok, _ := f.GetBool("new-config"); ok {
if ko.Bool("new-config") {
if err := newConfigFile(); err != nil {
lo.Println(err)
os.Exit(1)
@ -84,38 +75,12 @@ func init() {
os.Exit(0)
}
// Load config files.
cFiles, _ := f.GetStringSlice("config")
for _, f := range cFiles {
lo.Printf("reading config: %s", f)
if err := ko.Load(file.Provider(f), toml.Parser()); err != nil {
if os.IsNotExist(err) {
lo.Fatal("config file not found. If there isn't one yet, run --new-config to generate one.")
}
lo.Fatalf("error loadng config from file: %v.", err)
}
}
// Load config files to pick up the database settings first.
initConfigFiles(ko.Strings("config"), ko)
// Load environment variables and merge into the loaded config.
if err := ko.Load(env.Provider("LISTMONK_", ".", func(s string) string {
return strings.Replace(strings.ToLower(
strings.TrimPrefix(s, "LISTMONK_")), "__", ".", -1)
}), nil); err != nil {
lo.Fatalf("error loading config from env: %v", err)
}
if err := ko.Load(posflag.Provider(f, ".", ko), nil); err != nil {
lo.Fatalf("error loading config: %v", err)
}
}
func main() {
// Initialize the DB and the filesystem that are required by the installer
// and the app.
var (
fs = initFS(ko.String("static-dir"))
db = initDB()
)
defer db.Close()
// Connect to the database, load the filesystem to read SQL queries.
db = initDB()
fs = initFS(ko.String("static-dir"))
// Installer mode? This runs before the SQL queries are loaded and prepared
// as the installer needs to work on an empty DB.
@ -124,6 +89,22 @@ func main() {
return
}
// Load the SQL queries from the filesystem.
_, queries := initQueries(queryFilePath, db, fs, true)
// Load settings from DB.
initSettings(queries)
// Load environment variables and merge into the loaded config.
if err := ko.Load(env.Provider("LISTMONK_", ".", func(s string) string {
return strings.Replace(strings.ToLower(
strings.TrimPrefix(s, "LISTMONK_")), "__", ".", -1)
}), nil); err != nil {
lo.Fatalf("error loading config from env: %v", err)
}
}
func main() {
// Initialize the main app controller that wraps all of the app's
// components. This is passed around HTTP handlers.
app := &App{
@ -143,6 +124,32 @@ func main() {
// messages) get processed at the specified interval.
go app.manager.Run(time.Second * 5)
// Start and run the app server.
initHTTPServer(app)
// Start the app server.
srv := initHTTPServer(app)
// Wait for the reload signal with a callback to gracefully shut down resources.
// The `wait` channel is passed to awaitReload to wait for the callback to finish
// within N seconds, or do a force reload.
app.sigChan = make(chan os.Signal)
signal.Notify(app.sigChan, syscall.SIGHUP)
closerWait := make(chan bool)
<-awaitReload(app.sigChan, closerWait, func() {
// Stop the HTTP server.
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
srv.Shutdown(ctx)
// Close the campaign manager.
app.manager.Close()
// Close the DB pool.
app.db.DB.Close()
// Close the messenger pool.
app.messenger.Close()
// Signal the close.
closerWait <- true
})
}

View file

@ -76,6 +76,9 @@ type Queries struct {
CreateLink *sqlx.Stmt `query:"create-link"`
RegisterLinkClick *sqlx.Stmt `query:"register-link-click"`
GetSettings *sqlx.Stmt `query:"get-settings"`
UpdateSettings *sqlx.Stmt `query:"update-settings"`
// GetStats *sqlx.Stmt `query:"get-stats"`
}

View file

@ -724,3 +724,14 @@ SELECT JSON_BUILD_OBJECT('subscribers', JSON_BUILD_OBJECT(
)
),
'messages', (SELECT SUM(sent) AS messages FROM campaigns));
-- name: get-settings
SELECT JSON_OBJECT_AGG(key, value) AS settings
FROM (
SELECT * FROM settings ORDER BY key
) t;
-- name: update-settings
UPDATE settings AS s SET value = c.value
-- For each key in the incoming JSON map, update the row with the key and it's value.
FROM(SELECT * FROM JSONB_EACH($1)) AS c(key, value) WHERE s.key = c.key;

View file

@ -155,3 +155,40 @@ CREATE TABLE link_clicks (
DROP INDEX IF EXISTS idx_clicks_camp_id; CREATE INDEX idx_clicks_camp_id ON link_clicks(campaign_id);
DROP INDEX IF EXISTS idx_clicks_link_id; CREATE INDEX idx_clicks_link_id ON link_clicks(link_id);
DROP INDEX IF EXISTS idx_clicks_sub_id; CREATE INDEX idx_clicks_sub_id ON link_clicks(subscriber_id);
-- settings
DROP TABLE IF EXISTS settings CASCADE;
CREATE TABLE settings (
key TEXT NOT NULL UNIQUE,
value JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
DROP INDEX IF EXISTS idx_settings_key; CREATE INDEX idx_settings_key ON settings(key);
INSERT INTO settings (key, value) VALUES
('app.favicon_url', '""'),
('app.from_email', '"listmonk <noreply@listmonk.yoursite.com>"'),
('app.logo_url', '"http://localhost:9000/public/static/logo.png"'),
('app.concurrency', '10'),
('app.message_rate', '10'),
('app.batch_size', '1000'),
('app.max_send_errors', '1000'),
('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'),
('privacy.allow_blacklist', 'true'),
('privacy.allow_export', 'true'),
('privacy.allow_wipe', 'true'),
('privacy.exportable', '["profile", "subscriptions", "campaign_views", "link_clicks"]'),
('upload.provider', '"filesystem"'),
('upload.filesystem.upload_path', '"uploads"'),
('upload.filesystem.upload_uri', '"/uploads"'),
('upload.s3.aws_access_key_id', '""'),
('upload.s3.aws_secret_access_key', '""'),
('upload.s3.aws_default_region', '"ap-south-b"'),
('upload.s3.bucket', '""'),
('upload.s3.bucket_domain', '""'),
('upload.s3.bucket_path', '"/"'),
('upload.s3.bucket_type', '"public"'),
('upload.s3.expiry', '"14d"'),
('smtp',
'[{"enabled":true, "host":"smtp.yoursite.com","port":25,"auth_protocol":"cram","username":"username","password":"password","hello_hostname":"","max_conns":10,"idle_timeout":"15s","wait_timeout":"5s","max_msg_retries":2,"tls_enabled":true,"tls_skip_verify":false,"email_headers":[]},
{"enabled":false, "host":"smtp2.yoursite.com","port":587,"auth_protocol":"plain","username":"username","password":"password","hello_hostname":"","max_conns":10,"idle_timeout":"15s","wait_timeout":"5s","max_msg_retries":2,"tls_enabled":false,"tls_skip_verify":false,"email_headers":[]}]'),
('messengers', '[]');

179
settings.go Normal file
View file

@ -0,0 +1,179 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"syscall"
"time"
"github.com/jmoiron/sqlx/types"
"github.com/labstack/echo"
)
type settings struct {
AppRootURL string `json:"app.root_url"`
AppLogoURL string `json:"app.logo_url"`
AppFaviconURL string `json:"app.favicon_url"`
AppFromEmail string `json:"app.from_email"`
AppNotifyEmails []string `json:"app.notify_emails"`
AppBatchSize int `json:"app.batch_size"`
AppConcurrency int `json:"app.concurrency"`
AppMaxSendErrors int `json:"app.max_send_errors"`
AppMessageRate int `json:"app.message_rate"`
Messengers []interface{} `json:"messengers"`
PrivacyAllowBlacklist bool `json:"privacy.allow_blacklist"`
PrivacyAllowExport bool `json:"privacy.allow_export"`
PrivacyAllowWipe bool `json:"privacy.allow_wipe"`
PrivacyExportable []string `json:"privacy.exportable"`
SMTP []struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
HelloHostname string `json:"hello_hostname"`
Port int `json:"port"`
AuthProtocol string `json:"auth_protocol"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
EmailHeaders []map[string]string `json:"email_headers"`
MaxConns int `json:"max_conns"`
MaxMsgRetries int `json:"max_msg_retries"`
IdleTimeout string `json:"idle_timeout"`
WaitTimeout string `json:"wait_timeout"`
TLSEnabled bool `json:"tls_enabled"`
TLSSkipVerify bool `json:"tls_skip_verify"`
} `json:"smtp"`
UploadProvider string `json:"upload.provider"`
UploadFilesystemUploadPath string `json:"upload.filesystem.upload_path"`
UploadFilesystemUploadURI string `json:"upload.filesystem.upload_uri"`
UploadS3AwsAccessKeyID string `json:"upload.s3.aws_access_key_id"`
UploadS3AwsDefaultRegion string `json:"upload.s3.aws_default_region"`
UploadS3AwsSecretAccessKey string `json:"upload.s3.aws_secret_access_key,omitempty"`
UploadS3Bucket string `json:"upload.s3.bucket"`
UploadS3BucketDomain string `json:"upload.s3.bucket_domain"`
UploadS3BucketPath string `json:"upload.s3.bucket_path"`
UploadS3BucketType string `json:"upload.s3.bucket_type"`
UploadS3Expiry string `json:"upload.s3.expiry"`
}
// handleGetSettings returns settings from the DB.
func handleGetSettings(c echo.Context) error {
app := c.Get("app").(*App)
s, err := getSettings(app)
if err != nil {
return err
}
// Empty out passwords.
for i := 0; i < len(s.SMTP); i++ {
s.SMTP[i].Password = ""
}
s.UploadS3AwsSecretAccessKey = ""
return c.JSON(http.StatusOK, okResp{s})
}
// handleUpdateSettings returns settings from the DB.
func handleUpdateSettings(c echo.Context) error {
var (
app = c.Get("app").(*App)
set settings
)
// Unmarshal and marshal the fields once to sanitize the settings blob.
if err := c.Bind(&set); err != nil {
return err
}
// Get the existing settings.
cur, err := getSettings(app)
if err != nil {
return err
}
// There should be at least one SMTP block that's enabled.
has := false
for i, s := range set.SMTP {
if s.Enabled {
has = true
}
// If there's no password coming in from the frontend, attempt to get the
// last saved password for the SMTP block at the same position.
if set.SMTP[i].Password == "" {
if len(cur.SMTP) > i &&
set.SMTP[i].Host == cur.SMTP[i].Host &&
set.SMTP[i].Username == cur.SMTP[i].Username {
set.SMTP[i].Password = cur.SMTP[i].Password
}
}
}
if !has {
return echo.NewHTTPError(http.StatusBadRequest,
"At least one SMTP block should be enabled")
}
// S3 password?
if set.UploadS3AwsSecretAccessKey == "" {
set.UploadS3AwsSecretAccessKey = cur.UploadS3AwsSecretAccessKey
}
// Marshal settings.
b, err := json.Marshal(set)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error encoding settings: %v", err))
}
// Update the settings in the DB.
if _, err := app.queries.UpdateSettings.Exec(b); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error updating settings: %s", pqErrMsg(err)))
}
// If there are any active campaigns, don't do an auto reload and
// warn the user on the frontend.
if app.manager.HasRunningCampaigns() {
app.Lock()
app.needsRestart = true
app.Unlock()
return c.JSON(http.StatusOK, okResp{struct {
NeedsRestart bool `json:"needs_restart"`
}{true}})
}
// No running campaigns. Reload the app.
go func() {
<-time.After(time.Millisecond * 500)
app.sigChan <- syscall.SIGHUP
}()
return c.JSON(http.StatusOK, okResp{true})
}
func getSettings(app *App) (settings, error) {
var (
b types.JSONText
out settings
)
if err := app.queries.GetSettings.Get(&b); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching settings: %s", pqErrMsg(err)))
}
// Unmarshall the settings and filter out sensitive fields.
if err := json.Unmarshal([]byte(b), &out); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error parsing settings: %v", err))
}
return out, nil
}