fix(security): demo sends nothing, hidden SMTP password, body limits (FLAW-33..38)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s

- Demo: senders for mail, Discord and webhooks refuse, no alarm ticker;
  test alarm, SMTP test, server settings, backups, account and session
  changes of the shared demo accounts answer 403; the session list only
  shows the own device. The UI shows the notices and disables the controls.
- SMTP password is never sent back (smtpPassSet), empty keeps it.
- ?token= on webhook ingest accepts only API keys.
- Request bodies capped at 2 MB (sync 8 MB, imports 101 MB).
- Deleting an account removes its server backups.
- Backup downloads, exports and import uploads get 30 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 19:43:23 +01:00
1 parent 3709282a4f
commit 9d9698186c
21 files changed
+631 -304

No files matched your search

+9
View File
@@ -13,6 +13,9 @@ import (
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
if s.cfg.DemoMode {
return errDemoOutgoing
}
cfg, err := s.smtpSettings()
if err != nil {
return err
@@ -29,6 +32,9 @@ func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error
}
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
if s.cfg.DemoMode {
return errDemoOutgoing
}
allowPrivate := s.allowPrivateWebhooks()
if err := validateWebhookURL(webhookURL, true, allowPrivate); err != nil {
return err
@@ -63,6 +69,9 @@ func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate
}
func (s *Server) sendCustomWebhookNotification(webhookURL, event, title, notes, dueDate, priority string) error {
if s.cfg.DemoMode {
return errDemoOutgoing
}
allowPrivate := s.allowPrivateWebhooks()
if err := validateWebhookURL(webhookURL, false, allowPrivate); err != nil {
return err
+14
View File
@@ -162,7 +162,21 @@ func serveBackupFile(w http.ResponseWriter, r *http.Request, dir, name, contentT
writeError(w, http.StatusNotFound, err.Error())
return
}
allowLongTransfer(w)
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
http.ServeFile(w, r, path)
}
// removeUserBackups deletes the server backups of a deleted account (FLAW-37). The system snapshots
// still contain it until they expire (BACKUP retention).
func (s *Server) removeUserBackups(userID string) {
dir := storage.UserBackupDir(s.backupRoot(), userID)
// Only ever one user's own folder, never backups/users itself (IDs like "." or "/")
if base := filepath.Base(dir); userID == "" || base == "." || base == "users" || filepath.Dir(dir) != filepath.Join(s.backupRoot(), "users") {
return
}
if err := os.RemoveAll(dir); err != nil {
slog.Warn("Backups des gelöschten Kontos konnten nicht entfernt werden", "user_id", userID, "error", err)
}
}
+42
View File
@@ -0,0 +1,42 @@
package api
import (
"net/http"
"strings"
"time"
"github.com/sandrodach/mindsift/internal/storage"
)
// Request bodies are capped for every endpoint (FLAW-36), so a session or API key cannot tie up memory
// with huge uploads. Note sync and imports need more; their handlers apply the same limits again.
const defaultMaxBody = 2 << 20
func bodyLimitFor(path string) int64 {
switch {
case strings.HasPrefix(path, "/api/v1/import/"):
return storage.MaxImportUploadBytes + 1<<20
case strings.HasPrefix(path, "/api/v1/docs/") && strings.HasSuffix(path, "/sync"):
return syncMaxBody
}
return defaultMaxBody
}
func bodyLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil && r.Body != http.NoBody {
r.Body = http.MaxBytesReader(w, r.Body, bodyLimitFor(r.URL.Path))
}
next.ServeHTTP(w, r)
})
}
// The server gives every request 15 s to send its body and 30 s for the answer. Whole archives on a slow
// line need longer: downloads of backups and exports, uploads of imports (FLAW-38).
const longTransferTime = 30 * time.Minute
func allowLongTransfer(w http.ResponseWriter) {
rc := http.NewResponseController(w)
_ = rc.SetWriteDeadline(time.Now().Add(longTransferTime))
_ = rc.SetReadDeadline(time.Now().Add(longTransferTime))
}
+30
View File
@@ -0,0 +1,30 @@
package api
import (
"net/http"
"strings"
"testing"
)
func TestRequestBodyLimited(t *testing.T) {
srv, _, token := setupTestServer(t)
router := srv.Router()
huge := `{"content":"` + strings.Repeat("x", defaultMaxBody) + `"}`
if rec := sessionCall(router, "POST", "/api/v1/inbox", token, huge, ""); rec.Code != http.StatusBadRequest && rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("body over the limit: want 400/413, got %d", rec.Code)
}
if rec := sessionCall(router, "POST", "/api/v1/inbox", token, `{"content":"klein"}`, ""); rec.Code >= 300 {
t.Fatalf("normal body: got %d %s", rec.Code, rec.Body.String())
}
if bodyLimitFor("/api/v1/docs/doc_1/sync") != syncMaxBody || bodyLimitFor("/api/v1/import/obsidian") <= defaultMaxBody {
t.Fatal("sync and import keep their larger limits")
}
}
func TestWebhookTokenInURLOnlyAPIKey(t *testing.T) {
srv, _, token := setupTestServer(t)
rec := sessionCall(srv.Router(), "POST", "/api/v1/webhooks/ingest?token="+token, "", `{"text":"x"}`, "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("session token in URL: want 401, got %d", rec.Code)
}
}
+16
View File
@@ -1,6 +1,7 @@
package api
import (
"errors"
"log/slog"
"net/http"
"strings"
@@ -75,3 +76,18 @@ func (s *Server) startDemoResetTicker() {
s.resetDemo()
}
}
// A public demo must not send anything to the outside (FLAW-33): no alarm mails, Discord or webhook
// calls, no test messages. The senders refuse, and the routes that only exist to send or to change
// server settings answer with 403 right away. All visitors share the demo accounts, so whatever
// would lock the others out (passwords, ending sessions, deleting accounts) is refused as well.
var errDemoOutgoing = errors.New("In der Demo deaktiviert")
func (s *Server) notInDemo(h http.HandlerFunc) http.HandlerFunc {
if !s.cfg.DemoMode {
return h
}
return func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, "In der Demo deaktiviert")
}
}
+2
View File
@@ -135,6 +135,7 @@ func (s *Server) handleImportObsidian(w http.ResponseWriter, r *http.Request) {
// handleExportMarkdown streams an Obsidian-compatible ZIP of the user's workspace (no temp files).
func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
allowLongTransfer(w)
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-vault-%s.zip"`, time.Now().Format("2006-01-02")))
if err := s.store.WriteMarkdownVault(userID, w); err != nil {
@@ -145,6 +146,7 @@ func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
allowLongTransfer(w)
exportData, err := s.store.ExportUserData(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
+18 -4
View File
@@ -246,6 +246,7 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.removeUserBackups(targetUserID)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
@@ -302,8 +303,16 @@ func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
writeJSON(w, http.StatusOK, settings)
writeJSON(w, http.StatusOK, s.settingsForBrowser(settings))
}
// settingsForBrowser hides the SMTP password; the admin only sees whether one is stored.
func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.SystemSettings {
out := *settings
out.SMTPPassSet = out.SMTPPass != ""
out.SMTPPass = ""
out.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
return &out
}
func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Request) {
@@ -312,6 +321,12 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
// Empty means unchanged (the browser never has it); without SMTP user the password goes too
if settings.SMTPPass == "" && strings.TrimSpace(settings.SMTPUser) != "" {
if cur, err := s.store.GetSystemSettings(); err == nil {
settings.SMTPPass = cur.SMTPPass
}
}
previousMode := s.store.SharingMode()
if err := s.store.UpdateSystemSettings(&settings); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
@@ -324,8 +339,7 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
return
}
}
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
writeJSON(w, http.StatusOK, settings)
writeJSON(w, http.StatusOK, s.settingsForBrowser(&settings))
}
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
+6
View File
@@ -385,12 +385,14 @@ func (s *Server) handleDeleteOwnAccount(w http.ResponseWriter, r *http.Request)
writeError(w, status, err.Error())
return
}
s.removeUserBackups(user.ID)
slog.Info("Benutzer hat sein Konto gelöscht", "user_id", user.ID)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// importUpload reads the multipart field "file" (max. storage.MaxImportUploadBytes).
func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
allowLongTransfer(w)
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxImportUploadBytes+1<<20)
file, header, err := r.FormFile("file")
if err != nil {
@@ -456,6 +458,10 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
}
changesPassword := req.Password != nil && strings.TrimSpace(*req.Password) != ""
changesEmail := req.Email != nil && !strings.EqualFold(strings.TrimSpace(*req.Email), before.Email)
if (changesPassword || changesEmail) && s.cfg.DemoMode {
writeError(w, http.StatusForbidden, "In der Demo deaktiviert")
return
}
if (changesPassword || changesEmail) && !s.checkCurrentPassword(w, r, before, req.CurrentPassword) {
return
}
+6
View File
@@ -54,7 +54,13 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
// handleWebhookIngest accepts Smarthome / External automation webhooks via ?token=... or X-API-Key or Bearer token
func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
// In the URL only API keys are taken (devices that cannot set headers); a session token there would
// end up in proxy logs as a full login (FLAW-35)
token := r.URL.Query().Get("token")
if token != "" && !strings.HasPrefix(token, "mindsift_sec_") {
writeError(w, http.StatusUnauthorized, "In der URL sind nur API-Keys erlaubt; Sitzungs-Tokens gehören in den Authorization-Header")
return
}
if token == "" {
token = r.Header.Get("X-API-Key")
}
+113
View File
@@ -0,0 +1,113 @@
package api
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/sandrodach/mindsift/internal/config"
"github.com/sandrodach/mindsift/internal/storage"
"github.com/sandrodach/mindsift/internal/ws"
)
func TestDemoSendsNothingAndKeepsAccounts(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "demo.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.ResetAndSeedDemo(); err != nil {
t.Fatal(err)
}
srv := NewServer(&config.Config{DatabasePath: dbPath, JWTSecret: "demo-secret", DemoMode: true}, store, ws.NewHub(), nil)
router := srv.Router()
token := sessionLogin(t, router, "admin", "admin", "visitor-1")
_ = sessionLogin(t, router, "admin", "admin", "visitor-2")
if err := srv.sendCustomWebhookNotification("https://example.com/hook", "x", "x", "", "", ""); err != errDemoOutgoing {
t.Fatalf("webhook sender must refuse in demo, got %v", err)
}
if err := srv.sendDiscordNotification("https://discord.com/api/webhooks/1/x", "x", "", "", ""); err != errDemoOutgoing {
t.Fatalf("discord sender must refuse in demo, got %v", err)
}
if err := srv.sendEmailNotification("a@example.com", "x", "x"); err != errDemoOutgoing {
t.Fatalf("mail sender must refuse in demo, got %v", err)
}
blocked := []struct{ method, path, body string }{
{"POST", "/api/v1/auth/alarms/test", `{"channel":"webhook","target":"https://example.com/hook"}`},
{"PUT", "/api/v1/admin/settings", `{"allowPrivateWebhooks":true}`},
{"POST", "/api/v1/admin/smtp/test", `{"toEmail":"a@example.com"}`},
{"POST", "/api/v1/admin/backups", ``},
{"POST", "/api/v1/auth/revoke-all", ``},
{"DELETE", "/api/v1/user/account", `{"password":"admin"}`},
{"DELETE", "/api/v1/admin/users/" + storage.DemoUserID, ``},
{"PATCH", "/api/v1/auth/settings", `{"password":"neuesPasswort1","currentPassword":"admin"}`},
}
for _, b := range blocked {
rec := sessionCall(router, b.method, b.path, token, b.body, "")
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Demo") {
t.Fatalf("%s %s: want 403 demo notice, got %d %s", b.method, b.path, rec.Code, rec.Body.String())
}
}
// Harmless profile changes still work
if rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", token, `{"displayName":"Besucher"}`, ""); rec.Code != http.StatusOK {
t.Fatalf("display name in demo: %d", rec.Code)
}
// Visitors only see their own device, not the others' addresses
var list []storage.Session
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/auth/sessions", token, "", "").Body.Bytes(), &list)
if len(list) != 1 || !list[0].Current {
t.Fatalf("demo session list must only show the own device, got %+v", list)
}
}
func TestSMTPPasswordNeverReturned(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
rec := sessionCall(router, "PUT", "/api/v1/admin/settings", token,
`{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"bot","smtpPass":"geheim123"}`, "")
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "geheim123") || !strings.Contains(rec.Body.String(), `"smtpPassSet":true`) {
t.Fatalf("save: %d %s", rec.Code, rec.Body.String())
}
if body := sessionCall(router, "GET", "/api/v1/admin/settings", token, "", "").Body.String(); strings.Contains(body, "geheim123") {
t.Fatalf("GET leaks the password: %s", body)
}
// Saving again with the empty field keeps it
sessionCall(router, "PUT", "/api/v1/admin/settings", token, `{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"bot","smtpPass":""}`, "")
if sys, _ := store.GetSystemSettings(); sys.SMTPPass != "geheim123" {
t.Fatalf("empty field must keep the password, got %q", sys.SMTPPass)
}
// Without SMTP user the password is dropped
sessionCall(router, "PUT", "/api/v1/admin/settings", token, `{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"","smtpPass":""}`, "")
if sys, _ := store.GetSystemSettings(); sys.SMTPPass != "" {
t.Fatalf("password without user must be cleared, got %q", sys.SMTPPass)
}
}
func TestDeletedAccountLosesServerBackups(t *testing.T) {
srv, store, token := setupTestServer(t)
root := srv.backupRoot()
gone, _ := newTestUser(t, store, "gone@example.com")
stays, _ := newTestUser(t, store, "stays@example.com")
for _, id := range []string{gone.ID, stays.ID} {
dir := storage.UserBackupDir(root, id)
_ = os.MkdirAll(dir, 0o755)
_ = os.WriteFile(filepath.Join(dir, "backup.zip"), []byte("x"), 0o600)
}
if rec := sessionCall(srv.Router(), "DELETE", "/api/v1/admin/users/"+gone.ID, token, "", ""); rec.Code != http.StatusOK {
t.Fatalf("delete user: %d %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(storage.UserBackupDir(root, gone.ID)); !os.IsNotExist(err) {
t.Fatalf("backups of the deleted account must be gone: %v", err)
}
for _, odd := range []string{"", ".", "/", "..", "../users"} {
srv.removeUserBackups(odd)
}
if _, err := os.Stat(filepath.Join(storage.UserBackupDir(root, stays.ID), "backup.zip")); err != nil {
t.Fatalf("other accounts' backups must stay: %v", err)
}
}
+13 -12
View File
@@ -51,12 +51,12 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
store.OnTaskCreated = func(th *domain.Thought) {
srv.wsHub.BroadcastToUsers(srv.taskAudience(th), "TASK_CREATED", th)
}
go srv.startAlarmTicker()
go srv.startCompaction()
if cfg.DemoMode && cfg.DemoResetMinutes > 0 {
go srv.startDemoResetTicker()
}
if !cfg.DemoMode {
go srv.startAlarmTicker() // the demo sends nothing (FLAW-33)
go srv.startBackupScheduler()
}
return srv
@@ -84,6 +84,7 @@ func (s *Server) Router() http.Handler {
r.Use(s.clientIPMiddleware)
r.Use(s.securityHeaders)
r.Use(bodyLimit)
r.Use(s.demoResetGate)
r.Use(middleware.Recoverer)
r.Use(s.apiLoggingMiddleware)
@@ -126,11 +127,11 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/events/rooms", s.handleSSERooms)
r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket)
r.Get("/api/v1/auth/sessions", s.handleListSessions)
r.Delete("/api/v1/auth/sessions/{id}", s.handleEndSession)
r.Delete("/api/v1/auth/sessions/{id}", s.notInDemo(s.handleEndSession))
r.Post("/api/v1/auth/logout", s.handleLogout)
r.Post("/api/v1/auth/revoke-all", s.handleRevokeAllSessions)
r.Post("/api/v1/auth/revoke-all", s.notInDemo(s.handleRevokeAllSessions))
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
r.Post("/api/v1/auth/alarms/test", s.notInDemo(s.handleTestUserAlarm))
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory)
r.Get("/api/v1/collaborators", s.handleGetCollaborators)
r.Get("/api/v1/stats", s.handleStats)
@@ -142,7 +143,7 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/export/markdown", s.handleExportMarkdown)
r.Post("/api/v1/import/mindsift", s.handleImportMindSift)
r.Delete("/api/v1/user/data", s.handlePurgeOwnData)
r.Delete("/api/v1/user/account", s.handleDeleteOwnAccount)
r.Delete("/api/v1/user/account", s.notInDemo(s.handleDeleteOwnAccount))
r.Post("/api/v1/import/obsidian", s.handleImportObsidian)
r.Get("/api/v1/themes", s.handleGetThemes)
r.Post("/api/v1/themes", s.handleSaveTheme)
@@ -251,10 +252,10 @@ func (s *Server) Router() http.Handler {
r.Group(func(r chi.Router) {
r.Use(s.staffMiddleware)
r.Get("/api/v1/admin/users", s.handleAdminGetUsers)
r.Post("/api/v1/admin/users", s.handleAdminCreateUser)
r.Patch("/api/v1/admin/users/{id}", s.handleAdminUpdateUser)
r.Put("/api/v1/admin/users/{id}/password", s.handleAdminResetPassword)
r.Delete("/api/v1/admin/users/{id}", s.handleAdminDeleteUser)
r.Post("/api/v1/admin/users", s.notInDemo(s.handleAdminCreateUser))
r.Patch("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminUpdateUser))
r.Put("/api/v1/admin/users/{id}/password", s.notInDemo(s.handleAdminResetPassword))
r.Delete("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminDeleteUser))
r.Get("/api/v1/admin/invites", s.handleAdminGetInvites)
r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite)
@@ -266,9 +267,9 @@ func (s *Server) Router() http.Handler {
r.Use(s.adminMiddleware)
r.Get("/api/v1/admin/settings", s.handleAdminGetSettings)
r.Get("/api/v1/admin/backups", s.handleAdminListBackups)
r.Post("/api/v1/admin/backups", s.handleAdminRunBackup)
r.Post("/api/v1/admin/backups", s.notInDemo(s.handleAdminRunBackup))
r.Get("/api/v1/admin/backups/{name}", s.handleAdminDownloadBackup)
r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings)
r.Put("/api/v1/admin/settings", s.notInDemo(s.handleAdminUpdateSettings))
r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact)
// Organizations decide who can see whom; only server admins manage them
@@ -277,7 +278,7 @@ func (s *Server) Router() http.Handler {
r.Put("/api/v1/admin/organizations/{id}", s.handleAdminRenameOrganization)
r.Delete("/api/v1/admin/organizations/{id}", s.handleAdminDeleteOrganization)
r.Put("/api/v1/admin/users/{id}/organization", s.handleAdminSetUserOrganization)
r.Post("/api/v1/admin/smtp/test", s.handleAdminTestSMTP)
r.Post("/api/v1/admin/smtp/test", s.notInDemo(s.handleAdminTestSMTP))
})
})
+10
View File
@@ -58,6 +58,16 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if s.cfg.DemoMode {
// Demo visitors share the accounts; the others' devices and addresses are none of their business
own := list[:0]
for _, sess := range list {
if sess.Current {
own = append(own, sess)
}
}
list = own
}
writeJSON(w, http.StatusOK, list)
}
+7 -4
View File
@@ -285,10 +285,13 @@ type SystemSettings struct {
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUser string `json:"smtpUser"`
SMTPPass string `json:"smtpPass"`
SMTPFrom string `json:"smtpFrom"`
SMTPTLS bool `json:"smtpTls"`
SMTPSkipVerify bool `json:"smtpSkipVerify"` // accept self-signed certificates
// SMTPPass is never sent to the browser (FLAW-34): reads leave it empty and set SMTPPassSet;
// writing it empty keeps the stored password
SMTPPass string `json:"smtpPass"`
SMTPPassSet bool `json:"smtpPassSet,omitempty"`
SMTPFrom string `json:"smtpFrom"`
SMTPTLS bool `json:"smtpTls"`
SMTPSkipVerify bool `json:"smtpSkipVerify"` // accept self-signed certificates
// AllowPrivateWebhooks lets alarm webhooks reach LAN addresses (and plain http); localhost and cloud
// metadata stay blocked
AllowPrivateWebhooks bool `json:"allowPrivateWebhooks"`