mirror of
https://github.com/knadh/listmonk.git
synced 2025-10-23 05:36:20 +08:00
This patch introduces a new `Domain allowlist` input in Settings -> Privacy UI as a new tab alongside domain `Domain blocklist`. If any domains are entered here, then only subscriptions/imports/additions of e-mails from those particular domains are accepted. blocklist is mutually exclusive with allowlist when there are values in the allowlist.
362 lines
11 KiB
Go
362 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/gdgvda/cron"
|
|
"github.com/gofrs/uuid/v5"
|
|
"github.com/jmoiron/sqlx/types"
|
|
"github.com/knadh/koanf/parsers/json"
|
|
"github.com/knadh/koanf/providers/rawbytes"
|
|
"github.com/knadh/koanf/v2"
|
|
"github.com/knadh/listmonk/internal/messenger/email"
|
|
"github.com/knadh/listmonk/models"
|
|
"github.com/labstack/echo/v4"
|
|
)
|
|
|
|
const pwdMask = "•"
|
|
|
|
type aboutHost struct {
|
|
OS string `json:"os"`
|
|
Machine string `json:"arch"`
|
|
Hostname string `json:"hostname"`
|
|
}
|
|
type aboutSystem struct {
|
|
NumCPU int `json:"num_cpu"`
|
|
AllocMB uint64 `json:"memory_alloc_mb"`
|
|
OSMB uint64 `json:"memory_from_os_mb"`
|
|
}
|
|
type about struct {
|
|
Version string `json:"version"`
|
|
Build string `json:"build"`
|
|
GoVersion string `json:"go_version"`
|
|
GoArch string `json:"go_arch"`
|
|
Database types.JSONText `json:"database"`
|
|
System aboutSystem `json:"system"`
|
|
Host aboutHost `json:"host"`
|
|
}
|
|
|
|
var (
|
|
reAlphaNum = regexp.MustCompile(`[^a-z0-9\-]`)
|
|
)
|
|
|
|
// handleGetSettings returns settings from the DB.
|
|
func handleGetSettings(c echo.Context) error {
|
|
app := c.Get("app").(*App)
|
|
|
|
s, err := app.core.GetSettings()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Empty out passwords.
|
|
for i := range s.SMTP {
|
|
s.SMTP[i].Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SMTP[i].Password))
|
|
}
|
|
for i := range s.BounceBoxes {
|
|
s.BounceBoxes[i].Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BounceBoxes[i].Password))
|
|
}
|
|
for i := range s.Messengers {
|
|
s.Messengers[i].Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.Messengers[i].Password))
|
|
}
|
|
|
|
s.UploadS3AwsSecretAccessKey = strings.Repeat(pwdMask, utf8.RuneCountInString(s.UploadS3AwsSecretAccessKey))
|
|
s.SendgridKey = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SendgridKey))
|
|
s.BouncePostmark.Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BouncePostmark.Password))
|
|
s.BounceForwardEmail.Key = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BounceForwardEmail.Key))
|
|
s.SecurityCaptchaSecret = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SecurityCaptchaSecret))
|
|
s.OIDC.ClientSecret = strings.Repeat(pwdMask, utf8.RuneCountInString(s.OIDC.ClientSecret))
|
|
|
|
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 models.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 := app.core.GetSettings()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Validate and sanitize postback Messenger names along with SMTP names
|
|
// (where each SMTP is also considered as a standalone messenger).
|
|
// Duplicates are disallowed and "email" is a reserved name.
|
|
names := map[string]bool{emailMsgr: true}
|
|
|
|
// There should be at least one SMTP block that's enabled.
|
|
has := false
|
|
for i, s := range set.SMTP {
|
|
if s.Enabled {
|
|
has = true
|
|
}
|
|
|
|
// Sanitize and normalize the SMTP server name.
|
|
name := reAlphaNum.ReplaceAllString(strings.ToLower(strings.TrimSpace(s.Name)), "-")
|
|
if name != "" {
|
|
if !strings.HasPrefix(name, "email-") {
|
|
name = "email-" + name
|
|
}
|
|
|
|
if _, ok := names[name]; ok {
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
app.i18n.Ts("settings.duplicateMessengerName", "name", name))
|
|
}
|
|
|
|
names[name] = true
|
|
}
|
|
set.SMTP[i].Name = name
|
|
|
|
// Assign a UUID. The frontend only sends a password when the user explicitly
|
|
// changes the password. In other cases, the existing password in the DB
|
|
// is copied while updating the settings and the UUID is used to match
|
|
// the incoming array of SMTP blocks with the array in the DB.
|
|
if s.UUID == "" {
|
|
set.SMTP[i].UUID = uuid.Must(uuid.NewV4()).String()
|
|
}
|
|
|
|
// Ensure the HOST is trimmed of any whitespace.
|
|
// This is a common mistake when copy-pasting SMTP settings.
|
|
set.SMTP[i].Host = strings.TrimSpace(s.Host)
|
|
|
|
// If there's no password coming in from the frontend, copy the existing
|
|
// password by matching the UUID.
|
|
if s.Password == "" {
|
|
for _, c := range cur.SMTP {
|
|
if s.UUID == c.UUID {
|
|
set.SMTP[i].Password = c.Password
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !has {
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.errorNoSMTP"))
|
|
}
|
|
|
|
set.AppRootURL = strings.TrimRight(set.AppRootURL, "/")
|
|
|
|
// Bounce boxes.
|
|
for i, s := range set.BounceBoxes {
|
|
// Assign a UUID. The frontend only sends a password when the user explicitly
|
|
// changes the password. In other cases, the existing password in the DB
|
|
// is copied while updating the settings and the UUID is used to match
|
|
// the incoming array of blocks with the array in the DB.
|
|
if s.UUID == "" {
|
|
set.BounceBoxes[i].UUID = uuid.Must(uuid.NewV4()).String()
|
|
}
|
|
|
|
// Ensure the HOST is trimmed of any whitespace.
|
|
// This is a common mistake when copy-pasting SMTP settings.
|
|
set.BounceBoxes[i].Host = strings.TrimSpace(s.Host)
|
|
|
|
if d, _ := time.ParseDuration(s.ScanInterval); d.Minutes() < 1 {
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.bounces.invalidScanInterval"))
|
|
}
|
|
|
|
// If there's no password coming in from the frontend, copy the existing
|
|
// password by matching the UUID.
|
|
if s.Password == "" {
|
|
for _, c := range cur.BounceBoxes {
|
|
if s.UUID == c.UUID {
|
|
set.BounceBoxes[i].Password = c.Password
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for i, m := range set.Messengers {
|
|
// UUID to keep track of password changes similar to the SMTP logic above.
|
|
if m.UUID == "" {
|
|
set.Messengers[i].UUID = uuid.Must(uuid.NewV4()).String()
|
|
}
|
|
|
|
if m.Password == "" {
|
|
for _, c := range cur.Messengers {
|
|
if m.UUID == c.UUID {
|
|
set.Messengers[i].Password = c.Password
|
|
}
|
|
}
|
|
}
|
|
|
|
name := reAlphaNum.ReplaceAllString(strings.ToLower(m.Name), "")
|
|
if _, ok := names[name]; ok {
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
app.i18n.Ts("settings.duplicateMessengerName", "name", name))
|
|
}
|
|
if len(name) == 0 {
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.invalidMessengerName"))
|
|
}
|
|
|
|
set.Messengers[i].Name = name
|
|
names[name] = true
|
|
}
|
|
|
|
// S3 password?
|
|
if set.UploadS3AwsSecretAccessKey == "" {
|
|
set.UploadS3AwsSecretAccessKey = cur.UploadS3AwsSecretAccessKey
|
|
}
|
|
if set.SendgridKey == "" {
|
|
set.SendgridKey = cur.SendgridKey
|
|
}
|
|
if set.BouncePostmark.Password == "" {
|
|
set.BouncePostmark.Password = cur.BouncePostmark.Password
|
|
}
|
|
if set.BounceForwardEmail.Key == "" {
|
|
set.BounceForwardEmail.Key = cur.BounceForwardEmail.Key
|
|
}
|
|
if set.SecurityCaptchaSecret == "" {
|
|
set.SecurityCaptchaSecret = cur.SecurityCaptchaSecret
|
|
}
|
|
if set.OIDC.ClientSecret == "" {
|
|
set.OIDC.ClientSecret = cur.OIDC.ClientSecret
|
|
}
|
|
|
|
for n, v := range set.UploadExtensions {
|
|
set.UploadExtensions[n] = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(v), "."))
|
|
}
|
|
|
|
// Domain blocklist / allowlist.
|
|
doms := make([]string, 0, len(set.DomainBlocklist))
|
|
for _, d := range set.DomainBlocklist {
|
|
if d = strings.TrimSpace(strings.ToLower(d)); d != "" {
|
|
doms = append(doms, d)
|
|
}
|
|
}
|
|
set.DomainBlocklist = doms
|
|
|
|
doms = make([]string, 0, len(set.DomainAllowlist))
|
|
for _, d := range set.DomainAllowlist {
|
|
if d = strings.TrimSpace(strings.ToLower(d)); d != "" {
|
|
doms = append(doms, d)
|
|
}
|
|
}
|
|
set.DomainAllowlist = doms
|
|
|
|
// Validate slow query caching cron.
|
|
if set.CacheSlowQueries {
|
|
if _, err := cron.ParseStandard(set.CacheSlowQueriesInterval); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidData")+": slow query cron: "+err.Error())
|
|
}
|
|
}
|
|
|
|
// Update the settings in the DB.
|
|
if err := app.core.UpdateSettings(set); err != nil {
|
|
return 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.chReload <- syscall.SIGHUP
|
|
}()
|
|
|
|
return c.JSON(http.StatusOK, okResp{true})
|
|
}
|
|
|
|
// handleGetLogs returns the log entries stored in the log buffer.
|
|
func handleGetLogs(c echo.Context) error {
|
|
app := c.Get("app").(*App)
|
|
return c.JSON(http.StatusOK, okResp{app.bufLog.Lines()})
|
|
}
|
|
|
|
// handleTestSMTPSettings returns the log entries stored in the log buffer.
|
|
func handleTestSMTPSettings(c echo.Context) error {
|
|
app := c.Get("app").(*App)
|
|
|
|
// Copy the raw JSON post body.
|
|
reqBody, err := io.ReadAll(c.Request().Body)
|
|
if err != nil {
|
|
app.log.Printf("error reading SMTP test: %v", err)
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.internalError"))
|
|
}
|
|
|
|
// Load the JSON into koanf to parse SMTP settings properly including timestrings.
|
|
ko := koanf.New(".")
|
|
if err := ko.Load(rawbytes.Provider(reqBody), json.Parser()); err != nil {
|
|
app.log.Printf("error unmarshalling SMTP test request: %v", err)
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.internalError"))
|
|
}
|
|
|
|
req := email.Server{}
|
|
if err := ko.UnmarshalWithConf("", &req, koanf.UnmarshalConf{Tag: "json"}); err != nil {
|
|
app.log.Printf("error scanning SMTP test request: %v", err)
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.internalError"))
|
|
}
|
|
|
|
to := ko.String("email")
|
|
if to == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.missingFields", "name", "email"))
|
|
}
|
|
|
|
// Initialize a new SMTP pool.
|
|
req.MaxConns = 1
|
|
req.IdleTimeout = time.Second * 2
|
|
req.PoolWaitTimeout = time.Second * 2
|
|
msgr, err := email.New("", req)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
app.i18n.Ts("globals.messages.errorCreating", "name", "SMTP", "error", err.Error()))
|
|
}
|
|
|
|
var b bytes.Buffer
|
|
if err := app.notifTpls.tpls.ExecuteTemplate(&b, "smtp-test", nil); err != nil {
|
|
app.log.Printf("error compiling notification template '%s': %v", "smtp-test", err)
|
|
return err
|
|
}
|
|
|
|
m := models.Message{}
|
|
m.ContentType = app.notifTpls.contentType
|
|
m.From = app.constants.FromEmail
|
|
m.To = []string{to}
|
|
m.Subject = app.i18n.T("settings.smtp.testConnection")
|
|
m.Body = b.Bytes()
|
|
if err := msgr.Push(m); err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, okResp{app.bufLog.Lines()})
|
|
}
|
|
|
|
func handleGetAboutInfo(c echo.Context) error {
|
|
var (
|
|
app = c.Get("app").(*App)
|
|
mem runtime.MemStats
|
|
)
|
|
|
|
runtime.ReadMemStats(&mem)
|
|
|
|
out := app.about
|
|
out.System.AllocMB = mem.Alloc / 1024 / 1024
|
|
out.System.OSMB = mem.Sys / 1024 / 1024
|
|
|
|
return c.JSON(http.StatusOK, out)
|
|
}
|