added IMAP, LetsEncrypt, update layout
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
package acmecert
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
func TestLoadOrCreateAccountGeneratesAndPersistsKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
user1, err := loadOrCreateAccount(dir, "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user1.Registration != nil {
|
||||
t.Fatal("expected no registration on a brand-new account")
|
||||
}
|
||||
if user1.GetPrivateKey() == nil {
|
||||
t.Fatal("expected a generated private key")
|
||||
}
|
||||
|
||||
// Reload: must reuse the same key, not generate a new one.
|
||||
user2, err := loadOrCreateAccount(dir, "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyBytes1, _ := os.ReadFile(accountKeyPath(dir))
|
||||
if len(keyBytes1) == 0 {
|
||||
t.Fatal("expected a persisted key file")
|
||||
}
|
||||
// Re-reading shouldn't rewrite the file with different bytes.
|
||||
keyBytes2, _ := os.ReadFile(accountKeyPath(dir))
|
||||
if string(keyBytes1) != string(keyBytes2) {
|
||||
t.Fatal("expected the same key to be reused across loads")
|
||||
}
|
||||
_ = user2
|
||||
}
|
||||
|
||||
func TestSaveRegistrationRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := ®istration.Resource{URI: "https://example.com/acme/acct/123"}
|
||||
if err := saveRegistration(dir, reg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user, err := loadOrCreateAccount(dir, "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.Registration == nil || user.Registration.URI != reg.URI {
|
||||
t.Fatalf("expected registration to round-trip, got %+v", user.Registration)
|
||||
}
|
||||
}
|
||||
|
||||
// writeFixtureCert writes a minimal self-signed cert with the given expiry to certFile
|
||||
// (no matching key needed — NeedsRenewal only reads the cert).
|
||||
func writeFixtureCert(t *testing.T, certFile string, notAfter time.Time) {
|
||||
t.Helper()
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: notAfter,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
if err := os.WriteFile(certFile, pemBytes, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsRenewal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mgr := &Manager{Cfg: ini.Empty(), CertFile: filepath.Join(dir, "server.crt")}
|
||||
|
||||
writeFixtureCert(t, mgr.CertFile, time.Now().Add(200*24*time.Hour))
|
||||
if needs, err := mgr.NeedsRenewal(); err != nil || needs {
|
||||
t.Fatalf("expected NeedsRenewal=false for a cert expiring in 200 days, got %v (err=%v)", needs, err)
|
||||
}
|
||||
|
||||
writeFixtureCert(t, mgr.CertFile, time.Now().Add(5*24*time.Hour))
|
||||
if needs, err := mgr.NeedsRenewal(); err != nil || !needs {
|
||||
t.Fatalf("expected NeedsRenewal=true for a cert expiring in 5 days, got %v (err=%v)", needs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsRenewalMissingCertIsTrue(t *testing.T) {
|
||||
mgr := &Manager{Cfg: ini.Empty(), CertFile: filepath.Join(t.TempDir(), "does-not-exist.crt")}
|
||||
needs, err := mgr.NeedsRenewal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !needs {
|
||||
t.Fatal("expected NeedsRenewal=true when no certificate exists yet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDNSProviderUnknownName(t *testing.T) {
|
||||
cfg := ini.Empty()
|
||||
cfg.Section("LetsEncrypt").Key("dns_provider").SetValue("not-a-real-provider")
|
||||
if _, err := buildDNSProvider(cfg); err == nil {
|
||||
t.Fatal("expected an error for an unknown DNS provider name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDNSProviderDigitalOceanRequiresToken(t *testing.T) {
|
||||
cfg := ini.Empty()
|
||||
cfg.Section("LetsEncrypt").Key("dns_provider").SetValue("digitalocean")
|
||||
// AuthToken deliberately left blank — DigitalOcean's constructor validates this
|
||||
// locally (no network call) and errors immediately.
|
||||
if _, err := buildDNSProvider(cfg); err == nil {
|
||||
t.Fatal("expected an error when digitalocean_api_token is blank")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDNSProviderCloudflare(t *testing.T) {
|
||||
cfg := ini.Empty()
|
||||
sec := cfg.Section("LetsEncrypt")
|
||||
sec.Key("dns_provider").SetValue("cloudflare")
|
||||
sec.Key("cloudflare_api_token").SetValue("fake-token-for-local-construction-only")
|
||||
provider, err := buildDNSProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("expected local provider construction to succeed without a network call, got: %v", err)
|
||||
}
|
||||
if provider == nil {
|
||||
t.Fatal("expected a non-nil provider")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Package acmecert obtains and renews Let's Encrypt certificates via the DNS-01
|
||||
// challenge, as an admin-configurable alternative to the self-signed certificate
|
||||
// tlsutil generates by default. Obtained certificates are written to the same
|
||||
// cert/key file paths the self-signed generator already uses, so the SMTP/IMAP TLS
|
||||
// listeners (via tlsutil.CertReloader) never need to know which produced the active
|
||||
// certificate.
|
||||
package acmecert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/certcrypto"
|
||||
"github.com/go-acme/lego/v4/certificate"
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
"gopkg.in/ini.v1"
|
||||
|
||||
"mailgoserver/internal/tlsutil"
|
||||
"mailgoserver/internal/toolbox"
|
||||
)
|
||||
|
||||
// renewalThreshold mirrors the standard ACME-client convention (certbot/lego CLI):
|
||||
// renew once a certificate is within 30 days of its (90-day, for Let's Encrypt) expiry.
|
||||
const renewalThreshold = 30 * 24 * time.Hour
|
||||
|
||||
// Status is a read-only snapshot of the current Let's Encrypt configuration and the
|
||||
// last renewal attempt, for the admin settings page.
|
||||
type Status struct {
|
||||
Enabled bool
|
||||
Staging bool
|
||||
Domains []string
|
||||
Provider string
|
||||
NotAfter time.Time // parsed live from CertFile each call — never cached
|
||||
LastAttempt time.Time // zero value = no attempt yet this process run
|
||||
LastError string // empty if the last attempt succeeded, or none has run yet
|
||||
}
|
||||
|
||||
// Manager obtains and renews certificates for one configured domain set.
|
||||
type Manager struct {
|
||||
Cfg *ini.File
|
||||
CertFile, KeyFile string
|
||||
DataDir string
|
||||
Reloader *tlsutil.CertReloader
|
||||
Logger *toolbox.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
lastAttempt time.Time
|
||||
lastError string
|
||||
}
|
||||
|
||||
func New(cfg *ini.File, certFile, keyFile, dataDir string, reloader *tlsutil.CertReloader, logger *toolbox.Logger) *Manager {
|
||||
return &Manager{Cfg: cfg, CertFile: certFile, KeyFile: keyFile, DataDir: dataDir, Reloader: reloader, Logger: logger}
|
||||
}
|
||||
|
||||
func (m *Manager) section() *ini.Section { return m.Cfg.Section("LetsEncrypt") }
|
||||
|
||||
func (m *Manager) domains() []string {
|
||||
raw := m.section().Key("domains").String()
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Status returns a snapshot of the current configuration plus the last obtain/renew
|
||||
// attempt's outcome. In-memory only (no DB/file persistence) — this is informational
|
||||
// status for the settings page, not an audit log; it resets on restart, which is an
|
||||
// accepted simplification.
|
||||
func (m *Manager) Status() Status {
|
||||
m.mu.Lock()
|
||||
s := Status{
|
||||
Enabled: m.section().Key("enabled").MustBool(false),
|
||||
Staging: m.section().Key("staging").MustBool(false),
|
||||
Domains: m.domains(),
|
||||
Provider: m.section().Key("dns_provider").String(),
|
||||
LastAttempt: m.lastAttempt,
|
||||
LastError: m.lastError,
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if cert, err := readLeafCertificate(m.CertFile); err == nil {
|
||||
s.NotAfter = cert.NotAfter
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// NeedsRenewal reports whether the certificate currently at CertFile is within 30 days
|
||||
// of expiry (or unreadable/unparseable, which is treated as "yes" — nothing usable is
|
||||
// there to keep). This is a pure expiry check; it makes no attempt to distinguish a
|
||||
// self-signed cert from an ACME-obtained one (see the caller in main.go's renewal
|
||||
// ticker for how the very-first-check case is handled instead).
|
||||
func (m *Manager) NeedsRenewal() (bool, error) {
|
||||
cert, err := readLeafCertificate(m.CertFile)
|
||||
if err != nil {
|
||||
return true, nil
|
||||
}
|
||||
return time.Until(cert.NotAfter) < renewalThreshold, nil
|
||||
}
|
||||
|
||||
func readLeafCertificate(certFile string) (*x509.Certificate, error) {
|
||||
raw, err := os.ReadFile(certFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, _ := pem.Decode(raw)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("acmecert: no PEM block found in %s", certFile)
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
// ObtainOrRenew requests a certificate for the configured domains and, on success,
|
||||
// writes it to CertFile/KeyFile and hot-reloads the live TLS listeners. Used for both
|
||||
// first issuance and renewal — lego's Obtain covers both identically, so there is no
|
||||
// separate renewal code path. A no-op (nil error) if Let's Encrypt isn't enabled. On
|
||||
// any failure, the cert/key files on disk are left untouched — whatever was already
|
||||
// serving (self-signed or a previous ACME cert) keeps working.
|
||||
func (m *Manager) ObtainOrRenew(ctx context.Context) error {
|
||||
if !m.section().Key("enabled").MustBool(false) {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := m.obtain(ctx)
|
||||
|
||||
m.mu.Lock()
|
||||
m.lastAttempt = time.Now()
|
||||
if err != nil {
|
||||
m.lastError = err.Error()
|
||||
} else {
|
||||
m.lastError = ""
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) obtain(ctx context.Context) error {
|
||||
domains := m.domains()
|
||||
if len(domains) == 0 {
|
||||
return fmt.Errorf("acmecert: no domains configured")
|
||||
}
|
||||
|
||||
email := m.section().Key("contact_email").String()
|
||||
user, err := loadOrCreateAccount(m.DataDir, email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load ACME account: %w", err)
|
||||
}
|
||||
|
||||
config := lego.NewConfig(user)
|
||||
config.Certificate.KeyType = certcrypto.EC256
|
||||
if m.section().Key("staging").MustBool(false) {
|
||||
config.CADirURL = lego.LEDirectoryStaging
|
||||
}
|
||||
|
||||
client, err := lego.NewClient(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create ACME client: %w", err)
|
||||
}
|
||||
|
||||
provider, err := buildDNSProvider(m.Cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure DNS provider: %w", err)
|
||||
}
|
||||
if err := client.Challenge.SetDNS01Provider(provider); err != nil {
|
||||
return fmt.Errorf("set DNS-01 provider: %w", err)
|
||||
}
|
||||
|
||||
if user.Registration == nil {
|
||||
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("register ACME account: %w", err)
|
||||
}
|
||||
user.Registration = reg
|
||||
if err := saveRegistration(m.DataDir, reg); err != nil {
|
||||
m.Logger.Error("save ACME account registration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cert, err := client.Certificate.Obtain(certificate.ObtainRequest{
|
||||
Domains: domains,
|
||||
Bundle: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("obtain certificate: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(m.CertFile, cert.Certificate, 0o644); err != nil {
|
||||
return fmt.Errorf("write certificate: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(m.KeyFile, cert.PrivateKey, 0o600); err != nil {
|
||||
return fmt.Errorf("write private key: %w", err)
|
||||
}
|
||||
if err := m.Reloader.Reload(); err != nil {
|
||||
return fmt.Errorf("reload TLS certificate: %w", err)
|
||||
}
|
||||
|
||||
m.Logger.Info("Let's Encrypt certificate obtained for %s", strings.Join(domains, ", "))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package acmecert
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/go-acme/lego/v4/challenge"
|
||||
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
|
||||
"github.com/go-acme/lego/v4/providers/dns/digitalocean"
|
||||
"github.com/go-acme/lego/v4/providers/dns/gcloud"
|
||||
"github.com/go-acme/lego/v4/providers/dns/route53"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// buildDNSProvider constructs the lego DNS-01 provider selected by [LetsEncrypt]
|
||||
// dns_provider. A plain switch is the right amount of structure for a fixed set of
|
||||
// providers — no pluggable registry needed.
|
||||
func buildDNSProvider(cfg *ini.File) (challenge.Provider, error) {
|
||||
sec := cfg.Section("LetsEncrypt")
|
||||
switch name := sec.Key("dns_provider").String(); name {
|
||||
case "cloudflare":
|
||||
c := cloudflare.NewDefaultConfig()
|
||||
c.AuthToken = sec.Key("cloudflare_api_token").String()
|
||||
return cloudflare.NewDNSProviderConfig(c)
|
||||
case "route53":
|
||||
c := route53.NewDefaultConfig()
|
||||
c.AccessKeyID = sec.Key("route53_access_key_id").String()
|
||||
c.SecretAccessKey = sec.Key("route53_secret_access_key").String()
|
||||
c.Region = sec.Key("route53_region").String()
|
||||
c.HostedZoneID = sec.Key("route53_hosted_zone_id").String()
|
||||
return route53.NewDNSProviderConfig(c)
|
||||
case "digitalocean":
|
||||
c := digitalocean.NewDefaultConfig()
|
||||
c.AuthToken = sec.Key("digitalocean_api_token").String()
|
||||
return digitalocean.NewDNSProviderConfig(c)
|
||||
case "gcloud":
|
||||
project := sec.Key("gcloud_project").String()
|
||||
if saPath := sec.Key("gcloud_service_account_json_path").String(); saPath != "" {
|
||||
keyBytes, err := os.ReadFile(saPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read gcloud service account file: %w", err)
|
||||
}
|
||||
return gcloud.NewDNSProviderServiceAccountKey(keyBytes)
|
||||
}
|
||||
return gcloud.NewDNSProviderCredentials(project)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown or unset Let's Encrypt DNS provider %q", name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package acmecert
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-acme/lego/v4/certcrypto"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
)
|
||||
|
||||
// acmeUser implements registration.User. Its private key and (once registered)
|
||||
// registration resource are persisted to disk under a data directory, mirroring the
|
||||
// generate-if-missing file pattern already used by mailstore.LoadOrCreateMasterKey and
|
||||
// tlsutil.GenerateSelfSignedCert — so the ACME account survives restarts and is never
|
||||
// re-registered unnecessarily.
|
||||
type acmeUser struct {
|
||||
Email string
|
||||
Registration *registration.Resource
|
||||
key crypto.PrivateKey
|
||||
}
|
||||
|
||||
func (u *acmeUser) GetEmail() string { return u.Email }
|
||||
func (u *acmeUser) GetRegistration() *registration.Resource { return u.Registration }
|
||||
func (u *acmeUser) GetPrivateKey() crypto.PrivateKey { return u.key }
|
||||
|
||||
func accountKeyPath(dataDir string) string { return filepath.Join(dataDir, "account.key") }
|
||||
func accountRegPath(dataDir string) string { return filepath.Join(dataDir, "account.json") }
|
||||
|
||||
// loadOrCreateAccount loads the persisted ACME account key/registration from dataDir,
|
||||
// generating a fresh key if none exists yet. A nil Registration means no account has
|
||||
// been registered with the CA yet — the caller is responsible for registering and then
|
||||
// calling saveRegistration.
|
||||
func loadOrCreateAccount(dataDir, email string) (*acmeUser, error) {
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := loadOrCreateAccountKey(dataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := &acmeUser{Email: email, key: key}
|
||||
if regBytes, err := os.ReadFile(accountRegPath(dataDir)); err == nil {
|
||||
var reg registration.Resource
|
||||
if err := json.Unmarshal(regBytes, ®); err == nil {
|
||||
user.Registration = ®
|
||||
}
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func loadOrCreateAccountKey(dataDir string) (crypto.PrivateKey, error) {
|
||||
path := accountKeyPath(dataDir)
|
||||
if pemBytes, err := os.ReadFile(path); err == nil {
|
||||
return certcrypto.ParsePEMPrivateKey(pemBytes)
|
||||
}
|
||||
|
||||
key, err := certcrypto.GeneratePrivateKey(certcrypto.EC256)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(path, certcrypto.PEMEncode(key), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// saveRegistration persists the account's registration resource so future runs don't
|
||||
// re-register with the CA.
|
||||
func saveRegistration(dataDir string, reg *registration.Resource) error {
|
||||
data, err := json.Marshal(reg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(accountRegPath(dataDir), data, 0o600)
|
||||
}
|
||||
@@ -34,6 +34,10 @@ var defaults = []struct {
|
||||
{"helo_hostname", "mail.example.com", ""},
|
||||
{"", "", `IP address to bind to (0.0.0.0 = all interfaces), on Windows must use specific IP`},
|
||||
{"BIND_IP", "0.0.0.0", ""},
|
||||
{"", "", "HTTP port for the admin web UI (overridden by the -port flag if given)"},
|
||||
{"WEB_HTTP_PORT", "5000", ""},
|
||||
{"", "", "HTTPS port for the admin web UI (self-signed by default, or the Let's Encrypt cert when enabled)"},
|
||||
{"WEB_HTTPS_PORT", "5001", ""},
|
||||
{"", "", `Custom server banner (to make it empty use "" must be double quotes)`},
|
||||
{"server_banner", "", ""},
|
||||
{"", "", "Time zone for the server"},
|
||||
@@ -79,6 +83,63 @@ var defaults = []struct {
|
||||
{"", "", `Full origin (scheme+host+port) the dashboard is served at, e.g. "https://mail.example.com"`},
|
||||
{"rp_origin", "http://localhost:5000", ""},
|
||||
}},
|
||||
{"IMAP", []defaultKV{
|
||||
{"", "", "IMAP server configuration for mailbox retrieval (Thunderbird, etc.)"},
|
||||
{"", "", "Plain IMAP port (STARTTLS not offered, matching the SMTP plain-port design)"},
|
||||
{"IMAP_PORT", "1143", ""},
|
||||
{"", "", "Implicit-TLS IMAP port (IMAPS)"},
|
||||
{"IMAP_TLS_PORT", "1993", ""},
|
||||
}},
|
||||
{"Mailstore", []defaultKV{
|
||||
{"", "", "Local mailbox storage configuration"},
|
||||
{"", "", "Directory where encrypted message blobs are written"},
|
||||
{"mailstore_path", "server_data/mailstore", ""},
|
||||
{"", "", "Path to the server-held master key that wraps every mailbox's encryption key"},
|
||||
{"", "", "(generated on first run if missing; back this file up separately from the database -"},
|
||||
{"", "", "losing it makes all stored mail unrecoverable, even for admins)"},
|
||||
{"master_key_path", "server_data/mailstore_master.key", ""},
|
||||
{"", "", "Default per-mailbox storage quota in bytes (5 GiB)"},
|
||||
{"default_quota_bytes", "5368709120", ""},
|
||||
{"", "", "Minimum length for IMAP/SMTP app passwords (floor of 25 enforced regardless of this value)"},
|
||||
{"app_password_min_length", "25", ""},
|
||||
{"", "", "Reject messages scoring at or above this built-in heuristic spam score"},
|
||||
{"spam_reject_score", "5", ""},
|
||||
}},
|
||||
{"Rspamd", []defaultKV{
|
||||
{"", "", "Optional rspamd integration for spam scoring (off by default; the built-in"},
|
||||
{"", "", "heuristic score above always runs regardless of this setting)"},
|
||||
{"enabled", "false", ""},
|
||||
{"", "", "rspamd controller/worker URL"},
|
||||
{"url", "http://127.0.0.1:11333", ""},
|
||||
{"", "", "Reject messages rspamd scores at or above this threshold"},
|
||||
{"reject_score", "15", ""},
|
||||
}},
|
||||
{"LetsEncrypt", []defaultKV{
|
||||
{"", "", "Let's Encrypt (ACME, DNS-01 only) automatic certificate configuration for the"},
|
||||
{"", "", "SMTP/IMAP TLS listeners. Leave 'enabled' false to keep using the self-signed cert."},
|
||||
{"enabled", "false", ""},
|
||||
{"", "", "Use Let's Encrypt's staging directory (untrusted certs, no rate limits) for testing"},
|
||||
{"staging", "false", ""},
|
||||
{"", "", "Contact email for the ACME account"},
|
||||
{"contact_email", "", ""},
|
||||
{"", "", "Comma-separated domains to request, e.g. mail.example.com,*.mail.example.com"},
|
||||
{"domains", "", ""},
|
||||
{"", "", "DNS provider used to solve the DNS-01 challenge: cloudflare, route53, digitalocean, gcloud"},
|
||||
{"dns_provider", "", ""},
|
||||
{"", "", "-- Cloudflare --"},
|
||||
{"cloudflare_api_token", "", ""},
|
||||
{"", "", "-- AWS Route53 (leave access key/secret blank to use the host's default AWS credential chain) --"},
|
||||
{"route53_access_key_id", "", ""},
|
||||
{"route53_secret_access_key", "", ""},
|
||||
{"route53_region", "", ""},
|
||||
{"route53_hosted_zone_id", "", ""},
|
||||
{"", "", "-- DigitalOcean --"},
|
||||
{"digitalocean_api_token", "", ""},
|
||||
{"", "", "-- Google Cloud DNS --"},
|
||||
{"gcloud_project", "", ""},
|
||||
{"", "", "Path to an uploaded service-account JSON key; leave blank to use Application Default Credentials"},
|
||||
{"gcloud_service_account_json_path", "", ""},
|
||||
}},
|
||||
}
|
||||
|
||||
// GenerateSettingsIni writes settings.ini with default values and comments if it does
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func (d *DB) ListAliasesForMailbox(mailboxID int64) ([]MailboxAlias, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, email, domain_id, can_send_as, is_active, created_at
|
||||
FROM esrv_mailbox_aliases WHERE mailbox_id = ? ORDER BY email`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxAlias
|
||||
for rows.Next() {
|
||||
var a MailboxAlias
|
||||
var createdAt string
|
||||
if err := rows.Scan(&a.ID, &a.MailboxID, &a.Email, &a.DomainID, &a.CanSendAs, &a.IsActive, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetAliasByEmail mirrors GetMailboxByEmail: case-insensitive, active-only. Used by
|
||||
// mailstore.ResolveRecipient when a recipient address doesn't match any mailbox's
|
||||
// primary address directly.
|
||||
func (d *DB) GetAliasByEmail(email string) (*MailboxAlias, error) {
|
||||
row := d.QueryRow(`SELECT id, mailbox_id, email, domain_id, can_send_as, is_active, created_at
|
||||
FROM esrv_mailbox_aliases WHERE lower(email) = lower(?) AND is_active = 1`, email)
|
||||
var a MailboxAlias
|
||||
var createdAt string
|
||||
if err := row.Scan(&a.ID, &a.MailboxID, &a.Email, &a.DomainID, &a.CanSendAs, &a.IsActive, &createdAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
a.CreatedAt, _ = parseTime(createdAt)
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (d *DB) AliasEmailExists(email string, excludeID int64) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_aliases WHERE lower(email) = lower(?) AND id != ?`, email, excludeID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (d *DB) CreateAlias(mailboxID int64, email string, domainID int64, canSendAs bool) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_mailbox_aliases (mailbox_id, email, domain_id, can_send_as) VALUES (?, ?, ?, ?)`,
|
||||
mailboxID, email, domainID, canSendAs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// RemoveAlias deletes an alias, scoped to mailboxID so a caller can't remove one
|
||||
// belonging to a different mailbox by guessing its id (mirrors RemoveAppPassword).
|
||||
func (d *DB) RemoveAlias(id, mailboxID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_aliases WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
// MailboxCanSendAs reports whether address is an active, send-as-enabled alias owned
|
||||
// by mailboxID — the authorization check for an authenticated mailbox's MAIL FROM
|
||||
// (see smtpserver's validateSenderAuthorization).
|
||||
func (d *DB) MailboxCanSendAs(mailboxID int64, address string) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_aliases WHERE mailbox_id = ? AND lower(email) = lower(?) AND can_send_as = 1 AND is_active = 1`,
|
||||
mailboxID, address).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"math/big"
|
||||
"time"
|
||||
)
|
||||
|
||||
const appPasswordChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
|
||||
|
||||
// GenerateAppPassword returns a random secret for IMAP/SMTP client login — the only
|
||||
// credential those protocols ever see, since AUTH has no interactive MFA step (see
|
||||
// esrv_mailbox_app_passwords in schema.go). Floors at 25 chars regardless of minLen.
|
||||
func GenerateAppPassword(minLen int) string {
|
||||
if minLen < 25 {
|
||||
minLen = 25
|
||||
}
|
||||
b := make([]byte, minLen)
|
||||
max := big.NewInt(int64(len(appPasswordChars)))
|
||||
for i := range b {
|
||||
n, _ := rand.Int(rand.Reader, max)
|
||||
b[i] = appPasswordChars[n.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (d *DB) ListAppPasswordsForMailbox(mailboxID int64) ([]MailboxAppPassword, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, label, password_hash, is_active, created_at, last_used_at
|
||||
FROM esrv_mailbox_app_passwords WHERE mailbox_id = ? ORDER BY created_at`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxAppPassword
|
||||
for rows.Next() {
|
||||
var p MailboxAppPassword
|
||||
var createdAt string
|
||||
var lastUsedAt sql.NullString
|
||||
if err := rows.Scan(&p.ID, &p.MailboxID, &p.Label, &p.PasswordHash, &p.IsActive, &createdAt, &lastUsedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.CreatedAt, _ = parseTime(createdAt)
|
||||
if lastUsedAt.Valid {
|
||||
t, _ := parseTime(lastUsedAt.String)
|
||||
p.LastUsedAt = &t
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) CreateAppPassword(mailboxID int64, label, passwordHash string) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_mailbox_app_passwords (mailbox_id, label, password_hash) VALUES (?, ?, ?)`,
|
||||
mailboxID, label, passwordHash)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// VerifyMailboxAppPassword resolves the mailbox by its primary email (never an alias)
|
||||
// and bcrypt-checks it against every active app password. A mailbox's app-password
|
||||
// list is small, so a linear scan needs no index. Returns (nil, nil) on no match.
|
||||
func (d *DB) VerifyMailboxAppPassword(email, password string) (*Mailbox, error) {
|
||||
mbox, err := d.GetMailboxByEmail(email)
|
||||
if err != nil || mbox == nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := d.Query(`SELECT id, password_hash FROM esrv_mailbox_app_passwords WHERE mailbox_id = ? AND is_active = 1`, mbox.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var matchedID int64
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var hash string
|
||||
if err := rows.Scan(&id, &hash); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if CheckPassword(password, hash) {
|
||||
matchedID = id
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
rowsErr := rows.Err()
|
||||
// Must close before the UPDATE below: the connection pool is capped to one
|
||||
// connection (see schema.go's Open), so an Exec while these rows are still open
|
||||
// would deadlock waiting for a connection that rows itself is holding.
|
||||
rows.Close()
|
||||
if rowsErr != nil {
|
||||
return nil, rowsErr
|
||||
}
|
||||
if !found {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := d.Exec(`UPDATE esrv_mailbox_app_passwords SET last_used_at = ? WHERE id = ?`, time.Now(), matchedID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mbox, nil
|
||||
}
|
||||
|
||||
func (d *DB) SetAppPasswordActive(id int64, active bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_app_passwords SET is_active = ? WHERE id = ?`, active, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveAppPassword deletes an app password, scoped to mailboxID so a caller can't
|
||||
// remove one belonging to a different mailbox by guessing/manipulating its id —
|
||||
// mirrors DeleteWebAuthnCredential's (id, ownerID) pattern.
|
||||
func (d *DB) RemoveAppPassword(id, mailboxID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_app_passwords WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package db
|
||||
|
||||
import "strings"
|
||||
|
||||
func (d *DB) ListAllowBlock(mailboxID int64) ([]MailboxAllowBlockEntry, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, list_type, pattern, created_at
|
||||
FROM esrv_mailbox_allowblock WHERE mailbox_id = ? ORDER BY list_type, pattern`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxAllowBlockEntry
|
||||
for rows.Next() {
|
||||
var e MailboxAllowBlockEntry
|
||||
var createdAt string
|
||||
if err := rows.Scan(&e.ID, &e.MailboxID, &e.ListType, &e.Pattern, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) AddAllowBlockEntry(mailboxID int64, listType, pattern string) (int64, error) {
|
||||
res, err := d.Exec(`INSERT OR IGNORE INTO esrv_mailbox_allowblock (mailbox_id, list_type, pattern) VALUES (?, ?, ?)`,
|
||||
mailboxID, listType, strings.ToLower(pattern))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// RemoveAllowBlockEntry deletes an entry, scoped to mailboxID (mirrors RemoveAppPassword/RemoveAlias).
|
||||
func (d *DB) RemoveAllowBlockEntry(id, mailboxID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_allowblock WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) IsBlocked(mailboxID int64, senderAddr string) (bool, error) {
|
||||
return matchesAllowBlock(d, mailboxID, "block", senderAddr)
|
||||
}
|
||||
|
||||
func (d *DB) IsAllowed(mailboxID int64, senderAddr string) (bool, error) {
|
||||
return matchesAllowBlock(d, mailboxID, "allow", senderAddr)
|
||||
}
|
||||
|
||||
// matchesAllowBlock checks senderAddr against every pattern of listType for mailboxID
|
||||
// — an exact address match, or a "@domain.com" wildcard matching senderAddr's domain.
|
||||
func matchesAllowBlock(d *DB, mailboxID int64, listType, senderAddr string) (bool, error) {
|
||||
senderAddr = strings.ToLower(senderAddr)
|
||||
domain := domainPart(senderAddr)
|
||||
rows, err := d.Query(`SELECT pattern FROM esrv_mailbox_allowblock WHERE mailbox_id = ? AND list_type = ?`, mailboxID, listType)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var pattern string
|
||||
if err := rows.Scan(&pattern); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.HasPrefix(pattern, "@") {
|
||||
if pattern[1:] == domain {
|
||||
return true, nil
|
||||
}
|
||||
} else if pattern == senderAddr {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// InsertMessage records a stored message's index row (the ciphertext itself already
|
||||
// lives at storagePath — see internal/mailstore). Returns the new row's id, which
|
||||
// doubles as the IMAP UID in later milestones.
|
||||
func (d *DB) InsertMessage(mailboxID int64, folder, messageIDHeader, flags string, internalDate time.Time, sizeBytes int64, storagePath string, nonce []byte, cachedFrom, cachedSubject string) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_mailbox_messages
|
||||
(mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, storage_path, nonce, cached_from, cached_subject)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
mailboxID, folder, messageIDHeader, flags, internalDate, sizeBytes, storagePath, nonce, cachedFrom, cachedSubject)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) GetMessageByUID(mailboxID, uid int64) (*MailboxMessage, error) {
|
||||
row := d.QueryRow(`SELECT id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_subject, storage_path, nonce, created_at
|
||||
FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?`, uid, mailboxID)
|
||||
var m MailboxMessage
|
||||
var internalDate, createdAt string
|
||||
if err := row.Scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedSubject, &m.StoragePath, &m.Nonce, &createdAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
m.InternalDate, _ = parseTime(internalDate)
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (d *DB) DeleteMessage(mailboxID, uid int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?`, uid, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListMessageUIDsForMailbox returns every stored message's UID for mailboxID — used by
|
||||
// mailbox removal to delete each one's on-disk ciphertext via mailstore before the
|
||||
// mailbox row itself is removed.
|
||||
func (d *DB) ListMessageUIDsForMailbox(mailboxID int64) ([]int64, error) {
|
||||
rows, err := d.Query(`SELECT id FROM esrv_mailbox_messages WHERE mailbox_id = ? ORDER BY id ASC`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListMessagesForMailbox returns every stored message's full row for mailboxID,
|
||||
// ordered ascending by UID (id) — this ordering IS the IMAP sequence-number mapping
|
||||
// (index+1 == seqNum) that internal/imapserver relies on.
|
||||
func (d *DB) ListMessagesForMailbox(mailboxID int64) ([]MailboxMessage, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_subject, storage_path, nonce, created_at
|
||||
FROM esrv_mailbox_messages WHERE mailbox_id = ? ORDER BY id ASC`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxMessage
|
||||
for rows.Next() {
|
||||
var m MailboxMessage
|
||||
var internalDate, createdAt string
|
||||
if err := rows.Scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedSubject, &m.StoragePath, &m.Nonce, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.InternalDate, _ = parseTime(internalDate)
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetMessageFlags overwrites a message's stored IMAP flags (space-separated), scoped
|
||||
// to mailboxID so a session can't touch another mailbox's message by guessing a UID.
|
||||
func (d *DB) SetMessageFlags(mailboxID, uid int64, flags string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_messages SET flags = ? WHERE id = ? AND mailbox_id = ?`, flags, uid, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListMessagesInFolder is ListMessagesForMailbox scoped to one folder — internal/imapserver
|
||||
// uses this (not the unscoped version) so a filter rule's move_to_folder action produces
|
||||
// mail that's actually browsable in its own folder, not mixed into every SELECT.
|
||||
func (d *DB) ListMessagesInFolder(mailboxID int64, folder string) ([]MailboxMessage, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_subject, storage_path, nonce, created_at
|
||||
FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ? ORDER BY id ASC`, mailboxID, folder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxMessage
|
||||
for rows.Next() {
|
||||
var m MailboxMessage
|
||||
var internalDate, createdAt string
|
||||
if err := rows.Scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedSubject, &m.StoragePath, &m.Nonce, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.InternalDate, _ = parseTime(internalDate)
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DistinctFoldersForMailbox returns every folder name that has at least one stored
|
||||
// message, plus "INBOX" always (even if empty) — the folder list internal/imapserver's
|
||||
// LIST command reports.
|
||||
func (d *DB) DistinctFoldersForMailbox(mailboxID int64) ([]string, error) {
|
||||
rows, err := d.Query(`SELECT DISTINCT folder FROM esrv_mailbox_messages WHERE mailbox_id = ?`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[string]bool{"INBOX": true}
|
||||
out := []string{"INBOX"}
|
||||
for rows.Next() {
|
||||
var folder string
|
||||
if err := rows.Scan(&folder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !seen[folder] {
|
||||
seen[folder] = true
|
||||
out = append(out, folder)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package db
|
||||
|
||||
func (d *DB) ListRulesForMailbox(mailboxID int64) ([]MailboxFilterRule, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value, is_active, created_at
|
||||
FROM esrv_mailbox_filter_rules WHERE mailbox_id = ? ORDER BY priority ASC, id ASC`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxFilterRule
|
||||
for rows.Next() {
|
||||
var r MailboxFilterRule
|
||||
var createdAt string
|
||||
if err := rows.Scan(&r.ID, &r.MailboxID, &r.Priority, &r.ConditionField, &r.ConditionOp, &r.ConditionValue, &r.Action, &r.ActionValue, &r.IsActive, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) CreateRule(mailboxID int64, priority int, field, op, value, action, actionValue string) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_mailbox_filter_rules (mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, mailboxID, priority, field, op, value, action, actionValue)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// RemoveRule deletes a rule, scoped to mailboxID (mirrors RemoveAppPassword/RemoveAlias).
|
||||
func (d *DB) RemoveRule(id, mailboxID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_filter_rules WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- Sessions --- (mirrors crud_admin.go's session functions, parallel schema)
|
||||
|
||||
func newMailboxSessionToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (d *DB) CreateMailboxSession(mailboxID int64, mfaVerified bool, ttl time.Duration) (string, error) {
|
||||
token := newMailboxSessionToken()
|
||||
_, err := d.Exec(`INSERT INTO esrv_mailbox_sessions (token, mailbox_id, mfa_verified, expires_at) VALUES (?, ?, ?, ?)`,
|
||||
token, mailboxID, mfaVerified, time.Now().Add(ttl))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (d *DB) GetMailboxSession(token string) (*MailboxSession, error) {
|
||||
row := d.QueryRow(`SELECT token, mailbox_id, mfa_verified, created_at, expires_at FROM esrv_mailbox_sessions WHERE token = ?`, token)
|
||||
var s MailboxSession
|
||||
var createdAt, expiresAt string
|
||||
if err := row.Scan(&s.Token, &s.MailboxID, &s.MFAVerified, &createdAt, &expiresAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.CreatedAt, _ = parseTime(createdAt)
|
||||
s.ExpiresAt, _ = parseTime(expiresAt)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (d *DB) MarkMailboxSessionMFAVerified(token string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_sessions SET mfa_verified = 1 WHERE token = ?`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeleteMailboxSession(token string) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_sessions WHERE token = ?`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- WebAuthn credentials --- (mirrors crud_admin.go, parallel schema)
|
||||
|
||||
func (d *DB) ListMailboxWebAuthnCredentials(mailboxID int64) ([]MailboxWebAuthnCredential, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, name, credential_id, credential_data, created_at FROM esrv_mailbox_webauthn_credentials WHERE mailbox_id = ? ORDER BY created_at`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxWebAuthnCredential
|
||||
for rows.Next() {
|
||||
var c MailboxWebAuthnCredential
|
||||
var createdAt string
|
||||
if err := rows.Scan(&c.ID, &c.MailboxID, &c.Name, &c.CredentialID, &c.CredentialData, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) CreateMailboxWebAuthnCredential(mailboxID int64, name, credentialID, credentialData string) error {
|
||||
_, err := d.Exec(`INSERT INTO esrv_mailbox_webauthn_credentials (mailbox_id, name, credential_id, credential_data) VALUES (?, ?, ?, ?)`,
|
||||
mailboxID, name, credentialID, credentialData)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeleteMailboxWebAuthnCredential(id, mailboxID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_webauthn_credentials WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) CountMailboxWebAuthnCredentials(mailboxID int64) (int, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_webauthn_credentials WHERE mailbox_id = ?`, mailboxID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
const mailboxColumns = `id, email, domain_id, password_hash, is_active, quota_bytes, used_bytes, dek_wrapped, dek_nonce, created_at, created_by, totp_secret, totp_enabled`
|
||||
|
||||
func scanMailbox(row *sql.Row) (*Mailbox, error) {
|
||||
var m Mailbox
|
||||
var createdAt string
|
||||
var createdBy sql.NullInt64
|
||||
if err := row.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
if createdBy.Valid {
|
||||
m.CreatedBy = &createdBy.Int64
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// MailboxWithDomain joins a Mailbox with its Domain's name, mirroring SenderWithDomain.
|
||||
type MailboxWithDomain struct {
|
||||
Mailbox
|
||||
DomainName string
|
||||
}
|
||||
|
||||
func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
|
||||
rows, err := d.Query(`SELECT m.id, m.email, m.domain_id, m.password_hash, m.is_active, m.quota_bytes, m.used_bytes, m.dek_wrapped, m.dek_nonce, m.created_at, m.created_by, m.totp_secret, m.totp_enabled, dm.domain_name
|
||||
FROM esrv_mailboxes m JOIN esrv_domains dm ON dm.id = m.domain_id ORDER BY m.email`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxWithDomain
|
||||
for rows.Next() {
|
||||
var m MailboxWithDomain
|
||||
var createdAt string
|
||||
var createdBy sql.NullInt64
|
||||
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.DomainName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
if createdBy.Valid {
|
||||
m.CreatedBy = &createdBy.Int64
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) ListMailboxesForDomain(domainID int64) ([]Mailbox, error) {
|
||||
rows, err := d.Query(`SELECT `+mailboxColumns+` FROM esrv_mailboxes WHERE domain_id = ? ORDER BY email`, domainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Mailbox
|
||||
for rows.Next() {
|
||||
var m Mailbox
|
||||
var createdAt string
|
||||
var createdBy sql.NullInt64
|
||||
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
if createdBy.Valid {
|
||||
m.CreatedBy = &createdBy.Int64
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) GetMailboxByID(id int64) (*Mailbox, error) {
|
||||
row := d.QueryRow(`SELECT `+mailboxColumns+` FROM esrv_mailboxes WHERE id = ?`, id)
|
||||
return scanMailbox(row)
|
||||
}
|
||||
|
||||
// GetMailboxByEmail mirrors GetSenderByEmail: case-insensitive, active-only. Used both
|
||||
// for SMTP local-delivery recipient resolution and as the base lookup for app-password
|
||||
// verification — an alias never resolves here directly, per "login is always the
|
||||
// primary mailbox address."
|
||||
func (d *DB) GetMailboxByEmail(email string) (*Mailbox, error) {
|
||||
row := d.QueryRow(`SELECT `+mailboxColumns+` FROM esrv_mailboxes WHERE lower(email) = lower(?) AND is_active = 1`, email)
|
||||
return scanMailbox(row)
|
||||
}
|
||||
|
||||
func (d *DB) MailboxEmailExists(email string, excludeID int64) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailboxes WHERE lower(email) = lower(?) AND id != ?`, email, excludeID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// CreateMailbox inserts a new mailbox with its wrapped per-mailbox data encryption key
|
||||
// (see internal/mailstore for how wrappedDEK/dekNonce are produced).
|
||||
func (d *DB) CreateMailbox(email, passwordHash string, domainID int64, quotaBytes int64, wrappedDEK, dekNonce []byte) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_mailboxes (email, domain_id, password_hash, quota_bytes, dek_wrapped, dek_nonce)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`, email, domainID, passwordHash, quotaBytes, wrappedDEK, dekNonce)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) SetMailboxActive(id int64, active bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET is_active = ? WHERE id = ?`, active, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetMailboxQuota(id int64, quotaBytes int64) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET quota_bytes = ? WHERE id = ?`, quotaBytes, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetMailboxPasswordHash(id int64, passwordHash string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET password_hash = ? WHERE id = ?`, passwordHash, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetMailboxTOTPSecret(id int64, secret string, enabled bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET totp_secret = ?, totp_enabled = ? WHERE id = ?`, secret, enabled, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DisableMailboxTOTP(id int64) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET totp_secret = '', totp_enabled = 0 WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddMailboxUsedBytes adjusts the cached running total by delta (positive on store,
|
||||
// negative on delete) in a single statement, avoiding a read-modify-write race.
|
||||
func (d *DB) AddMailboxUsedBytes(id int64, delta int64) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET used_bytes = used_bytes + ? WHERE id = ?`, delta, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) GetDomainDefaultQuota(domainID int64) (int64, error) {
|
||||
var n int64
|
||||
err := d.QueryRow(`SELECT default_mailbox_quota_bytes FROM esrv_domains WHERE id = ?`, domainID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (d *DB) SetDomainDefaultQuota(domainID int64, bytes int64) error {
|
||||
_, err := d.Exec(`UPDATE esrv_domains SET default_mailbox_quota_bytes = ? WHERE id = ?`, bytes, domainID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveMailboxCascade hard-deletes a mailbox and its app passwords / any remaining
|
||||
// message rows. Callers should delete each message's on-disk ciphertext via
|
||||
// mailstore.DeleteMessage first (see ListMessageUIDsForMailbox) — the message-row
|
||||
// DELETE here is just a safety net for any that weren't individually cleaned up.
|
||||
func (d *DB) RemoveMailboxCascade(id int64) error {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, stmt := range []string{
|
||||
`DELETE FROM esrv_mailbox_app_passwords WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailbox_aliases WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailbox_allowblock WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailbox_filter_rules WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailbox_sessions WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailbox_webauthn_credentials WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailbox_messages WHERE mailbox_id = ?`,
|
||||
`DELETE FROM esrv_mailboxes WHERE id = ?`,
|
||||
} {
|
||||
if _, err := tx.Exec(stmt, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
// Mailbox is a real, IMAP-retrievable local mailbox — distinct from Sender (which is
|
||||
// relay/auth-only). PasswordHash authenticates the self-service web portal only;
|
||||
// IMAP/SMTP client login always goes through a MailboxAppPassword instead.
|
||||
type Mailbox struct {
|
||||
ID int64
|
||||
Email string
|
||||
DomainID int64
|
||||
PasswordHash string
|
||||
IsActive bool
|
||||
QuotaBytes int64
|
||||
UsedBytes int64
|
||||
DEKWrapped []byte
|
||||
DEKNonce []byte
|
||||
CreatedAt time.Time
|
||||
CreatedBy *int64
|
||||
TOTPSecret string
|
||||
TOTPEnabled bool
|
||||
}
|
||||
|
||||
// MailboxSession is a self-service webmail portal login — a parallel schema to
|
||||
// AdminSession, not shared (see esrv_mailbox_sessions in schema.go).
|
||||
type MailboxSession struct {
|
||||
Token string
|
||||
MailboxID int64
|
||||
MFAVerified bool
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// MailboxWebAuthnCredential is a mailbox owner's passkey — a parallel schema to
|
||||
// WebAuthnCredential, not shared.
|
||||
type MailboxWebAuthnCredential struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Name string
|
||||
CredentialID string
|
||||
CredentialData string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxAlias is an alternate address for a mailbox — receive-only by default, or
|
||||
// also usable as MAIL FROM once authenticated (CanSendAs). Login is always the
|
||||
// mailbox's own primary address, never an alias.
|
||||
type MailboxAlias struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Email string
|
||||
DomainID int64
|
||||
CanSendAs bool
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxAllowBlockEntry is one allow- or block-list pattern for a mailbox.
|
||||
type MailboxAllowBlockEntry struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
ListType string // "allow" | "block"
|
||||
Pattern string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxFilterRule is one priority-ordered, first-match-wins delivery rule.
|
||||
type MailboxFilterRule struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Priority int
|
||||
ConditionField string // "from" | "to" | "subject"
|
||||
ConditionOp string // "contains" | "equals" | "starts_with"
|
||||
ConditionValue string
|
||||
Action string // "move_to_folder" | "delete" | "mark_read"
|
||||
ActionValue string
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxAppPassword is the only credential an IMAP/SMTP client ever uses. Plaintext
|
||||
// is shown once at creation and never stored.
|
||||
type MailboxAppPassword struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Label string
|
||||
PasswordHash string
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
LastUsedAt *time.Time
|
||||
}
|
||||
|
||||
// MailboxMessage is one stored message. CachedFrom/CachedSubject are plaintext by
|
||||
// design (see schema.go); the rest of the message lives encrypted at StoragePath.
|
||||
type MailboxMessage struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Folder string
|
||||
MessageIDHeader string
|
||||
Flags string
|
||||
InternalDate time.Time
|
||||
SizeBytes int64
|
||||
CachedFrom string
|
||||
CachedSubject string
|
||||
StoragePath string
|
||||
Nonce []byte
|
||||
CreatedAt time.Time
|
||||
}
|
||||
+133
-1
@@ -21,7 +21,8 @@ CREATE TABLE IF NOT EXISTS esrv_domains (
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
verification_token TEXT NOT NULL DEFAULT '',
|
||||
is_verified INTEGER NOT NULL DEFAULT 0,
|
||||
verified_at DATETIME
|
||||
verified_at DATETIME,
|
||||
default_mailbox_quota_bytes INTEGER NOT NULL DEFAULT 5368709120
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_senders (
|
||||
@@ -149,6 +150,122 @@ CREATE TABLE IF NOT EXISTS esrv_webauthn_credentials (
|
||||
credential_data TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Mailboxes are a distinct identity from esrv_senders: senders are relay/auth-only,
|
||||
-- mailboxes are real IMAP-retrievable local storage. password_hash authenticates the
|
||||
-- (future) self-service web portal only, never IMAP/SMTP client login — those use an
|
||||
-- app password instead (esrv_mailbox_app_passwords), since IMAP/SMTP AUTH has no
|
||||
-- interactive MFA step. dek_wrapped/dek_nonce hold this mailbox's AES-256 data
|
||||
-- encryption key, sealed with the server-held master key (internal/mailstore) — a
|
||||
-- raw DB dump alone can't decrypt stored mail without that separate key file.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailboxes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
|
||||
password_hash TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
quota_bytes INTEGER NOT NULL DEFAULT 5368709120,
|
||||
used_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
dek_wrapped BLOB NOT NULL,
|
||||
dek_nonce BLOB NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES esrv_admin_users(id),
|
||||
totp_secret TEXT NOT NULL DEFAULT '',
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Self-service webmail portal sessions — deliberately a parallel schema to
|
||||
-- esrv_admin_sessions, not shared: a mailbox owner is a different actor type with no
|
||||
-- accessScope/domain-admin semantics of its own.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
mfa_verified INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_webauthn_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
credential_data TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- App passwords are the only credential IMAP/SMTP clients (Thunderbird etc.) ever see
|
||||
-- for a mailbox. plaintext is shown once at creation and never stored/re-shown.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_app_passwords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at DATETIME
|
||||
);
|
||||
|
||||
-- A mailbox's receive-only (or, with can_send_as, send-as too) alternate addresses.
|
||||
-- Login is always the mailbox's own primary address (esrv_mailboxes.email), never an
|
||||
-- alias — an alias only changes which addresses can deliver here / be used as MAIL
|
||||
-- FROM by this mailbox once authenticated via its app password.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_aliases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
|
||||
can_send_as INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Per-mailbox sender allow/block list. pattern is either an exact address
|
||||
-- ("spam@evil.com") or a whole-domain wildcard ("@evil.com"). A single table with a
|
||||
-- list_type column, not two near-identical tables.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_allowblock (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
list_type TEXT NOT NULL CHECK(list_type IN ('allow','block')),
|
||||
pattern TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(mailbox_id, list_type, pattern)
|
||||
);
|
||||
|
||||
-- Simple first-match-wins filter rules, evaluated in priority order (lower first) at
|
||||
-- delivery time, before a message is encrypted and stored — so from/to/subject
|
||||
-- matching works against the real message, not just the plaintext cache columns below.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_filter_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
condition_field TEXT NOT NULL CHECK(condition_field IN ('from','to','subject')),
|
||||
condition_op TEXT NOT NULL CHECK(condition_op IN ('contains','equals','starts_with')),
|
||||
condition_value TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK(action IN ('move_to_folder','delete','mark_read')),
|
||||
action_value TEXT NOT NULL DEFAULT '',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- One row per stored message. cached_from/cached_subject are deliberately plaintext
|
||||
-- (a narrow, confirmed exception to "encrypted at rest") so IMAP LIST/basic SEARCH
|
||||
-- don't need to decrypt every message in a folder; body and every other header stay
|
||||
-- ciphertext-only at storage_path, decrypted solely on FETCH.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
folder TEXT NOT NULL DEFAULT 'INBOX',
|
||||
message_id_header TEXT NOT NULL DEFAULT '',
|
||||
flags TEXT NOT NULL DEFAULT '',
|
||||
internal_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
cached_from TEXT NOT NULL DEFAULT '',
|
||||
cached_subject TEXT NOT NULL DEFAULT '',
|
||||
storage_path TEXT NOT NULL,
|
||||
nonce BLOB NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`
|
||||
|
||||
// migrateAddedColumns best-effort ALTER TABLEs the columns added to esrv_domains
|
||||
@@ -164,6 +281,9 @@ func migrateAddedColumns(db *sql.DB) {
|
||||
`ALTER TABLE esrv_domains ADD COLUMN verified_at DATETIME`,
|
||||
`ALTER TABLE esrv_admin_users ADD COLUMN is_global_admin INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE esrv_admin_users ADD COLUMN created_by INTEGER`,
|
||||
`ALTER TABLE esrv_domains ADD COLUMN default_mailbox_quota_bytes INTEGER NOT NULL DEFAULT 5368709120`,
|
||||
`ALTER TABLE esrv_mailboxes ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE esrv_mailboxes ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
db.Exec(stmt)
|
||||
@@ -181,6 +301,18 @@ func Open(path string) (*DB, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
// The web UI, SMTP server, and IMAP server all share this one *sql.DB. SQLite only
|
||||
// allows one writer at a time, and PRAGMAs are per-connection — database/sql's
|
||||
// pool can silently open a second physical connection at any time, so a PRAGMA
|
||||
// set via Exec here isn't guaranteed to apply to whichever connection later hits a
|
||||
// lock. Capping the pool to one connection is the standard fix: every access is
|
||||
// serialized through a single physical connection, so no connection can ever
|
||||
// collide with another's in-progress write.
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if _, err := sqlDB.Exec(`PRAGMA busy_timeout = 5000`); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("set busy_timeout: %w", err)
|
||||
}
|
||||
if _, err := sqlDB.Exec(schema); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("create tables: %w", err)
|
||||
|
||||
@@ -216,6 +216,23 @@ func stripExistingSignature(content string) string {
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// VerifyInbound checks whether content carries at least one valid DKIM signature
|
||||
// aligned to domainName (typically the From-header domain), mirroring Sign's
|
||||
// error-swallowing-to-false style. Used only as one signal feeding the inbound spam
|
||||
// heuristic (internal/mailstore) — never blocks delivery by itself.
|
||||
func VerifyInbound(content, domainName string) bool {
|
||||
verifications, err := msgdkim.Verify(strings.NewReader(content))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, v := range verifications {
|
||||
if v.Err == nil && strings.EqualFold(v.Domain, domainName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetActiveCustomHeaders mirrors DKIMManager.get_active_custom_headers.
|
||||
func (m *Manager) GetActiveCustomHeaders(domainName string) ([][2]string, error) {
|
||||
dom, err := m.DB.GetDomainByName(domainName)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Package imapserver implements a minimal read-mostly IMAP4rev1 server backed by
|
||||
// internal/mailstore, so a mail client (Thunderbird etc.) can retrieve mail this
|
||||
// server received into a local mailbox. Only a single fixed "INBOX" folder is
|
||||
// supported — no folder hierarchy yet (aliases/multiple folders are a later
|
||||
// milestone). Login only accepts an app password (esrv_mailbox_app_passwords), never
|
||||
// a mailbox's own portal password, since IMAP AUTH has no interactive MFA step.
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/mailstore"
|
||||
"mailgoserver/internal/toolbox"
|
||||
)
|
||||
|
||||
// Backend holds the shared dependencies every connection's Session uses, mirroring
|
||||
// smtpserver.Backend.
|
||||
type Backend struct {
|
||||
DB *db.DB
|
||||
Mailstore *mailstore.Store
|
||||
Logger *toolbox.Logger
|
||||
}
|
||||
|
||||
func (b *Backend) NewSession() *Session {
|
||||
return &Session{backend: b}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package imapserver_test
|
||||
|
||||
import (
|
||||
"net"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/imapserver"
|
||||
"mailgoserver/internal/mailstore"
|
||||
)
|
||||
|
||||
// newTestMailboxWithAppPassword seeds a domain + mailbox + app password (returning the
|
||||
// plaintext, since only its bcrypt hash is stored) and one stored message, and starts
|
||||
// a plain IMAP listener against it.
|
||||
func newTestMailboxWithAppPassword(t *testing.T) (client *imapclient.Client, mailboxEmail, appPassword string, mailboxID int64) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
database, err := db.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
domainID, err := database.CreateDomain("example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := mailstore.New(database, mailstore.GenerateDEK(), t.TempDir())
|
||||
dek := mailstore.GenerateDEK()
|
||||
wrapped, nonce, err := store.WrapDEK(dek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
portalHash, err := db.HashPassword("portal-password-unused")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mailboxID, err = database.CreateMailbox("inbox@example.com", portalHash, domainID, 5*1024*1024*1024, wrapped, nonce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
appPassword = db.GenerateAppPassword(25)
|
||||
appHash, err := db.HashPassword(appPassword)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.CreateAppPassword(mailboxID, "test client", appHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw := []byte("From: Alice <alice@example.com>\r\nSubject: Hello there\r\n\r\nBody text.")
|
||||
if _, err := store.StoreMessage(mailboxID, "INBOX", raw, "<abc@example.com>", "Alice <alice@example.com>", "Hello there"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backend := &imapserver.Backend{DB: database, Mailstore: store}
|
||||
srv := imapserver.NewPlainServer(backend)
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go srv.Serve(ln)
|
||||
t.Cleanup(func() { srv.Close() })
|
||||
|
||||
conn, err := net.Dial("tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client = imapclient.New(conn, nil)
|
||||
t.Cleanup(func() { client.Close() })
|
||||
|
||||
return client, "inbox@example.com", appPassword, mailboxID
|
||||
}
|
||||
|
||||
func TestIMAPLoginSelectFetch(t *testing.T) {
|
||||
client, email, appPassword, _ := newTestMailboxWithAppPassword(t)
|
||||
|
||||
if err := client.Login(email, appPassword).Wait(); err != nil {
|
||||
t.Fatalf("login with app password: %v", err)
|
||||
}
|
||||
|
||||
selectData, err := client.Select("INBOX", nil).Wait()
|
||||
if err != nil {
|
||||
t.Fatalf("select INBOX: %v", err)
|
||||
}
|
||||
if selectData.NumMessages != 1 {
|
||||
t.Fatalf("NumMessages = %d, want 1", selectData.NumMessages)
|
||||
}
|
||||
|
||||
msgs, err := client.Fetch(imap.SeqSetNum(1), &imap.FetchOptions{
|
||||
Envelope: true,
|
||||
Flags: true,
|
||||
BodySection: []*imap.FetchItemBodySection{{}},
|
||||
}).Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("got %d messages, want 1", len(msgs))
|
||||
}
|
||||
msg := msgs[0]
|
||||
if msg.Envelope == nil || msg.Envelope.Subject != "Hello there" {
|
||||
t.Fatalf("envelope subject = %+v, want %q", msg.Envelope, "Hello there")
|
||||
}
|
||||
if len(msg.Envelope.From) != 1 || msg.Envelope.From[0].Addr() != "alice@example.com" {
|
||||
t.Fatalf("envelope from = %+v, want alice@example.com", msg.Envelope.From)
|
||||
}
|
||||
if len(msg.BodySection) != 1 {
|
||||
t.Fatalf("expected one body section, got %d", len(msg.BodySection))
|
||||
}
|
||||
got := string(msg.BodySection[0].Bytes)
|
||||
want := "From: Alice <alice@example.com>\r\nSubject: Hello there\r\n\r\nBody text."
|
||||
if got != want {
|
||||
t.Fatalf("body section = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if err := client.Logout().Wait(); err != nil {
|
||||
t.Fatalf("logout: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMAPLoginRejectsPortalPassword(t *testing.T) {
|
||||
client, email, _, _ := newTestMailboxWithAppPassword(t)
|
||||
|
||||
// The mailbox's own portal password must never work for IMAP login — only an
|
||||
// app password does (see Session.Login).
|
||||
err := client.Login(email, "portal-password-unused").Wait()
|
||||
if err == nil {
|
||||
t.Fatal("expected login with the portal password to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMAPListAndSelectAdditionalFolder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
database, err := db.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
domainID, err := database.CreateDomain("example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := mailstore.New(database, mailstore.GenerateDEK(), t.TempDir())
|
||||
dek := mailstore.GenerateDEK()
|
||||
wrapped, nonce, err := store.WrapDEK(dek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
portalHash, err := db.HashPassword("portal-password-unused")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mailboxID, err := database.CreateMailbox("inbox@example.com", portalHash, domainID, 5*1024*1024*1024, wrapped, nonce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appPassword := db.GenerateAppPassword(25)
|
||||
appHash, err := db.HashPassword(appPassword)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.CreateAppPassword(mailboxID, "test client", appHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := store.StoreMessage(mailboxID, "INBOX", []byte("Subject: normal\r\n\r\nhi"), "<a@example.com>", "a@example.com", "normal"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.StoreMessage(mailboxID, "Spam", []byte("Subject: junk\r\n\r\nspam"), "<b@example.com>", "b@example.com", "junk"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backend := &imapserver.Backend{DB: database, Mailstore: store}
|
||||
srv := imapserver.NewPlainServer(backend)
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go srv.Serve(ln)
|
||||
t.Cleanup(func() { srv.Close() })
|
||||
conn, err := net.Dial("tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := imapclient.New(conn, nil)
|
||||
t.Cleanup(func() { client.Close() })
|
||||
|
||||
if err := client.Login("inbox@example.com", appPassword).Wait(); err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
|
||||
mailboxes, err := client.List("", "%", nil).Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
var names []string
|
||||
for _, m := range mailboxes {
|
||||
names = append(names, m.Mailbox)
|
||||
}
|
||||
if len(names) != 2 {
|
||||
t.Fatalf("expected 2 folders (INBOX, Spam), got %v", names)
|
||||
}
|
||||
|
||||
inboxData, err := client.Select("INBOX", nil).Wait()
|
||||
if err != nil {
|
||||
t.Fatalf("select INBOX: %v", err)
|
||||
}
|
||||
if inboxData.NumMessages != 1 {
|
||||
t.Fatalf("INBOX NumMessages = %d, want 1", inboxData.NumMessages)
|
||||
}
|
||||
|
||||
spamData, err := client.Select("Spam", nil).Wait()
|
||||
if err != nil {
|
||||
t.Fatalf("select Spam: %v", err)
|
||||
}
|
||||
if spamData.NumMessages != 1 {
|
||||
t.Fatalf("Spam NumMessages = %d, want 1", spamData.NumMessages)
|
||||
}
|
||||
|
||||
msgs, err := client.Fetch(imap.SeqSetNum(1), &imap.FetchOptions{Envelope: true}).Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("fetch in Spam: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 || msgs[0].Envelope.Subject != "junk" {
|
||||
t.Fatalf("expected the Spam-folder message (subject %q), got %+v", "junk", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMAPStoreSeenFlag(t *testing.T) {
|
||||
client, email, appPassword, _ := newTestMailboxWithAppPassword(t)
|
||||
|
||||
if err := client.Login(email, appPassword).Wait(); err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if _, err := client.Select("INBOX", nil).Wait(); err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
|
||||
storeFlags := &imap.StoreFlags{Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagSeen}}
|
||||
msgs, err := client.Store(imap.SeqSetNum(1), storeFlags, nil).Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("got %d fetch responses from STORE, want 1", len(msgs))
|
||||
}
|
||||
found := false
|
||||
for _, f := range msgs[0].Flags {
|
||||
if f == imap.FlagSeen {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected \\Seen in returned flags, got %v", msgs[0].Flags)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
|
||||
goimapserver "github.com/emersion/go-imap/v2/imapserver"
|
||||
)
|
||||
|
||||
func newSessionFunc(backend *Backend) func(*goimapserver.Conn) (goimapserver.Session, *goimapserver.GreetingData, error) {
|
||||
return func(c *goimapserver.Conn) (goimapserver.Session, *goimapserver.GreetingData, error) {
|
||||
return backend.NewSession(), nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewPlainServer mirrors smtpserver.NewPlainServer: no TLS at all, so STARTTLS is
|
||||
// never offered. InsecureAuth is required here or LOGIN would always fail with
|
||||
// [PRIVACYREQUIRED] on this listener.
|
||||
func NewPlainServer(backend *Backend) *goimapserver.Server {
|
||||
return goimapserver.New(&goimapserver.Options{
|
||||
NewSession: newSessionFunc(backend),
|
||||
InsecureAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
// NewTLSServer mirrors smtpserver.NewTLSServer: implicit TLS, the whole connection is
|
||||
// encrypted from the first byte. Call ListenAndServeTLS (not ListenAndServe) to run
|
||||
// it — TLSConfig here only takes effect for that method.
|
||||
func NewTLSServer(backend *Backend, tlsConfig *tls.Config) *goimapserver.Server {
|
||||
return goimapserver.New(&goimapserver.Options{
|
||||
NewSession: newSessionFunc(backend),
|
||||
TLSConfig: tlsConfig,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/mail"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
goimapserver "github.com/emersion/go-imap/v2/imapserver"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
const inboxName = "INBOX"
|
||||
|
||||
var _ goimapserver.Session = (*Session)(nil)
|
||||
|
||||
// Session implements goimapserver.Session against one mailbox's messages via
|
||||
// mailstore. Sequence numbers are recomputed fresh from the DB on every command
|
||||
// rather than cached/tracked across concurrent updates.
|
||||
// ponytail: no MailboxTracker/IDLE push support — Idle just blocks until the client
|
||||
// sends DONE, so a connected client still gets new mail via NOOP/periodic re-SELECT,
|
||||
// just not an instant push. Add a tracker if that matters.
|
||||
type Session struct {
|
||||
backend *Backend
|
||||
mailbox *db.Mailbox // set once Login succeeds
|
||||
selectedFolder string // set by Select; defaults to INBOX if empty
|
||||
}
|
||||
|
||||
func (s *Session) Close() error { return nil }
|
||||
|
||||
// Login accepts only an app password (esrv_mailbox_app_passwords) — never the
|
||||
// mailbox's own portal password, since IMAP AUTH has no interactive MFA step.
|
||||
func (s *Session) Login(username, password string) error {
|
||||
mbox, err := s.backend.DB.VerifyMailboxAppPassword(username, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mbox == nil {
|
||||
return goimapserver.ErrAuthFailed
|
||||
}
|
||||
s.mailbox = mbox
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) requireAuth() error {
|
||||
if s.mailbox == nil {
|
||||
return errors.New("not authenticated")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func notFoundErr() error {
|
||||
return &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeNonExistent, Text: "No such mailbox"}
|
||||
}
|
||||
|
||||
func isInbox(name string) bool { return strings.EqualFold(name, inboxName) }
|
||||
|
||||
// folderExists reports whether name is a real folder for this mailbox — INBOX always
|
||||
// is (even empty), any other name only if a filter rule's move_to_folder action has
|
||||
// actually delivered something there (see mailstore's ApplyRules). Returns the
|
||||
// canonical stored name (case as written in the DB) so callers use it consistently.
|
||||
func (s *Session) folderExists(name string) (string, bool, error) {
|
||||
if isInbox(name) {
|
||||
return inboxName, true, nil
|
||||
}
|
||||
folders, err := s.backend.DB.DistinctFoldersForMailbox(s.mailbox.ID)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
for _, f := range folders {
|
||||
if strings.EqualFold(f, name) {
|
||||
return f, true, nil
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// messages loads every stored message in the currently selected folder, ordered
|
||||
// ascending by UID — this ordering IS the sequence-number mapping (index+1 == seqNum).
|
||||
func (s *Session) messages() ([]db.MailboxMessage, error) {
|
||||
folder := s.selectedFolder
|
||||
if folder == "" {
|
||||
folder = inboxName
|
||||
}
|
||||
return s.backend.DB.ListMessagesInFolder(s.mailbox.ID, folder)
|
||||
}
|
||||
|
||||
func (s *Session) Select(mailbox string, options *imap.SelectOptions) (*imap.SelectData, error) {
|
||||
if err := s.requireAuth(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
folder, ok, err := s.folderExists(mailbox)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, notFoundErr()
|
||||
}
|
||||
s.selectedFolder = folder
|
||||
msgs, err := s.messages()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
flagSet := map[imap.Flag]struct{}{}
|
||||
var firstUnseen uint32
|
||||
for i, m := range msgs {
|
||||
for _, f := range splitFlags(m.Flags) {
|
||||
flagSet[f] = struct{}{}
|
||||
}
|
||||
if firstUnseen == 0 && !hasFlag(m.Flags, imap.FlagSeen) {
|
||||
firstUnseen = uint32(i) + 1
|
||||
}
|
||||
}
|
||||
var flags []imap.Flag
|
||||
for f := range flagSet {
|
||||
flags = append(flags, f)
|
||||
}
|
||||
sort.Slice(flags, func(i, j int) bool { return flags[i] < flags[j] })
|
||||
permanent := append(append([]imap.Flag{}, flags...), imap.FlagWildcard)
|
||||
|
||||
return &imap.SelectData{
|
||||
Flags: flags,
|
||||
PermanentFlags: permanent,
|
||||
NumMessages: uint32(len(msgs)),
|
||||
FirstUnseenSeqNum: firstUnseen,
|
||||
UIDNext: nextUID(msgs),
|
||||
UIDValidity: uint32(s.mailbox.ID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Session) Unselect() error {
|
||||
s.selectedFolder = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) Create(mailbox string, options *imap.CreateOptions) error {
|
||||
return errors.New("creating mailboxes is not supported")
|
||||
}
|
||||
|
||||
func (s *Session) Delete(mailbox string) error {
|
||||
return errors.New("deleting mailboxes is not supported")
|
||||
}
|
||||
|
||||
func (s *Session) Rename(mailbox, newName string, options *imap.RenameOptions) error {
|
||||
return errors.New("renaming mailboxes is not supported")
|
||||
}
|
||||
|
||||
func (s *Session) Subscribe(mailbox string) error {
|
||||
_, ok, err := s.folderExists(mailbox)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return notFoundErr()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) Unsubscribe(mailbox string) error {
|
||||
_, ok, err := s.folderExists(mailbox)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return notFoundErr()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List reports every folder that actually has mail (plus INBOX, always) — a filter
|
||||
// rule's move_to_folder action is what creates a second folder; there's no IMAP
|
||||
// CREATE/manual folder management.
|
||||
func (s *Session) List(w *goimapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error {
|
||||
if err := s.requireAuth(); err != nil {
|
||||
return err
|
||||
}
|
||||
folders, err := s.backend.DB.DistinctFoldersForMailbox(s.mailbox.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(patterns) == 0 {
|
||||
patterns = []string{""}
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
for _, folder := range folders {
|
||||
if !goimapserver.MatchList(folder, '/', ref, pattern) {
|
||||
continue
|
||||
}
|
||||
if err := w.WriteList(&imap.ListData{Mailbox: folder, Delim: '/'}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) Status(mailbox string, options *imap.StatusOptions) (*imap.StatusData, error) {
|
||||
if err := s.requireAuth(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
folder, ok, err := s.folderExists(mailbox)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, notFoundErr()
|
||||
}
|
||||
msgs, err := s.backend.DB.ListMessagesInFolder(s.mailbox.ID, folder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := &imap.StatusData{Mailbox: folder, UIDValidity: uint32(s.mailbox.ID), UIDNext: nextUID(msgs)}
|
||||
if options.NumMessages {
|
||||
n := uint32(len(msgs))
|
||||
data.NumMessages = &n
|
||||
}
|
||||
if options.NumUnseen {
|
||||
var n uint32
|
||||
for _, m := range msgs {
|
||||
if !hasFlag(m.Flags, imap.FlagSeen) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
data.NumUnseen = &n
|
||||
}
|
||||
if options.Size {
|
||||
var size int64
|
||||
for _, m := range msgs {
|
||||
size += m.SizeBytes
|
||||
}
|
||||
data.Size = &size
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *Session) Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) {
|
||||
return nil, errors.New("APPEND is not supported yet")
|
||||
}
|
||||
|
||||
func (s *Session) Poll(w *goimapserver.UpdateWriter, allowExpunge bool) error { return nil }
|
||||
|
||||
func (s *Session) Idle(w *goimapserver.UpdateWriter, stop <-chan struct{}) error {
|
||||
<-stop
|
||||
return nil
|
||||
}
|
||||
|
||||
// Expunge permanently removes every \Deleted-flagged message matched by uids (or all
|
||||
// of them, if uids is nil) — this is how a client actually deletes mail (STORE
|
||||
// \Deleted, then EXPUNGE), calling all the way down to mailstore so the on-disk
|
||||
// ciphertext is removed too, not just the index row.
|
||||
func (s *Session) Expunge(w *goimapserver.ExpungeWriter, uids *imap.UIDSet) error {
|
||||
if err := s.requireAuth(); err != nil {
|
||||
return err
|
||||
}
|
||||
msgs, err := s.messages()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
m := msgs[i]
|
||||
if uids != nil && !uids.Contains(imap.UID(m.ID)) {
|
||||
continue
|
||||
}
|
||||
if !hasFlag(m.Flags, imap.FlagDeleted) {
|
||||
continue
|
||||
}
|
||||
if err := s.backend.Mailstore.DeleteMessage(s.mailbox.ID, m.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteExpunge(uint32(i) + 1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search supports structural criteria (sequence/UID sets, flags, size, boolean
|
||||
// combinators) without decrypting anything.
|
||||
// ponytail: no header/body/text/date matching (would require decrypting every
|
||||
// candidate message) — SEARCH FROM/SUBJECT/BODY/SINCE etc. are treated as always
|
||||
// matching rather than filtering. Add if a client's search relies on it.
|
||||
func (s *Session) Search(kind goimapserver.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) {
|
||||
if err := s.requireAuth(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msgs, err := s.messages()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data imap.SearchData
|
||||
var seqSet imap.SeqSet
|
||||
var uidSet imap.UIDSet
|
||||
for i, m := range msgs {
|
||||
seqNum := uint32(i) + 1
|
||||
if !matchesSearch(m, seqNum, criteria) {
|
||||
continue
|
||||
}
|
||||
uidSet.AddNum(imap.UID(m.ID))
|
||||
seqSet.AddNum(seqNum)
|
||||
data.Count++
|
||||
num := seqNum
|
||||
if kind == goimapserver.NumKindUID {
|
||||
num = uint32(m.ID)
|
||||
}
|
||||
if data.Min == 0 || num < data.Min {
|
||||
data.Min = num
|
||||
}
|
||||
if num > data.Max {
|
||||
data.Max = num
|
||||
}
|
||||
}
|
||||
if kind == goimapserver.NumKindUID {
|
||||
data.All = uidSet
|
||||
} else {
|
||||
data.All = seqSet
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func matchesSearch(m db.MailboxMessage, seqNum uint32, c *imap.SearchCriteria) bool {
|
||||
if c == nil {
|
||||
return true
|
||||
}
|
||||
for _, ss := range c.SeqNum {
|
||||
if !ss.Contains(seqNum) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, us := range c.UID {
|
||||
if !us.Contains(imap.UID(m.ID)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, f := range c.Flag {
|
||||
if !hasFlag(m.Flags, f) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, f := range c.NotFlag {
|
||||
if hasFlag(m.Flags, f) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if c.Larger > 0 && m.SizeBytes <= c.Larger {
|
||||
return false
|
||||
}
|
||||
if c.Smaller > 0 && m.SizeBytes >= c.Smaller {
|
||||
return false
|
||||
}
|
||||
for _, not := range c.Not {
|
||||
if matchesSearch(m, seqNum, ¬) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, or := range c.Or {
|
||||
if !matchesSearch(m, seqNum, &or[0]) && !matchesSearch(m, seqNum, &or[1]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Fetch streams FETCH responses for every message matched by numSet. Fetching a
|
||||
// non-peek body section marks the message \Seen, mirroring standard IMAP semantics.
|
||||
func (s *Session) Fetch(w *goimapserver.FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error {
|
||||
if err := s.requireAuth(); err != nil {
|
||||
return err
|
||||
}
|
||||
msgs, err := s.messages()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
markSeen := false
|
||||
for _, bs := range options.BodySection {
|
||||
if !bs.Peek {
|
||||
markSeen = true
|
||||
}
|
||||
}
|
||||
for i, m := range msgs {
|
||||
seqNum := uint32(i) + 1
|
||||
if !numSetContains(numSet, seqNum, imap.UID(m.ID)) {
|
||||
continue
|
||||
}
|
||||
if markSeen && !hasFlag(m.Flags, imap.FlagSeen) {
|
||||
newFlags := addFlag(m.Flags, imap.FlagSeen)
|
||||
if err := s.backend.DB.SetMessageFlags(s.mailbox.ID, m.ID, newFlags); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Flags = newFlags
|
||||
}
|
||||
if err := s.writeFetch(w.CreateMessage(seqNum), m, options); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeFetch writes one message's FETCH response.
|
||||
// ponytail: no MIME-aware sub-part/header-only body-section extraction — any
|
||||
// requested BODY[...] section returns the full raw RFC822 message regardless of the
|
||||
// requested part/specifier. Real clients' basic "fetch the whole message" flow works
|
||||
// fine with this; upgrade to real section extraction if header-only/sub-part fetches
|
||||
// are needed later.
|
||||
func (s *Session) writeFetch(w *goimapserver.FetchResponseWriter, m db.MailboxMessage, options *imap.FetchOptions) error {
|
||||
w.WriteUID(imap.UID(m.ID))
|
||||
if options.Flags {
|
||||
w.WriteFlags(splitFlags(m.Flags))
|
||||
}
|
||||
if options.InternalDate {
|
||||
w.WriteInternalDate(m.InternalDate)
|
||||
}
|
||||
if options.RFC822Size {
|
||||
w.WriteRFC822Size(m.SizeBytes)
|
||||
}
|
||||
if options.Envelope {
|
||||
w.WriteEnvelope(buildEnvelope(m))
|
||||
}
|
||||
if len(options.BodySection) > 0 {
|
||||
raw, err := s.backend.Mailstore.FetchMessage(s.mailbox.ID, m.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, bs := range options.BodySection {
|
||||
wc := w.WriteBodySection(bs, int64(len(raw)))
|
||||
if _, werr := wc.Write(raw); werr != nil {
|
||||
wc.Close()
|
||||
return werr
|
||||
}
|
||||
if cerr := wc.Close(); cerr != nil {
|
||||
return cerr
|
||||
}
|
||||
}
|
||||
}
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
func buildEnvelope(m db.MailboxMessage) *imap.Envelope {
|
||||
env := &imap.Envelope{Date: m.InternalDate, Subject: m.CachedSubject, MessageID: m.MessageIDHeader}
|
||||
if addr, err := mail.ParseAddress(m.CachedFrom); err == nil {
|
||||
mailbox, host := splitAddr(addr.Address)
|
||||
env.From = []imap.Address{{Name: addr.Name, Mailbox: mailbox, Host: host}}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func splitAddr(addr string) (mailbox, host string) {
|
||||
i := strings.LastIndex(addr, "@")
|
||||
if i < 0 {
|
||||
return addr, ""
|
||||
}
|
||||
return addr[:i], addr[i+1:]
|
||||
}
|
||||
|
||||
// Store applies a flag change to every message matched by numSet, then (unless
|
||||
// .SILENT was requested) reports the resulting flags back via a FETCH response,
|
||||
// mirroring standard STORE semantics.
|
||||
func (s *Session) Store(w *goimapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error {
|
||||
if err := s.requireAuth(); err != nil {
|
||||
return err
|
||||
}
|
||||
msgs, err := s.messages()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, m := range msgs {
|
||||
seqNum := uint32(i) + 1
|
||||
if !numSetContains(numSet, seqNum, imap.UID(m.ID)) {
|
||||
continue
|
||||
}
|
||||
newFlags := applyStoreFlags(m.Flags, flags)
|
||||
if err := s.backend.DB.SetMessageFlags(s.mailbox.ID, m.ID, newFlags); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if flags.Silent {
|
||||
return nil
|
||||
}
|
||||
return s.Fetch(w, numSet, &imap.FetchOptions{Flags: true})
|
||||
}
|
||||
|
||||
func (s *Session) Copy(numSet imap.NumSet, dest string) (*imap.CopyData, error) {
|
||||
return nil, errors.New("COPY is not supported")
|
||||
}
|
||||
|
||||
func nextUID(msgs []db.MailboxMessage) imap.UID {
|
||||
if len(msgs) == 0 {
|
||||
return 1
|
||||
}
|
||||
return imap.UID(msgs[len(msgs)-1].ID) + 1
|
||||
}
|
||||
|
||||
func numSetContains(numSet imap.NumSet, seqNum uint32, uid imap.UID) bool {
|
||||
switch ns := numSet.(type) {
|
||||
case imap.SeqSet:
|
||||
return ns.Contains(seqNum)
|
||||
case imap.UIDSet:
|
||||
return ns.Contains(uid)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitFlags(s string) []imap.Flag {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Fields(s)
|
||||
out := make([]imap.Flag, len(parts))
|
||||
for i, p := range parts {
|
||||
out[i] = imap.Flag(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasFlag(flags string, target imap.Flag) bool {
|
||||
for _, f := range strings.Fields(flags) {
|
||||
if imap.Flag(f) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func addFlag(flags string, f imap.Flag) string {
|
||||
if hasFlag(flags, f) {
|
||||
return flags
|
||||
}
|
||||
if flags == "" {
|
||||
return string(f)
|
||||
}
|
||||
return flags + " " + string(f)
|
||||
}
|
||||
|
||||
func applyStoreFlags(current string, store *imap.StoreFlags) string {
|
||||
set := map[imap.Flag]struct{}{}
|
||||
for _, f := range splitFlags(current) {
|
||||
set[f] = struct{}{}
|
||||
}
|
||||
switch store.Op {
|
||||
case imap.StoreFlagsSet:
|
||||
set = map[imap.Flag]struct{}{}
|
||||
fallthrough
|
||||
case imap.StoreFlagsAdd:
|
||||
for _, f := range store.Flags {
|
||||
set[f] = struct{}{}
|
||||
}
|
||||
case imap.StoreFlagsDel:
|
||||
for _, f := range store.Flags {
|
||||
delete(set, f)
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(set))
|
||||
for f := range set {
|
||||
out = append(out, string(f))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Package mailstore handles local mailbox storage: per-mailbox encryption at rest
|
||||
// (a random AES-256 key per mailbox, sealed with one server-held master key so a raw
|
||||
// DB/backup theft alone can't decrypt mail — see MasterKey below), on-disk ciphertext
|
||||
// layout, and quota accounting. Message retrieval/IMAP serving is a later milestone;
|
||||
// this package is the storage engine underneath it.
|
||||
package mailstore
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// masterKeySize is 32 bytes (AES-256).
|
||||
const masterKeySize = 32
|
||||
|
||||
// LoadOrCreateMasterKey reads the server's master encryption key from path, generating
|
||||
// a fresh random one on first run if the file doesn't exist yet — mirrors
|
||||
// tlsutil.GenerateSelfSignedCert's generate-if-missing pattern. This file must be
|
||||
// backed up separately from the database: losing it makes every stored mailbox's mail
|
||||
// permanently unrecoverable, even for admins.
|
||||
func LoadOrCreateMasterKey(path string) ([]byte, error) {
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
if len(b) != masterKeySize {
|
||||
return nil, fmt.Errorf("master key at %s is %d bytes, want %d", path, len(b), masterKeySize)
|
||||
}
|
||||
return b, nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := make([]byte, masterKeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(path, key, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Store is the local mailbox storage engine: one per running server, shared across
|
||||
// connections (analogous to smtpserver.Backend).
|
||||
type Store struct {
|
||||
DB *db.DB
|
||||
MasterKey []byte
|
||||
BasePath string
|
||||
}
|
||||
|
||||
func New(database *db.DB, masterKey []byte, basePath string) *Store {
|
||||
return &Store{DB: database, MasterKey: masterKey, BasePath: basePath}
|
||||
}
|
||||
|
||||
// GenerateDEK returns a fresh random AES-256 data encryption key for one mailbox.
|
||||
func GenerateDEK() []byte {
|
||||
dek := make([]byte, masterKeySize)
|
||||
rand.Read(dek)
|
||||
return dek
|
||||
}
|
||||
|
||||
// WrapDEK seals dek with the server master key, returning the ciphertext and the
|
||||
// nonce used for that one seal operation (both stored on the mailbox row).
|
||||
func (s *Store) WrapDEK(dek []byte) (wrapped, nonce []byte, err error) {
|
||||
return sealAESGCM(s.MasterKey, dek)
|
||||
}
|
||||
|
||||
// UnwrapDEK reverses WrapDEK.
|
||||
func (s *Store) UnwrapDEK(wrapped, nonce []byte) ([]byte, error) {
|
||||
return openAESGCM(s.MasterKey, wrapped, nonce)
|
||||
}
|
||||
|
||||
func sealAESGCM(key, plaintext []byte) (ciphertext, nonce []byte, err error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
nonce = make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return gcm.Seal(nil, nonce, plaintext, nil), nonce, nil
|
||||
}
|
||||
|
||||
func openAESGCM(key, ciphertext, nonce []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(nonce) != gcm.NonceSize() {
|
||||
return nil, errors.New("mailstore: invalid nonce size")
|
||||
}
|
||||
return gcm.Open(nil, nonce, ciphertext, nil)
|
||||
}
|
||||
|
||||
// sanitizePathSegment neuters filesystem-unsafe characters in a mailbox email address
|
||||
// so it can be used directly as a directory name, mirroring
|
||||
// smtpserver.sanitizePathSegment's spirit (that one only handles domains; this one
|
||||
// also strips "@" and ":" since a full address is used here, not just a domain).
|
||||
func sanitizePathSegment(s string) string {
|
||||
for _, c := range []string{"/", "\\", ":", "@"} {
|
||||
s = strings.ReplaceAll(s, c, "_")
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package mailstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) *db.DB {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
database, err := db.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
return database
|
||||
}
|
||||
|
||||
// newTestMailbox creates a domain + mailbox with a real wrapped DEK, using a Store
|
||||
// built against a random master key, and returns both.
|
||||
func newTestMailbox(t *testing.T, quotaBytes int64) (*Store, int64) {
|
||||
t.Helper()
|
||||
database := newTestDB(t)
|
||||
domainID, err := database.CreateDomain("example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
masterKey := GenerateDEK() // 32 random bytes, reused here as a throwaway master key
|
||||
s := New(database, masterKey, t.TempDir())
|
||||
|
||||
dek := GenerateDEK()
|
||||
wrapped, nonce, err := s.WrapDEK(dek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := db.HashPassword("irrelevant-portal-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mailboxID, err := database.CreateMailbox("user@example.com", hash, domainID, quotaBytes, wrapped, nonce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, mailboxID
|
||||
}
|
||||
|
||||
func TestWrapUnwrapDEK(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
s := New(database, GenerateDEK(), t.TempDir())
|
||||
|
||||
dek := GenerateDEK()
|
||||
wrapped, nonce, err := s.WrapDEK(dek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.UnwrapDEK(wrapped, nonce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(dek, got) {
|
||||
t.Fatalf("unwrapped DEK does not match original: got %x, want %x", got, dek)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreFetchRoundTrip(t *testing.T) {
|
||||
s, mailboxID := newTestMailbox(t, 1024*1024)
|
||||
raw := []byte("From: a@example.com\r\nSubject: hi\r\n\r\nhello world")
|
||||
|
||||
uid, err := s.StoreMessage(mailboxID, "INBOX", raw, "<abc@example.com>", "a@example.com", "hi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := s.FetchMessage(mailboxID, uid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(raw, got) {
|
||||
t.Fatalf("fetched message does not match stored: got %q, want %q", got, raw)
|
||||
}
|
||||
|
||||
msg, err := s.DB.GetMessageByUID(mailboxID, uid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
onDisk, err := os.ReadFile(msg.StoragePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Equal(onDisk, raw) {
|
||||
t.Fatal("on-disk file matches plaintext — message was not actually encrypted")
|
||||
}
|
||||
|
||||
mbox, err := s.DB.GetMailboxByID(mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mbox.UsedBytes != int64(len(raw)) {
|
||||
t.Fatalf("used_bytes = %d, want %d", mbox.UsedBytes, len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotaExceeded(t *testing.T) {
|
||||
s, mailboxID := newTestMailbox(t, 10) // tiny quota
|
||||
raw := []byte("this message is definitely longer than ten bytes")
|
||||
|
||||
_, err := s.StoreMessage(mailboxID, "INBOX", raw, "<abc@example.com>", "a@example.com", "hi")
|
||||
if err != ErrQuotaExceeded {
|
||||
t.Fatalf("err = %v, want ErrQuotaExceeded", err)
|
||||
}
|
||||
|
||||
mbox, err := s.DB.GetMailboxByID(mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mbox.UsedBytes != 0 {
|
||||
t.Fatalf("used_bytes = %d after a rejected store, want 0", mbox.UsedBytes)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(s.BasePath)
|
||||
if err == nil && len(entries) != 0 {
|
||||
t.Fatalf("expected no files written under %s after a rejected store, found %d entries", s.BasePath, len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMessageFreesQuota(t *testing.T) {
|
||||
s, mailboxID := newTestMailbox(t, 1024*1024)
|
||||
raw := []byte("From: a@example.com\r\nSubject: bye\r\n\r\ngoodbye")
|
||||
|
||||
uid, err := s.StoreMessage(mailboxID, "INBOX", raw, "<def@example.com>", "a@example.com", "bye")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg, err := s.DB.GetMessageByUID(mailboxID, uid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
storagePath := msg.StoragePath
|
||||
|
||||
if err := s.DeleteMessage(mailboxID, uid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mbox, err := s.DB.GetMailboxByID(mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mbox.UsedBytes != 0 {
|
||||
t.Fatalf("used_bytes = %d after delete, want 0", mbox.UsedBytes)
|
||||
}
|
||||
if _, err := os.Stat(storagePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("ciphertext file %s still exists after delete", storagePath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package mailstore
|
||||
|
||||
import "fmt"
|
||||
|
||||
// QuotaStatus reports a mailbox's storage usage — feeds the "≥90% full" badge on the
|
||||
// mailboxes list and the dashboard tile in a later milestone.
|
||||
func (s *Store) QuotaStatus(mailboxID int64) (used, quota int64, pctFull float64, err error) {
|
||||
mbox, err := s.DB.GetMailboxByID(mailboxID)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
if mbox == nil {
|
||||
return 0, 0, 0, fmt.Errorf("mailstore: mailbox %d not found", mailboxID)
|
||||
}
|
||||
if mbox.QuotaBytes == 0 {
|
||||
return mbox.UsedBytes, 0, 0, nil
|
||||
}
|
||||
return mbox.UsedBytes, mbox.QuotaBytes, float64(mbox.UsedBytes) / float64(mbox.QuotaBytes) * 100, nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package mailstore
|
||||
|
||||
import "mailgoserver/internal/db"
|
||||
|
||||
// ResolveRecipient looks up a local mailbox for addr — its primary email first, then
|
||||
// any active alias — so mail sent to an alias lands in the owning mailbox's INBOX.
|
||||
func (s *Store) ResolveRecipient(addr string) (*db.Mailbox, error) {
|
||||
mbox, err := s.DB.GetMailboxByEmail(addr)
|
||||
if err != nil || mbox != nil {
|
||||
return mbox, err
|
||||
}
|
||||
alias, err := s.DB.GetAliasByEmail(addr)
|
||||
if err != nil || alias == nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.DB.GetMailboxByID(alias.MailboxID)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package mailstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CheckRspamd sends a message to an optional rspamd instance for scoring, only called
|
||||
// when [Rspamd] enabled=true — the built-in SpamScore heuristic (spam.go) always runs
|
||||
// regardless, so this is additive, not a replacement.
|
||||
func CheckRspamd(url string, raw []byte, mailFrom, rcptTo string) (score float64, action string, err error) {
|
||||
req, err := http.NewRequest(http.MethodPost, url+"/checkv2", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
req.Header.Set("From", mailFrom)
|
||||
req.Header.Set("Rcpt", rcptTo)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Score float64 `json:"score"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
return result.Score, result.Action, nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package mailstore
|
||||
|
||||
import "strings"
|
||||
|
||||
// FilterAction is the outcome of evaluating a mailbox's filter rules against one
|
||||
// incoming message.
|
||||
type FilterAction struct {
|
||||
Folder string // non-empty: store here instead of INBOX
|
||||
MarkRead bool
|
||||
Drop bool // don't store at all
|
||||
}
|
||||
|
||||
// ApplyRules evaluates a mailbox's filter rules in priority order and returns the
|
||||
// first match's action (zero value if none match, meaning "store in INBOX, unread").
|
||||
// headers should have "from"/"to"/"subject" keys — rules run at delivery time, before
|
||||
// the message is encrypted and stored, so real header values are available, not just
|
||||
// the plaintext cache columns used for fast IMAP listing.
|
||||
func (s *Store) ApplyRules(mailboxID int64, headers map[string]string) (FilterAction, error) {
|
||||
rules, err := s.DB.ListRulesForMailbox(mailboxID)
|
||||
if err != nil {
|
||||
return FilterAction{}, err
|
||||
}
|
||||
for _, r := range rules {
|
||||
if !r.IsActive {
|
||||
continue
|
||||
}
|
||||
if !matchCondition(r.ConditionOp, headers[r.ConditionField], r.ConditionValue) {
|
||||
continue
|
||||
}
|
||||
switch r.Action {
|
||||
case "move_to_folder":
|
||||
return FilterAction{Folder: r.ActionValue}, nil
|
||||
case "delete":
|
||||
return FilterAction{Drop: true}, nil
|
||||
case "mark_read":
|
||||
return FilterAction{MarkRead: true}, nil
|
||||
}
|
||||
}
|
||||
return FilterAction{}, nil
|
||||
}
|
||||
|
||||
func matchCondition(op, value, target string) bool {
|
||||
value = strings.ToLower(value)
|
||||
target = strings.ToLower(target)
|
||||
switch op {
|
||||
case "contains":
|
||||
return strings.Contains(value, target)
|
||||
case "equals":
|
||||
return value == target
|
||||
case "starts_with":
|
||||
return strings.HasPrefix(value, target)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package mailstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// spamKeywords is a tiny, obvious-spam subject keyword list — a coarse signal only.
|
||||
var spamKeywords = []string{"viagra", "casino", "lottery winner", "click here now", "wire transfer urgent", "nigerian prince"}
|
||||
|
||||
// SpamScore is a lightweight built-in heuristic — always runs, regardless of whether
|
||||
// rspamd (rspamd.go) is also enabled; both are additive, not either/or. Higher is more
|
||||
// suspicious; compare against [Mailstore] spam_reject_score.
|
||||
// ponytail: naive keyword/weight heuristic, not a real Bayesian/ML scorer — upgrade or
|
||||
// lean harder on rspamd if false-positive rate matters.
|
||||
func SpamScore(peerIP string, headers map[string]string, dkimPass, spfPass bool) int {
|
||||
score := 0
|
||||
if !spfPass {
|
||||
score += 2
|
||||
}
|
||||
if !dkimPass {
|
||||
score++
|
||||
}
|
||||
if CheckDNSBL(peerIP) {
|
||||
score += 5
|
||||
}
|
||||
subject := strings.ToLower(headers["subject"])
|
||||
for _, kw := range spamKeywords {
|
||||
if strings.Contains(subject, kw) {
|
||||
score++
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
// CheckDNSBL looks up peerIP against the Spamhaus ZEN DNSBL. Per RFC 5782, a listing
|
||||
// response is always an A record in 127.0.0.0/8 — checking for that range (rather than
|
||||
// "any resolution succeeded") avoids false positives from a resolver that hijacks
|
||||
// NXDOMAIN into a search/ad page instead of returning an error.
|
||||
func CheckDNSBL(peerIP string) bool {
|
||||
ip := net.ParseIP(peerIP)
|
||||
if ip == nil || ip.To4() == nil {
|
||||
return false
|
||||
}
|
||||
octets := strings.Split(ip.To4().String(), ".")
|
||||
reversed := octets[3] + "." + octets[2] + "." + octets[1] + "." + octets[0]
|
||||
addrs, err := net.DefaultResolver.LookupHost(context.Background(), reversed+".zen.spamhaus.org")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if resolved := net.ParseIP(a); resolved != nil && resolved.To4() != nil && resolved.To4()[0] == 127 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package mailstore
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSpamScoreArithmetic exercises SpamScore's own weighting logic with synthetic
|
||||
// dkimPass/spfPass inputs — CheckDNSBL still runs (it does live DNS), so this only
|
||||
// asserts the score is monotonically at least as high when signals get worse, rather
|
||||
// than pinning an exact network-dependent number.
|
||||
func TestSpamScoreArithmetic(t *testing.T) {
|
||||
clean := SpamScore("203.0.113.1", map[string]string{"subject": "hello"}, true, true)
|
||||
noDKIM := SpamScore("203.0.113.1", map[string]string{"subject": "hello"}, false, true)
|
||||
noSPF := SpamScore("203.0.113.1", map[string]string{"subject": "hello"}, true, false)
|
||||
keyword := SpamScore("203.0.113.1", map[string]string{"subject": "WIN THE LOTTERY WINNER NOW"}, true, true)
|
||||
|
||||
if noDKIM <= clean {
|
||||
t.Fatalf("missing DKIM should raise the score: clean=%d noDKIM=%d", clean, noDKIM)
|
||||
}
|
||||
if noSPF <= clean {
|
||||
t.Fatalf("failing SPF should raise the score: clean=%d noSPF=%d", clean, noSPF)
|
||||
}
|
||||
if keyword <= clean {
|
||||
t.Fatalf("a spam keyword in the subject should raise the score: clean=%d keyword=%d", clean, keyword)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalSPF(t *testing.T) {
|
||||
ip := net.ParseIP("203.0.113.10")
|
||||
other := net.ParseIP("198.51.100.5")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record string
|
||||
ip net.IP
|
||||
want bool
|
||||
}{
|
||||
{"ip4 match passes", "v=spf1 ip4:203.0.113.0/24 -all", ip, true},
|
||||
{"ip4 no match hard fails", "v=spf1 ip4:203.0.113.0/24 -all", other, false},
|
||||
{"no all and no match is neutral", "v=spf1 ip4:203.0.113.0/24", other, true},
|
||||
{"soft fail all is neutral for unmatched ip", "v=spf1 ip4:203.0.113.0/24 ~all", other, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := evalSPF(tt.record, tt.ip, "example.com", 0)
|
||||
if got != tt.want {
|
||||
t.Fatalf("evalSPF(%q) = %v, want %v", tt.record, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package mailstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CheckSPF is a minimal, single-level SPF check (v=spf1 ip4:/a/mx/include:, no
|
||||
// recursive include/redirect, no macro expansion) against the sender domain's TXT
|
||||
// record — one signal feeding the spam heuristic in spam.go, not an authoritative
|
||||
// pass/fail gate.
|
||||
// ponytail: not full RFC 7208 (no multi-level includes, no redirect, no macros) —
|
||||
// good enough as a signal, revisit if a real sender's SPF record depends on it.
|
||||
func CheckSPF(mailFrom, peerIP string) bool {
|
||||
domain := domainOf(mailFrom)
|
||||
if domain == "" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(peerIP)
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
record, ok := lookupSPFRecord(domain)
|
||||
if !ok {
|
||||
return true // no SPF record published: neutral, not a penalty
|
||||
}
|
||||
return evalSPF(record, ip, domain, 0)
|
||||
}
|
||||
|
||||
func domainOf(address string) string {
|
||||
i := strings.LastIndex(address, "@")
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(address[i+1:])
|
||||
}
|
||||
|
||||
func lookupSPFRecord(domain string) (string, bool) {
|
||||
txts, err := net.DefaultResolver.LookupTXT(context.Background(), domain)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
for _, t := range txts {
|
||||
if strings.HasPrefix(strings.ToLower(t), "v=spf1") {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// evalSPF walks mechanisms left to right; depth caps includes at one level.
|
||||
func evalSPF(record string, ip net.IP, domain string, depth int) bool {
|
||||
fields := strings.Fields(record)
|
||||
for _, f := range fields[1:] { // skip "v=spf1"
|
||||
qualifier := byte('+')
|
||||
mech := f
|
||||
if len(f) > 0 && strings.ContainsRune("+-~?", rune(f[0])) {
|
||||
qualifier = f[0]
|
||||
mech = f[1:]
|
||||
}
|
||||
switch {
|
||||
case mech == "all":
|
||||
return qualifier != '-'
|
||||
case strings.HasPrefix(mech, "ip4:"):
|
||||
if matchIP4(mech[4:], ip) {
|
||||
return qualifier != '-'
|
||||
}
|
||||
case mech == "a":
|
||||
if matchA(domain, ip) {
|
||||
return qualifier != '-'
|
||||
}
|
||||
case strings.HasPrefix(mech, "a:"):
|
||||
if matchA(mech[2:], ip) {
|
||||
return qualifier != '-'
|
||||
}
|
||||
case mech == "mx":
|
||||
if matchMX(domain, ip) {
|
||||
return qualifier != '-'
|
||||
}
|
||||
case strings.HasPrefix(mech, "mx:"):
|
||||
if matchMX(mech[3:], ip) {
|
||||
return qualifier != '-'
|
||||
}
|
||||
case strings.HasPrefix(mech, "include:") && depth == 0:
|
||||
sub, ok := lookupSPFRecord(mech[len("include:"):])
|
||||
if ok && evalSPF(sub, ip, mech[len("include:"):], depth+1) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true // no matching mechanism and no explicit "all": neutral
|
||||
}
|
||||
|
||||
func matchIP4(cidr string, ip net.IP) bool {
|
||||
if !strings.Contains(cidr, "/") {
|
||||
cidr += "/32"
|
||||
}
|
||||
_, network, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return network.Contains(ip)
|
||||
}
|
||||
|
||||
func matchA(host string, ip net.IP) bool {
|
||||
ips, err := net.DefaultResolver.LookupIP(context.Background(), "ip4", host)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, a := range ips {
|
||||
if a.Equal(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchMX(domain string, ip net.IP) bool {
|
||||
mxs, err := net.LookupMX(domain)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, mx := range mxs {
|
||||
if matchA(strings.TrimSuffix(mx.Host, "."), ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package mailstore
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrQuotaExceeded is returned by StoreMessage when storing raw would push the
|
||||
// mailbox over its quota. No row, file, or used_bytes change occurs in that case.
|
||||
var ErrQuotaExceeded = errors.New("mailstore: mailbox quota exceeded")
|
||||
|
||||
// StoreMessage encrypts raw with the mailbox's own data encryption key and persists
|
||||
// it to disk, then indexes it in esrv_mailbox_messages and updates the mailbox's
|
||||
// cached used_bytes. from/subject are cached in the DB in plain text by design (see
|
||||
// schema.go) so IMAP LIST/basic SEARCH don't need to decrypt every message.
|
||||
func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, messageIDHeader, from, subject string) (uid int64, err error) {
|
||||
mbox, err := s.DB.GetMailboxByID(mailboxID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if mbox == nil {
|
||||
return 0, fmt.Errorf("mailstore: mailbox %d not found", mailboxID)
|
||||
}
|
||||
if mbox.UsedBytes+int64(len(raw)) > mbox.QuotaBytes {
|
||||
return 0, ErrQuotaExceeded
|
||||
}
|
||||
|
||||
dek, err := s.UnwrapDEK(mbox.DEKWrapped, mbox.DEKNonce)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ciphertext, nonce, err := sealAESGCM(dek, raw)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
dir := filepath.Join(s.BasePath, sanitizePathSegment(mbox.Email), folder, now.Format("2006-02-Jan"))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
name := make([]byte, 8)
|
||||
rand.Read(name)
|
||||
storagePath := filepath.Join(dir, hex.EncodeToString(name)+".eml.enc")
|
||||
if err := os.WriteFile(storagePath, ciphertext, 0o600); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
uid, err = s.DB.InsertMessage(mailboxID, folder, messageIDHeader, "", now, int64(len(raw)), storagePath, nonce, from, subject)
|
||||
if err != nil {
|
||||
os.Remove(storagePath)
|
||||
return 0, err
|
||||
}
|
||||
if err := s.DB.AddMailboxUsedBytes(mailboxID, int64(len(raw))); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
|
||||
// FetchMessage decrypts a stored message on demand. Plaintext is never written to disk
|
||||
// or cached — only returned to the caller.
|
||||
func (s *Store) FetchMessage(mailboxID, uid int64) ([]byte, error) {
|
||||
msg, err := s.DB.GetMessageByUID(mailboxID, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg == nil {
|
||||
return nil, fmt.Errorf("mailstore: message %d not found in mailbox %d", uid, mailboxID)
|
||||
}
|
||||
mbox, err := s.DB.GetMailboxByID(mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mbox == nil {
|
||||
return nil, fmt.Errorf("mailstore: mailbox %d not found", mailboxID)
|
||||
}
|
||||
dek, err := s.UnwrapDEK(mbox.DEKWrapped, mbox.DEKNonce)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ciphertext, err := os.ReadFile(msg.StoragePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return openAESGCM(dek, ciphertext, msg.Nonce)
|
||||
}
|
||||
|
||||
// DeleteMessage removes the on-disk ciphertext, the index row, and frees the quota.
|
||||
func (s *Store) DeleteMessage(mailboxID, uid int64) error {
|
||||
msg, err := s.DB.GetMessageByUID(mailboxID, uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(msg.StoragePath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := s.DB.DeleteMessage(mailboxID, uid); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.DB.AddMailboxUsedBytes(mailboxID, -msg.SizeBytes)
|
||||
}
|
||||
@@ -66,6 +66,11 @@ func (s *Session) Auth(mech string) (sasl.Server, error) {
|
||||
// AuthLog row either way, and on any failure returns a *smtp.SMTPError carrying the
|
||||
// exact Python response code/message, arming the connection to close right after that
|
||||
// response is flushed — mirroring CustomSMTP.smtp_AUTH's transport.close() override.
|
||||
//
|
||||
// Two independent identity types can authenticate here: a Sender (relay-only, tried
|
||||
// first — unchanged from the original behavior), or a mailbox's app password (never
|
||||
// its portal password — see esrv_mailbox_app_passwords), which lets a mailbox owner
|
||||
// send mail as their own primary address or a send-as-enabled alias.
|
||||
func (s *Session) authenticate(username, password string) error {
|
||||
sender, err := s.backend.DB.GetSenderByEmail(username)
|
||||
if err != nil {
|
||||
@@ -73,16 +78,32 @@ func (s *Session) authenticate(username, password string) error {
|
||||
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, false, fmt.Sprintf("Authentication error: %v", err))
|
||||
return s.failAuth(451, "Internal server error")
|
||||
}
|
||||
if sender == nil || !db.CheckPassword(password, sender.PasswordHash) {
|
||||
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, false, fmt.Sprintf("Invalid credentials for %s", username))
|
||||
return s.failAuth(535, "Authentication failed")
|
||||
if sender != nil && db.CheckPassword(password, sender.PasswordHash) {
|
||||
s.authenticatedSender = sender
|
||||
s.authType = "sender"
|
||||
s.username = username
|
||||
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, true, "Successful sender authentication")
|
||||
return nil
|
||||
}
|
||||
|
||||
s.authenticatedSender = sender
|
||||
s.authType = "sender"
|
||||
s.username = username
|
||||
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, true, "Successful sender authentication")
|
||||
return nil
|
||||
if s.backend.Mailstore != nil {
|
||||
mbox, merr := s.backend.DB.VerifyMailboxAppPassword(username, password)
|
||||
if merr != nil {
|
||||
s.backend.Logger.Error("Mailbox authentication error: %v", merr)
|
||||
_ = s.backend.DB.LogAuthAttempt("mailbox", username, s.peerIP, false, fmt.Sprintf("Authentication error: %v", merr))
|
||||
return s.failAuth(451, "Internal server error")
|
||||
}
|
||||
if mbox != nil {
|
||||
s.authenticatedMailbox = mbox
|
||||
s.authType = "mailbox"
|
||||
s.username = username
|
||||
_ = s.backend.DB.LogAuthAttempt("mailbox", username, s.peerIP, true, "Successful mailbox app-password authentication")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, false, fmt.Sprintf("Invalid credentials for %s", username))
|
||||
return s.failAuth(535, "Authentication failed")
|
||||
}
|
||||
|
||||
// failAuth builds the SMTPError for a failed AUTH attempt and closes the connection
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// createAlias mirrors createMailboxFor in webui's multitenant tests, for aliases.
|
||||
func createAlias(t *testing.T, backend *Backend, mailboxID, domainID int64, email string, canSendAs bool) {
|
||||
t.Helper()
|
||||
if _, err := backend.DB.CreateAlias(mailboxID, email, domainID, canSendAs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDeliveryToAliasLandsInOwningMailbox(t *testing.T) {
|
||||
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||
createAlias(t, backend, mailboxID, 1, "alias@example.com", false)
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
if err := c.Mail("test@example.com"); err != nil {
|
||||
t.Fatalf("MAIL FROM: %v", err)
|
||||
}
|
||||
if err := c.Rcpt("alias@example.com"); err != nil {
|
||||
t.Fatalf("expected RCPT to a receive-only alias to succeed, got: %v", err)
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Write([]byte("Subject: via alias\r\n\r\nhi"))
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("expected DATA to succeed delivering to an alias, got: %v", err)
|
||||
}
|
||||
|
||||
mbox, err := backend.DB.GetMailboxByID(mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mbox.UsedBytes == 0 {
|
||||
t.Fatal("expected mail delivered to an alias to land in the owning mailbox")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailboxAppPasswordCanSendAsPrimaryButNotArbitraryAddress(t *testing.T) {
|
||||
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||
appPassword := "a-long-enough-app-password-123456"
|
||||
hash, err := db.HashPassword(appPassword)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err := c.Auth(smtp.PlainAuth("", "inbox@example.com", appPassword, "127.0.0.1")); err != nil {
|
||||
t.Fatalf("expected app-password auth to succeed, got: %v", err)
|
||||
}
|
||||
if err := c.Mail("inbox@example.com"); err != nil {
|
||||
t.Fatalf("expected MAIL FROM as own primary address to succeed, got: %v", err)
|
||||
}
|
||||
|
||||
c2, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c2.Close()
|
||||
if err := c2.Auth(smtp.PlainAuth("", "inbox@example.com", appPassword, "127.0.0.1")); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
err = c2.Mail("someoneelse@example.com")
|
||||
if err == nil {
|
||||
t.Fatal("expected MAIL FROM as an address with no alias relationship to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "550") {
|
||||
t.Fatalf("expected 550, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailboxAppPasswordCanSendAsEnabledAlias(t *testing.T) {
|
||||
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||
createAlias(t, backend, mailboxID, 1, "sendalias@example.com", true)
|
||||
appPassword := "another-long-enough-app-password-9"
|
||||
hash, err := db.HashPassword(appPassword)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err := c.Auth(smtp.PlainAuth("", "inbox@example.com", appPassword, "127.0.0.1")); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
if err := c.Mail("sendalias@example.com"); err != nil {
|
||||
t.Fatalf("expected MAIL FROM as a send-as-enabled alias to succeed, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailboxAppPasswordCannotSendAsReceiveOnlyAlias(t *testing.T) {
|
||||
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||
createAlias(t, backend, mailboxID, 1, "receivealias@example.com", false)
|
||||
appPassword := "yet-another-long-enough-app-pass1"
|
||||
hash, err := db.HashPassword(appPassword)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err := c.Auth(smtp.PlainAuth("", "inbox@example.com", appPassword, "127.0.0.1")); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
err = c.Mail("receivealias@example.com")
|
||||
if err == nil {
|
||||
t.Fatal("expected MAIL FROM as a receive-only alias to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "550") {
|
||||
t.Fatalf("expected 550, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/mailstore"
|
||||
)
|
||||
|
||||
// newTestBackendWithMailbox extends newTestBackend with a live Mailstore and one
|
||||
// mailbox, inbox@example.com, on the same verified example.com domain used by the
|
||||
// rest of this package's tests.
|
||||
func newTestBackendWithMailbox(t *testing.T) (*Backend, int64) {
|
||||
t.Helper()
|
||||
backend := newTestBackend(t)
|
||||
|
||||
store := mailstore.New(backend.DB, mailstore.GenerateDEK(), t.TempDir())
|
||||
backend.Mailstore = store
|
||||
// Spam/SPF/DNSBL checks make live DNS calls (see internal/mailstore) — deliberately
|
||||
// so in production, but that makes their exact score environment-dependent (e.g. a
|
||||
// resolver that hijacks NXDOMAIN, or a real SPF record on the test domain). These
|
||||
// tests exercise local-delivery wiring, not spam-scoring accuracy (see
|
||||
// internal/mailstore's own tests for that), so disable rejection entirely here.
|
||||
backend.Cfg.Section("Mailstore").Key("spam_reject_score").SetValue("1000000")
|
||||
|
||||
dek := mailstore.GenerateDEK()
|
||||
wrapped, nonce, err := store.WrapDEK(dek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := db.HashPassword("portal-password-unused")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mailboxID, err := backend.DB.CreateMailbox("inbox@example.com", hash, 1, 5*1024*1024*1024, wrapped, nonce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return backend, mailboxID
|
||||
}
|
||||
|
||||
func TestLocalDeliveryToKnownMailbox(t *testing.T) {
|
||||
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
if err := c.Mail("test@example.com"); err != nil {
|
||||
t.Fatalf("MAIL FROM: %v", err)
|
||||
}
|
||||
if err := c.Rcpt("inbox@example.com"); err != nil {
|
||||
t.Fatalf("expected RCPT to a real local mailbox to succeed, got: %v", err)
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte("Subject: hello\r\n\r\nhi there")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("expected DATA to succeed for local delivery, got: %v", err)
|
||||
}
|
||||
|
||||
mbox, err := backend.DB.GetMailboxByID(mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mbox.UsedBytes == 0 {
|
||||
t.Fatal("expected mailbox used_bytes to increase after local delivery")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDeliveryUnknownMailboxRejected(t *testing.T) {
|
||||
backend, _ := newTestBackendWithMailbox(t)
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
if err := c.Mail("test@example.com"); err != nil {
|
||||
t.Fatalf("MAIL FROM: %v", err)
|
||||
}
|
||||
err = c.Rcpt("nobody@example.com")
|
||||
if err == nil {
|
||||
t.Fatal("expected RCPT to an unknown address on a locally-configured domain to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "550") {
|
||||
t.Fatalf("expected 550 response, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalSenderCanOnlyDeliverLocally(t *testing.T) {
|
||||
backend, _ := newTestBackendWithMailbox(t)
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// No AUTH, no whitelist match: MAIL FROM an entirely unconfigured external
|
||||
// domain must be provisionally accepted (Mailstore is enabled) ...
|
||||
if err := c.Mail("someone@external.example"); err != nil {
|
||||
t.Fatalf("expected MAIL FROM from an external domain to be provisionally accepted, got: %v", err)
|
||||
}
|
||||
// ... but RCPT to an external address must still be denied (not an open relay).
|
||||
err = c.Rcpt("other@somewhere-else.example")
|
||||
if err == nil {
|
||||
t.Fatal("expected RCPT to an external address to be denied for an unauthorized sender")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "550") {
|
||||
t.Fatalf("expected 550 response, got: %v", err)
|
||||
}
|
||||
|
||||
// RCPT to our local mailbox must still succeed for the same provisionally
|
||||
// accepted sender — this is the whole point of accepting it.
|
||||
if err := c.Rcpt("inbox@example.com"); err != nil {
|
||||
t.Fatalf("expected RCPT to a local mailbox to succeed for an external sender, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizedSenderStillRelaysExternallyWithMailstoreEnabled(t *testing.T) {
|
||||
backend, _ := newTestBackendWithMailbox(t)
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
if err := c.Mail("test@example.com"); err != nil {
|
||||
t.Fatalf("MAIL FROM: %v", err)
|
||||
}
|
||||
if err := c.Rcpt("someone@elsewhere.example"); err != nil {
|
||||
t.Fatalf("expected an authorized sender's relay RCPT to still be accepted with Mailstore enabled, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBlockedSenderRejectedAtRcpt(t *testing.T) {
|
||||
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||
if _, err := backend.DB.AddAllowBlockEntry(mailboxID, "block", "test@example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
if err := c.Mail("test@example.com"); err != nil {
|
||||
t.Fatalf("MAIL FROM: %v", err)
|
||||
}
|
||||
err = c.Rcpt("inbox@example.com")
|
||||
if err == nil {
|
||||
t.Fatal("expected RCPT to be rejected for a blocked sender")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "550") {
|
||||
t.Fatalf("expected 550, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowListBypassesSpamRejection(t *testing.T) {
|
||||
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||
// Force every non-allow-listed message to be rejected as spam.
|
||||
backend.Cfg.Section("Mailstore").Key("spam_reject_score").SetValue("0")
|
||||
|
||||
send := func(t *testing.T) error {
|
||||
t.Helper()
|
||||
c, err := smtp.Dial(startTestServer(t, backend))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
if err := c.Mail("test@example.com"); err != nil {
|
||||
t.Fatalf("MAIL FROM: %v", err)
|
||||
}
|
||||
if err := c.Rcpt("inbox@example.com"); err != nil {
|
||||
t.Fatalf("RCPT: %v", err)
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Write([]byte("Subject: hi\r\n\r\nhi"))
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
if err := send(t); err == nil {
|
||||
t.Fatal("expected delivery to fail as spam with a zero reject threshold and no allow-list entry")
|
||||
}
|
||||
|
||||
if _, err := backend.DB.AddAllowBlockEntry(mailboxID, "allow", "test@example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := send(t); err != nil {
|
||||
t.Fatalf("expected delivery to succeed once the sender is allow-listed, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterRuleDeleteDropsMessage(t *testing.T) {
|
||||
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||
if _, err := backend.DB.CreateRule(mailboxID, 0, "subject", "contains", "drop-me", "delete", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
if err := c.Mail("test@example.com"); err != nil {
|
||||
t.Fatalf("MAIL FROM: %v", err)
|
||||
}
|
||||
if err := c.Rcpt("inbox@example.com"); err != nil {
|
||||
t.Fatalf("RCPT: %v", err)
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Write([]byte("Subject: drop-me please\r\n\r\nhi"))
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("expected DATA to still report success even though the rule drops the message, got: %v", err)
|
||||
}
|
||||
|
||||
msgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(msgs) != 0 {
|
||||
t.Fatalf("expected the delete rule to prevent storage, found %d messages", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterRuleMarkReadSetsSeenFlag(t *testing.T) {
|
||||
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||
if _, err := backend.DB.CreateRule(mailboxID, 0, "subject", "contains", "newsletter", "mark_read", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
if err := c.Mail("test@example.com"); err != nil {
|
||||
t.Fatalf("MAIL FROM: %v", err)
|
||||
}
|
||||
if err := c.Rcpt("inbox@example.com"); err != nil {
|
||||
t.Fatalf("RCPT: %v", err)
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Write([]byte("Subject: weekly newsletter\r\n\r\nhi"))
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("DATA: %v", err)
|
||||
}
|
||||
|
||||
msgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||
}
|
||||
if !strings.Contains(msgs[0].Flags, `\Seen`) {
|
||||
t.Fatalf("expected the mark_read rule to set \\Seen, got flags %q", msgs[0].Flags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterRuleMoveToFolderStoresInNamedFolder(t *testing.T) {
|
||||
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||
if _, err := backend.DB.CreateRule(mailboxID, 0, "subject", "contains", "spam", "move_to_folder", "Spam"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
if err := c.Mail("test@example.com"); err != nil {
|
||||
t.Fatalf("MAIL FROM: %v", err)
|
||||
}
|
||||
if err := c.Rcpt("inbox@example.com"); err != nil {
|
||||
t.Fatalf("RCPT: %v", err)
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Write([]byte("Subject: this looks like spam\r\n\r\nhi"))
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("DATA: %v", err)
|
||||
}
|
||||
|
||||
inbox, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(inbox) != 0 {
|
||||
t.Fatalf("expected nothing in INBOX, found %d", len(inbox))
|
||||
}
|
||||
spam, err := backend.DB.ListMessagesInFolder(mailboxID, "Spam")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(spam) != 1 {
|
||||
t.Fatalf("expected 1 message in Spam, got %d", len(spam))
|
||||
}
|
||||
}
|
||||
+184
-26
@@ -13,6 +13,7 @@ import (
|
||||
"gopkg.in/ini.v1"
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/dkim"
|
||||
"mailgoserver/internal/mailstore"
|
||||
"mailgoserver/internal/relay"
|
||||
"mailgoserver/internal/toolbox"
|
||||
)
|
||||
@@ -25,6 +26,7 @@ type Backend struct {
|
||||
DKIM *dkim.Manager
|
||||
Relay *relay.Relay
|
||||
Cfg *ini.File
|
||||
Mailstore *mailstore.Store
|
||||
Logger *toolbox.Logger
|
||||
HeloHostname string
|
||||
AttachmentsBasePath string
|
||||
@@ -45,18 +47,23 @@ type Session struct {
|
||||
conn *smtp.Conn
|
||||
peerIP string
|
||||
|
||||
authenticatedSender *db.Sender
|
||||
authType string // "sender" | "ip" | ""
|
||||
authorizedDomain string
|
||||
username string
|
||||
authenticatedSender *db.Sender
|
||||
authenticatedMailbox *db.Mailbox // set instead of authenticatedSender when auth used an app password
|
||||
authType string // "sender" | "mailbox" | "ip" | ""
|
||||
authorizedDomain string
|
||||
username string
|
||||
|
||||
mailFrom string
|
||||
rcptTos []string
|
||||
mailFrom string
|
||||
mailFromAuthorized bool // true only via an existing authorized path (sender/IP) on one of our own domains
|
||||
rcptTos []string
|
||||
localMailboxes map[string]*db.Mailbox // lowercased rcpt -> resolved local mailbox, set in Rcpt
|
||||
}
|
||||
|
||||
func (s *Session) Reset() {
|
||||
s.mailFrom = ""
|
||||
s.mailFromAuthorized = false
|
||||
s.rcptTos = nil
|
||||
s.localMailboxes = nil
|
||||
}
|
||||
|
||||
func (s *Session) Logout() error { return nil }
|
||||
@@ -64,63 +71,89 @@ func (s *Session) Logout() error { return nil }
|
||||
// Mail mirrors EnhancedCustomSMTPHandler.handle_MAIL, delegating authorization to
|
||||
// validateSenderAuthorization (== auth.validate_sender_authorization).
|
||||
func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
|
||||
ok, message := s.validateSenderAuthorization(from)
|
||||
if !ok {
|
||||
accept, authorized, message := s.validateSenderAuthorization(from)
|
||||
if !accept {
|
||||
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: message}
|
||||
}
|
||||
s.mailFrom = from
|
||||
s.mailFromAuthorized = authorized
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSenderAuthorization mirrors auth.validate_sender_authorization exactly,
|
||||
// including its two branches (already-authenticated sender vs. IP whitelist fallback)
|
||||
// and the AuthLog rows each path writes.
|
||||
func (s *Session) validateSenderAuthorization(mailFrom string) (bool, string) {
|
||||
// validateSenderAuthorization mirrors auth.validate_sender_authorization for every
|
||||
// domain configured on this server — that part is byte-for-byte unchanged: senders
|
||||
// unauthorized or unverified on OUR OWN domains are still hard-rejected here, exactly
|
||||
// as before, to prevent spoofing/open-relay for domains we're responsible for.
|
||||
//
|
||||
// One addition: when this server has local mailbox storage enabled (Mailstore != nil),
|
||||
// a MAIL FROM on a domain we don't manage at all is now provisionally accepted
|
||||
// (accept=true, authorized=false) instead of hard-rejected — otherwise this server
|
||||
// could never receive genuine inbound mail from the internet, since every external
|
||||
// sender's domain is by definition "not configured here". Rcpt enforces that a
|
||||
// provisionally-accepted sender may only deliver to a local mailbox, never relay
|
||||
// onward, so this cannot be used as an open relay.
|
||||
func (s *Session) validateSenderAuthorization(mailFrom string) (accept, authorized bool, message string) {
|
||||
if mailFrom == "" {
|
||||
return false, "No sender address provided"
|
||||
return false, false, "No sender address provided"
|
||||
}
|
||||
fromDomain := domainOfAddr(mailFrom)
|
||||
if fromDomain == "" {
|
||||
return false, "Invalid sender address format"
|
||||
return false, false, "Invalid sender address format"
|
||||
}
|
||||
|
||||
// A domain must have its DNS ownership TXT record verified before it can send —
|
||||
// otherwise anyone could add a domain they don't control and relay mail as it.
|
||||
dom, err := s.backend.DB.GetDomainByName(fromDomain)
|
||||
if err != nil {
|
||||
s.backend.Logger.Error("domain lookup failed: %v", err)
|
||||
return false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
|
||||
return false, false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
|
||||
}
|
||||
if dom == nil {
|
||||
return false, fmt.Sprintf("Domain %s is not configured on this server", fromDomain)
|
||||
if s.backend.Mailstore != nil {
|
||||
return true, false, fmt.Sprintf("Domain %s not configured here; accepted for possible local delivery only", fromDomain)
|
||||
}
|
||||
return false, false, fmt.Sprintf("Domain %s is not configured on this server", fromDomain)
|
||||
}
|
||||
if !dom.IsVerified {
|
||||
return false, fmt.Sprintf("Domain %s has not completed DNS ownership verification yet", fromDomain)
|
||||
return false, false, fmt.Sprintf("Domain %s has not completed DNS ownership verification yet", fromDomain)
|
||||
}
|
||||
|
||||
if s.authenticatedSender != nil {
|
||||
sender := s.authenticatedSender
|
||||
if sender.CanSendAs(mailFrom) {
|
||||
return true, fmt.Sprintf("Sender authorized to send as %s", mailFrom)
|
||||
return true, true, fmt.Sprintf("Sender authorized to send as %s", mailFrom)
|
||||
}
|
||||
_ = s.backend.DB.LogAuthAttempt("sender_validation", fmt.Sprintf("%s -> %s", sender.Email, mailFrom), s.peerIP, false, "")
|
||||
return false, fmt.Sprintf("Sender %s not authorized to send as %s", sender.Email, mailFrom)
|
||||
return false, false, fmt.Sprintf("Sender %s not authorized to send as %s", sender.Email, mailFrom)
|
||||
}
|
||||
|
||||
// A mailbox (authenticated via app password) may send as its own primary address,
|
||||
// or as any of its active send-as-enabled aliases — never as an arbitrary address,
|
||||
// even within a domain it happens to own a mailbox on.
|
||||
if s.authenticatedMailbox != nil {
|
||||
mbox := s.authenticatedMailbox
|
||||
if strings.EqualFold(mailFrom, mbox.Email) {
|
||||
return true, true, fmt.Sprintf("Mailbox authorized to send as %s", mailFrom)
|
||||
}
|
||||
if canSendAs, err := s.backend.DB.MailboxCanSendAs(mbox.ID, mailFrom); err == nil && canSendAs {
|
||||
return true, true, fmt.Sprintf("Mailbox authorized to send as alias %s", mailFrom)
|
||||
}
|
||||
_ = s.backend.DB.LogAuthAttempt("mailbox_validation", fmt.Sprintf("%s -> %s", mbox.Email, mailFrom), s.peerIP, false, "")
|
||||
return false, false, fmt.Sprintf("Mailbox %s not authorized to send as %s", mbox.Email, mailFrom)
|
||||
}
|
||||
|
||||
wl, err := s.backend.DB.GetWhitelistedIP(s.peerIP, fromDomain)
|
||||
if err != nil {
|
||||
s.backend.Logger.Error("IP authorization lookup failed: %v", err)
|
||||
return false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
|
||||
return false, false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
|
||||
}
|
||||
if wl != nil {
|
||||
s.authType = "ip"
|
||||
s.authorizedDomain = fromDomain
|
||||
s.username = "IP:" + s.peerIP
|
||||
_ = s.backend.DB.LogAuthAttempt("ip", fmt.Sprintf("%s -> %s", s.peerIP, fromDomain), s.peerIP, true, fmt.Sprintf("IP %s authorized for domain %s", s.peerIP, fromDomain))
|
||||
return true, fmt.Sprintf("IP authorized for domain %s", fromDomain)
|
||||
return true, true, fmt.Sprintf("IP authorized for domain %s", fromDomain)
|
||||
}
|
||||
_ = s.backend.DB.LogAuthAttempt("ip", fmt.Sprintf("%s -> %s", s.peerIP, fromDomain), s.peerIP, false, fmt.Sprintf("IP %s not authorized for domain %s", s.peerIP, fromDomain))
|
||||
return false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
|
||||
return false, false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
|
||||
}
|
||||
|
||||
func domainOfAddr(address string) string {
|
||||
@@ -131,8 +164,42 @@ func domainOfAddr(address string) string {
|
||||
return strings.ToLower(address[i+1:])
|
||||
}
|
||||
|
||||
// Rcpt mirrors handle_RCPT: accepts any address, no validation.
|
||||
// Rcpt mirrors handle_RCPT for the pure-relay case (still accept-all for an authorized
|
||||
// sender relaying to an external address — unchanged), and adds local-mailbox
|
||||
// resolution: a recipient on one of our own configured+verified domains must resolve
|
||||
// to a real mailbox, or is rejected with 550 "No such mailbox" — matching how a real
|
||||
// MTA rejects unknown local recipients at RCPT time. A recipient that resolves to
|
||||
// neither a local mailbox nor an authorized-to-relay sender's target is rejected with
|
||||
// "Relay access denied" — the anti-open-relay invariant for provisionally-accepted
|
||||
// senders (see validateSenderAuthorization).
|
||||
func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
|
||||
domain := domainOfAddr(to)
|
||||
var localDomain *db.Domain
|
||||
if domain != "" && s.backend.Mailstore != nil {
|
||||
if dom, err := s.backend.DB.GetDomainByName(domain); err == nil && dom != nil && dom.IsVerified {
|
||||
localDomain = dom
|
||||
}
|
||||
}
|
||||
|
||||
if localDomain != nil {
|
||||
mbox, err := s.backend.Mailstore.ResolveRecipient(to)
|
||||
if err != nil || mbox == nil {
|
||||
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "No such mailbox"}
|
||||
}
|
||||
if blocked, _ := s.backend.DB.IsBlocked(mbox.ID, s.mailFrom); blocked {
|
||||
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "Message rejected"}
|
||||
}
|
||||
if s.localMailboxes == nil {
|
||||
s.localMailboxes = map[string]*db.Mailbox{}
|
||||
}
|
||||
s.localMailboxes[strings.ToLower(to)] = mbox
|
||||
s.rcptTos = append(s.rcptTos, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !s.mailFromAuthorized {
|
||||
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "Relay access denied"}
|
||||
}
|
||||
s.rcptTos = append(s.rcptTos, to)
|
||||
return nil
|
||||
}
|
||||
@@ -232,7 +299,26 @@ func (s *Session) Data(r io.Reader) error {
|
||||
}
|
||||
}
|
||||
|
||||
results := s.backend.Relay.RelayEmailAsync(s.mailFrom, s.rcptTos, signedContent, recipientTypes)
|
||||
// Split recipients resolved to a local mailbox in Rcpt from everything else
|
||||
// (still relayed exactly as before — unchanged for every non-local recipient).
|
||||
var localRcpts, localTypes, relayRcpts, relayTypes []string
|
||||
for i, rcpt := range s.rcptTos {
|
||||
if _, ok := s.localMailboxes[strings.ToLower(rcpt)]; ok {
|
||||
localRcpts = append(localRcpts, rcpt)
|
||||
localTypes = append(localTypes, recipientTypes[i])
|
||||
} else {
|
||||
relayRcpts = append(relayRcpts, rcpt)
|
||||
relayTypes = append(relayTypes, recipientTypes[i])
|
||||
}
|
||||
}
|
||||
|
||||
var results []relay.Result
|
||||
if len(relayRcpts) > 0 {
|
||||
results = s.backend.Relay.RelayEmailAsync(s.mailFrom, relayRcpts, signedContent, relayTypes)
|
||||
}
|
||||
if len(localRcpts) > 0 {
|
||||
results = append(results, s.deliverLocally(localRcpts, localTypes, signedContent, messageID, subject)...)
|
||||
}
|
||||
|
||||
allSucceeded := len(results) > 0
|
||||
for _, res := range results {
|
||||
@@ -266,6 +352,78 @@ func (s *Session) Data(r io.Reader) error {
|
||||
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "Message relay failed"}
|
||||
}
|
||||
|
||||
// deliverLocally runs the inbound DKIM/SPF/spam checks once for the message (they
|
||||
// don't vary per recipient at this milestone — no per-mailbox allow/block-list yet)
|
||||
// and stores it into each resolved local mailbox, producing one relay.Result per
|
||||
// recipient so it can be merged into the same LogEmail/allSucceeded logic as relay
|
||||
// results.
|
||||
func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID, subject string) []relay.Result {
|
||||
senderDomain := domainOfAddr(s.mailFrom)
|
||||
dkimPass := senderDomain != "" && dkim.VerifyInbound(signedContent, senderDomain)
|
||||
spfPass := mailstore.CheckSPF(s.mailFrom, s.peerIP)
|
||||
heuristicScore := mailstore.SpamScore(s.peerIP, map[string]string{"subject": subject}, dkimPass, spfPass)
|
||||
rejectScore := s.backend.Cfg.Section("Mailstore").Key("spam_reject_score").MustInt(5)
|
||||
rspamdEnabled := s.backend.Cfg.Section("Rspamd").Key("enabled").MustBool(false)
|
||||
rspamdURL := s.backend.Cfg.Section("Rspamd").Key("url").MustString("http://127.0.0.1:11333")
|
||||
rspamdRejectScore := s.backend.Cfg.Section("Rspamd").Key("reject_score").MustInt(15)
|
||||
|
||||
results := make([]relay.Result, 0, len(rcpts))
|
||||
for i, rcpt := range rcpts {
|
||||
mbox := s.localMailboxes[strings.ToLower(rcpt)]
|
||||
|
||||
// An explicit per-mailbox allow-list entry bypasses spam scoring entirely —
|
||||
// the built-in heuristic and optional rspamd check both run regardless of each
|
||||
// other (additive, not either/or), but neither runs at all once allow-listed.
|
||||
if allowed, _ := s.backend.DB.IsAllowed(mbox.ID, s.mailFrom); !allowed {
|
||||
reject := heuristicScore >= rejectScore
|
||||
if !reject && rspamdEnabled {
|
||||
if score, action, err := mailstore.CheckRspamd(rspamdURL, []byte(signedContent), s.mailFrom, rcpt); err == nil {
|
||||
if action == "reject" || score >= float64(rspamdRejectScore) {
|
||||
reject = true
|
||||
}
|
||||
}
|
||||
// rspamd unreachable/erroring must not block mail — errors are swallowed,
|
||||
// the built-in heuristic above is still the baseline gate either way.
|
||||
}
|
||||
if reject {
|
||||
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "550", ErrorMessage: "Message rejected as spam"})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
action, err := s.backend.Mailstore.ApplyRules(mbox.ID, map[string]string{"from": s.mailFrom, "to": rcpt, "subject": subject})
|
||||
if err != nil {
|
||||
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "450", ErrorMessage: err.Error()})
|
||||
continue
|
||||
}
|
||||
if action.Drop {
|
||||
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Discarded by filter rule"})
|
||||
continue
|
||||
}
|
||||
folder := "INBOX"
|
||||
if action.Folder != "" {
|
||||
folder = action.Folder
|
||||
}
|
||||
|
||||
uid, err := s.backend.Mailstore.StoreMessage(mbox.ID, folder, []byte(signedContent), messageID, s.mailFrom, subject)
|
||||
if err != nil {
|
||||
errCode, errMsg := "450", err.Error()
|
||||
if err == mailstore.ErrQuotaExceeded {
|
||||
errCode, errMsg = "552", "Mailbox quota exceeded"
|
||||
}
|
||||
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: errCode, ErrorMessage: errMsg})
|
||||
continue
|
||||
}
|
||||
if action.MarkRead {
|
||||
if err := s.backend.DB.SetMessageFlags(mbox.ID, uid, `\Seen`); err != nil {
|
||||
s.backend.Logger.Error("mark_read rule failed to set flag for message %d: %v", uid, err)
|
||||
}
|
||||
}
|
||||
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Delivered to local mailbox"})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func containsStr(list []string, s string) bool {
|
||||
for _, v := range list {
|
||||
if v == s {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package tlsutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCertReloaderReload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certFile := filepath.Join(dir, "server.crt")
|
||||
keyFile := filepath.Join(dir, "server.key")
|
||||
|
||||
if err := GenerateSelfSignedCert(certFile, keyFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloader, err := NewCertReloader(certFile, keyFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certA, err := reloader.GetCertificate(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Overwrite with a fresh cert (delete first, since GenerateSelfSignedCert is
|
||||
// skip-if-exists).
|
||||
if err := os.Remove(certFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(keyFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := GenerateSelfSignedCert(certFile, keyFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reloader.Reload(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certB, err := reloader.GetCertificate(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if string(certA.Certificate[0]) == string(certB.Certificate[0]) {
|
||||
t.Fatal("expected Reload to pick up a different certificate, got the same bytes")
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -92,3 +93,53 @@ func CreateSSLContext(certFile, keyFile string) (*tls.Config, error) {
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CertReloader holds the currently-active certificate behind a tls.Config's
|
||||
// GetCertificate hook, so a listener can pick up a newly-obtained/renewed certificate
|
||||
// (see internal/acmecert) without restarting the process.
|
||||
type CertReloader struct {
|
||||
certFile, keyFile string
|
||||
|
||||
mu sync.RWMutex
|
||||
cert *tls.Certificate
|
||||
}
|
||||
|
||||
// NewCertReloader loads certFile/keyFile once and returns a reloader ready to hand to
|
||||
// a tls.Config's GetCertificate field.
|
||||
func NewCertReloader(certFile, keyFile string) (*CertReloader, error) {
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CertReloader{certFile: certFile, keyFile: keyFile, cert: &cert}, nil
|
||||
}
|
||||
|
||||
// GetCertificate satisfies tls.Config.GetCertificate.
|
||||
func (r *CertReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.cert, nil
|
||||
}
|
||||
|
||||
// Reload re-reads certFile/keyFile from disk and atomically swaps the active
|
||||
// certificate. Called after a successful Let's Encrypt obtain/renew.
|
||||
func (r *CertReloader) Reload() error {
|
||||
cert, err := tls.LoadX509KeyPair(r.certFile, r.keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.cert = &cert
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewReloadableTLSConfig builds a tls.Config backed by reloader instead of a fixed
|
||||
// certificate — used by both the SMTP and IMAP implicit-TLS listeners so a single
|
||||
// Reload() call (self-signed regeneration or a Let's Encrypt renewal) updates both.
|
||||
func NewReloadableTLSConfig(reloader *CertReloader) *tls.Config {
|
||||
return &tls.Config{
|
||||
GetCertificate: reloader.GetCertificate,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
}
|
||||
|
||||
+14
-28
@@ -23,29 +23,17 @@ func (a *App) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
a.Logger.Error("dashboard: %v", err)
|
||||
}
|
||||
|
||||
var domainCount, senderCount, dkimCount int
|
||||
if isGlobal {
|
||||
domainCount, _ = a.DB.CountActiveDomains()
|
||||
senderCount, _ = a.DB.CountActiveSenders()
|
||||
dkimCount, _ = a.DB.CountActiveDKIMKeys()
|
||||
} else {
|
||||
domains, _ := a.DB.ListDomains()
|
||||
for _, d := range domains {
|
||||
if d.IsActive && scope.Allowed(d.ID) {
|
||||
domainCount++
|
||||
}
|
||||
// Domain/sender/mailbox/DKIM counts are injected uniformly into every page by
|
||||
// render() (see computeNavCounts) — only "near quota" is dashboard-specific,
|
||||
// so it's the only mailbox stat still computed here.
|
||||
var mailboxesNearQuota int
|
||||
mailboxes, _ := a.DB.ListMailboxes()
|
||||
for _, m := range mailboxes {
|
||||
if !m.IsActive || (!isGlobal && !scope.Allowed(m.DomainID)) {
|
||||
continue
|
||||
}
|
||||
senders, _ := a.DB.ListSenders()
|
||||
for _, s := range senders {
|
||||
if s.IsActive && scope.Allowed(s.DomainID) {
|
||||
senderCount++
|
||||
}
|
||||
}
|
||||
keys, _ := a.DB.ListActiveDKIMKeysWithDomain()
|
||||
for _, k := range keys {
|
||||
if scope.Allowed(k.DomainID) {
|
||||
dkimCount++
|
||||
}
|
||||
if m.QuotaBytes > 0 && float64(m.UsedBytes)/float64(m.QuotaBytes)*100 >= 90 {
|
||||
mailboxesNearQuota++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,12 +63,10 @@ func (a *App) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
a.render(w, r, "dashboard.html", M{
|
||||
"active": "dashboard",
|
||||
"domain_count": domainCount,
|
||||
"sender_count": senderCount,
|
||||
"dkim_count": dkimCount,
|
||||
"recent_emails": recentEmails,
|
||||
"recent_auths": recentAuths,
|
||||
"active": "dashboard",
|
||||
"mailboxes_near_quota": mailboxesNearQuota,
|
||||
"recent_emails": recentEmails,
|
||||
"recent_auths": recentAuths,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/mailstore"
|
||||
)
|
||||
|
||||
// TestDashboardShowsMailboxNearQuota confirms the dashboard tile surfaces a mailbox
|
||||
// that has crossed the 90% quota threshold, and doesn't for one that hasn't.
|
||||
func TestDashboardShowsMailboxNearQuota(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
cookie := loginSession(t, app) // global admin
|
||||
|
||||
domains, err := app.DB.ListDomains()
|
||||
if err != nil || len(domains) == 0 {
|
||||
t.Fatalf("expected a seeded domain: %v", err)
|
||||
}
|
||||
domainID := domains[0].ID
|
||||
|
||||
dek := mailstore.GenerateDEK()
|
||||
wrapped, nonce, err := app.Mailstore.WrapDEK(dek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := db.HashPassword("irrelevant-portal-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fullID, err := app.DB.CreateMailbox("full@example.com", hash, domainID, 100, wrapped, nonce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.DB.AddMailboxUsedBytes(fullID, 95); err != nil { // 95% full
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, Prefix+"/", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("dashboard status = %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "near quota") {
|
||||
t.Fatal("expected the dashboard to flag a mailbox at 95% quota usage")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// leAlwaysOverwriteFields are plain (non-secret) [LetsEncrypt] settings — always
|
||||
// persisted from the submitted form, same as any other settings.html field.
|
||||
var leAlwaysOverwriteFields = []string{
|
||||
"enabled", "staging", "contact_email", "domains", "dns_provider",
|
||||
"route53_region", "route53_hosted_zone_id", "gcloud_project",
|
||||
}
|
||||
|
||||
// leSecretFields hold DNS provider credentials. They're never rendered back into the
|
||||
// form (always blank) and the save handler only overwrites the stored value when the
|
||||
// submitted field is non-empty — "leave blank to keep the current value", the same
|
||||
// idiom edit_sender.html already uses for its password field.
|
||||
var leSecretFields = []string{
|
||||
"cloudflare_api_token", "route53_access_key_id", "route53_secret_access_key",
|
||||
"digitalocean_api_token", "gcloud_service_account_json_path",
|
||||
}
|
||||
|
||||
// letsEncryptPage shows the current Let's Encrypt status and configuration form.
|
||||
// Secret fields are always blank in the rendered form — see leSecretFields.
|
||||
func (a *App) letsEncryptPage(w http.ResponseWriter, r *http.Request) {
|
||||
sec := a.Cfg.Section("LetsEncrypt")
|
||||
kv := M{}
|
||||
for _, k := range leAlwaysOverwriteFields {
|
||||
kv[k] = sec.Key(k).String()
|
||||
}
|
||||
for _, k := range leSecretFields {
|
||||
kv[k] = ""
|
||||
}
|
||||
a.render(w, r, "letsencrypt.html", M{"active": "letsencrypt", "le": kv, "status": a.ACME.Status()})
|
||||
}
|
||||
|
||||
// letsEncryptSave is a dedicated handler (not the generic settingsUpdate reflection)
|
||||
// specifically because of leSecretFields' blank-means-keep-existing semantics —
|
||||
// settingsUpdate would otherwise blank out a stored credential whenever this form is
|
||||
// submitted with a secret field left empty.
|
||||
func (a *App) letsEncryptSave(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
setFlash(w, "error", "Invalid form data")
|
||||
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
|
||||
return
|
||||
}
|
||||
sec := a.Cfg.Section("LetsEncrypt")
|
||||
for _, k := range leAlwaysOverwriteFields {
|
||||
sec.Key(k).SetValue(r.FormValue(k))
|
||||
}
|
||||
for _, k := range leSecretFields {
|
||||
if v := r.FormValue(k); v != "" {
|
||||
sec.Key(k).SetValue(v)
|
||||
}
|
||||
}
|
||||
if err := a.Cfg.SaveTo(a.ConfigPath); err != nil {
|
||||
setFlash(w, "error", "Error saving settings: "+err.Error())
|
||||
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
|
||||
return
|
||||
}
|
||||
setFlash(w, "success", `Let's Encrypt settings saved. Use "Obtain / Renew Now" to test the configuration.`)
|
||||
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
|
||||
}
|
||||
|
||||
// letsEncryptObtainNow triggers an immediate obtain/renew — separate from Save, since
|
||||
// saving configuration must never silently kick off an ACME transaction as a side
|
||||
// effect. This is also how the very first certificate actually gets obtained.
|
||||
func (a *App) letsEncryptObtainNow(w http.ResponseWriter, r *http.Request) {
|
||||
if err := a.ACME.ObtainOrRenew(r.Context()); err != nil {
|
||||
setFlash(w, "error", "Could not obtain certificate: "+err.Error())
|
||||
} else {
|
||||
setFlash(w, "success", "Certificate obtained successfully")
|
||||
}
|
||||
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
|
||||
}
|
||||
|
||||
// uploadGCloudServiceAccount mirrors settings.go's uploadTLSFile two-step flow: upload
|
||||
// the file, return its saved path as JSON, and the browser fills a sibling text input
|
||||
// with that path — the path only actually persists once the surrounding form (Save)
|
||||
// is submitted.
|
||||
func (a *App) uploadGCloudServiceAccount(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "Invalid upload"})
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("gcloud_key_file")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "No file provided"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if ext := strings.ToLower(filepath.Ext(header.Filename)); ext != ".json" {
|
||||
writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "Expected a .json service account key file"})
|
||||
return
|
||||
}
|
||||
acmeDir := filepath.Join(filepath.Dir(a.ConfigPath), "server_data", "acme")
|
||||
os.MkdirAll(acmeDir, 0o755)
|
||||
filePath := filepath.Join(acmeDir, fmt.Sprintf("gcloud-sa-%d.json", time.Now().Unix()))
|
||||
out, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, M{"status": "error", "message": "Could not save file"})
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := out.ReadFrom(file); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, M{"status": "error", "message": "Could not save file"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, M{"status": "success", "filepath": filePath})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLetsEncryptSaveBlankMeansKeepExisting confirms a secret field submitted blank
|
||||
// doesn't wipe a previously-saved credential, while a non-empty submission does
|
||||
// overwrite it — the whole reason this page has its own save handler instead of using
|
||||
// the generic settingsUpdate reflection.
|
||||
func TestLetsEncryptSaveBlankMeansKeepExisting(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
cookie := loginSession(t, app)
|
||||
|
||||
app.Cfg.Section("LetsEncrypt").Key("cloudflare_api_token").SetValue("original-secret-token")
|
||||
|
||||
// Submit the form with the secret field blank (and a plain field changed).
|
||||
form := url.Values{
|
||||
"enabled": {"true"},
|
||||
"dns_provider": {"cloudflare"},
|
||||
"domains": {"mail.example.com"},
|
||||
"contact_email": {"admin@example.com"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, Prefix+"/letsencrypt/save", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("expected redirect, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := app.Cfg.Section("LetsEncrypt").Key("cloudflare_api_token").String(); got != "original-secret-token" {
|
||||
t.Fatalf("expected the blank submission to keep the existing token, got %q", got)
|
||||
}
|
||||
if got := app.Cfg.Section("LetsEncrypt").Key("enabled").String(); got != "true" {
|
||||
t.Fatalf("expected the plain field to be updated, got %q", got)
|
||||
}
|
||||
|
||||
// Now submit a real value for the secret field — it must overwrite.
|
||||
form.Set("cloudflare_api_token", "new-secret-token")
|
||||
req2 := httptest.NewRequest(http.MethodPost, Prefix+"/letsencrypt/save", strings.NewReader(form.Encode()))
|
||||
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req2.AddCookie(cookie)
|
||||
rec2 := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusFound {
|
||||
t.Fatalf("expected redirect, got %d", rec2.Code)
|
||||
}
|
||||
if got := app.Cfg.Section("LetsEncrypt").Key("cloudflare_api_token").String(); got != "new-secret-token" {
|
||||
t.Fatalf("expected a non-empty submission to overwrite the token, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLetsEncryptPageNeverRendersSecrets confirms secret fields are always blank in
|
||||
// the rendered form, even when a value is stored.
|
||||
func TestLetsEncryptPageNeverRendersSecrets(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
cookie := loginSession(t, app)
|
||||
app.Cfg.Section("LetsEncrypt").Key("cloudflare_api_token").SetValue("super-secret-value")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, Prefix+"/letsencrypt", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "super-secret-value") {
|
||||
t.Fatal("expected the stored secret to never be rendered back into the page")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// aliasesList shows a mailbox's aliases plus a form to add a new one. The alias's
|
||||
// domain can differ from the mailbox's own — it's resolved and access-checked
|
||||
// independently, same as buildMailboxEmail/buildSenderEmail — so an alias can live on
|
||||
// any domain the current admin/tenant controls, not just the mailbox's own domain.
|
||||
func (a *App) aliasesList(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
aliases, err := a.DB.ListAliasesForMailbox(mailbox.ID)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error loading aliases")
|
||||
}
|
||||
domains, _ := a.accessibleDomains(r)
|
||||
a.render(w, r, "mailbox_aliases.html", M{"active": "mailboxes", "mailbox": mailbox, "aliases": aliases, "domains": domains})
|
||||
}
|
||||
|
||||
func (a *App) addAlias(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
localPart := strings.TrimSpace(r.FormValue("local_part"))
|
||||
domainID := int64(atoi(r.FormValue("domain_id")))
|
||||
canSendAs := r.FormValue("can_send_as") == "on"
|
||||
|
||||
if !requireDomainAccess(w, r, domainID) {
|
||||
return
|
||||
}
|
||||
email, err := a.buildMailboxEmail(localPart, domainID)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error creating alias")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/aliases", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if email == "" {
|
||||
setFlash(w, "error", "Please provide a valid local part (letters, numbers, and . _ % + - only) and domain")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/aliases", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if exists, _ := a.DB.MailboxEmailExists(email, -1); exists {
|
||||
setFlash(w, "error", "That address is already a mailbox")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/aliases", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if exists, _ := a.DB.AliasEmailExists(email, -1); exists {
|
||||
setFlash(w, "error", "That address is already an alias")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/aliases", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if _, err := a.DB.CreateAlias(mailbox.ID, email, domainID, canSendAs); err != nil {
|
||||
setFlash(w, "error", "Error creating alias")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/aliases", http.StatusFound)
|
||||
return
|
||||
}
|
||||
setFlash(w, "success", "Alias added successfully")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/aliases", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) removeAlias(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
aliasID := int64(atoi(r.PathValue("alias_id")))
|
||||
if err := a.DB.RemoveAlias(aliasID, mailbox.ID); err != nil {
|
||||
setFlash(w, "error", "Error removing alias")
|
||||
} else {
|
||||
setFlash(w, "success", "Alias removed")
|
||||
}
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/aliases", http.StatusFound)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// appPasswordsList shows a mailbox's existing app-password labels (never the secrets
|
||||
// themselves — those are shown once, at creation) plus a form to add a new one.
|
||||
func (a *App) appPasswordsList(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
passwords, err := a.DB.ListAppPasswordsForMailbox(mailbox.ID)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error loading app passwords")
|
||||
}
|
||||
a.render(w, r, "mailbox_apppasswords.html", M{"active": "mailboxes", "mailbox": mailbox, "passwords": passwords})
|
||||
}
|
||||
|
||||
// addAppPassword generates a random secret (the only credential IMAP/SMTP clients ever
|
||||
// use for this mailbox — never the portal password), shows it once via flash, and
|
||||
// stores only its bcrypt hash.
|
||||
func (a *App) addAppPassword(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
label := strings.TrimSpace(r.FormValue("label"))
|
||||
if label == "" {
|
||||
label = "App password"
|
||||
}
|
||||
|
||||
minLen := a.Cfg.Section("Mailstore").Key("app_password_min_length").MustInt(25)
|
||||
secret := db.GenerateAppPassword(minLen)
|
||||
hash, err := db.HashPassword(secret)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error creating app password")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/apppasswords", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if _, err := a.DB.CreateAppPassword(mailbox.ID, label, hash); err != nil {
|
||||
setFlash(w, "error", "Error creating app password")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/apppasswords", http.StatusFound)
|
||||
return
|
||||
}
|
||||
setFlash(w, "success", "App password created — copy it now, it will not be shown again: "+secret)
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/apppasswords", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) revokeAppPassword(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pwID := int64(atoi(r.PathValue("pw_id")))
|
||||
if err := a.DB.RemoveAppPassword(pwID, mailbox.ID); err != nil {
|
||||
setFlash(w, "error", "Error revoking app password")
|
||||
} else {
|
||||
setFlash(w, "success", "App password revoked")
|
||||
}
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/apppasswords", http.StatusFound)
|
||||
}
|
||||
|
||||
func idStr(r *http.Request) string {
|
||||
return r.PathValue("id")
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// listsPage shows a mailbox's allow-list and block-list entries plus forms to add to
|
||||
// either. A single handler set keyed by list_type, backing the single
|
||||
// esrv_mailbox_allowblock table (not two near-identical resources).
|
||||
func (a *App) listsPage(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entries, err := a.DB.ListAllowBlock(mailbox.ID)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error loading lists")
|
||||
}
|
||||
var allow, block []any
|
||||
for _, e := range entries {
|
||||
if e.ListType == "allow" {
|
||||
allow = append(allow, e)
|
||||
} else {
|
||||
block = append(block, e)
|
||||
}
|
||||
}
|
||||
a.render(w, r, "mailbox_lists.html", M{"active": "mailboxes", "mailbox": mailbox, "allow": allow, "block": block})
|
||||
}
|
||||
|
||||
func (a *App) addAllowBlockEntry(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
listType := r.FormValue("list_type")
|
||||
pattern := strings.ToLower(strings.TrimSpace(r.FormValue("pattern")))
|
||||
if (listType != "allow" && listType != "block") || pattern == "" {
|
||||
setFlash(w, "error", "Please provide a valid address or @domain pattern")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/lists", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if _, err := a.DB.AddAllowBlockEntry(mailbox.ID, listType, pattern); err != nil {
|
||||
setFlash(w, "error", "Error adding entry")
|
||||
} else {
|
||||
setFlash(w, "success", "Entry added")
|
||||
}
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/lists", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) removeAllowBlockEntry(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entryID := int64(atoi(r.PathValue("entry_id")))
|
||||
if err := a.DB.RemoveAllowBlockEntry(entryID, mailbox.ID); err != nil {
|
||||
setFlash(w, "error", "Error removing entry")
|
||||
} else {
|
||||
setFlash(w, "success", "Entry removed")
|
||||
}
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/lists", http.StatusFound)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/mailstore"
|
||||
)
|
||||
|
||||
// createMailboxFor mirrors setupTwoTenants' sender helper, for a mailbox instead.
|
||||
func createMailboxFor(t *testing.T, app *App, email string, domainID int64) *db.Mailbox {
|
||||
t.Helper()
|
||||
hash, err := db.HashPassword("password123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dek := mailstore.GenerateDEK()
|
||||
wrapped, nonce, err := app.Mailstore.WrapDEK(dek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := app.DB.CreateMailbox(email, hash, domainID, 5*1024*1024*1024, wrapped, nonce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mbox, err := app.DB.GetMailboxByID(id)
|
||||
if err != nil || mbox == nil {
|
||||
t.Fatalf("expected mailbox to exist: %v", err)
|
||||
}
|
||||
return mbox
|
||||
}
|
||||
|
||||
func TestScopedAdminCannotAccessOtherTenantMailboxByID(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domainA, domainB, _, _ := setupTwoTenants(t, app)
|
||||
mailboxB := createMailboxFor(t, app, "carol@"+domainB.DomainName, domainB.ID)
|
||||
|
||||
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, Prefix+"/mailboxes/"+strconv.FormatInt(mailboxB.ID, 10)+"/edit", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for a mailbox outside scope, got %d", rec.Code)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
req2 := httptest.NewRequest(http.MethodPost, Prefix+"/mailboxes/"+strconv.FormatInt(mailboxB.ID, 10)+"/delete", strings.NewReader(form.Encode()))
|
||||
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req2.AddCookie(cookie)
|
||||
rec2 := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 disabling a mailbox outside scope, got %d", rec2.Code)
|
||||
}
|
||||
stillActive, err := app.DB.GetMailboxByID(mailboxB.ID)
|
||||
if err != nil || stillActive == nil || !stillActive.IsActive {
|
||||
t.Fatal("mailbox outside scope must not have been modified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedAdminCannotCreateMailboxOnUnownedDomain(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domainA, domainB, _, _ := setupTwoTenants(t, app)
|
||||
|
||||
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
|
||||
|
||||
form := url.Values{
|
||||
"local_part": {"mallory"},
|
||||
"domain_id": {strconv.FormatInt(domainB.ID, 10)}, // not theirs
|
||||
"password": {"password123"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, Prefix+"/mailboxes/add", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 creating a mailbox on an unowned domain, got %d", rec.Code)
|
||||
}
|
||||
if m, _ := app.DB.GetMailboxByEmail("mallory@" + domainB.DomainName); m != nil {
|
||||
t.Fatal("mailbox must not have been created on a domain outside the admin's scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedAdminCanManageOwnMailboxAppPasswords(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domainA, _, _, _ := setupTwoTenants(t, app)
|
||||
mailboxA := createMailboxFor(t, app, "dave@"+domainA.DomainName, domainA.ID)
|
||||
|
||||
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
|
||||
|
||||
form := url.Values{"label": {"laptop"}}
|
||||
req := httptest.NewRequest(http.MethodPost, Prefix+"/mailboxes/"+strconv.FormatInt(mailboxA.ID, 10)+"/apppasswords/add", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("expected redirect after creating an app password, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
passwords, err := app.DB.ListAppPasswordsForMailbox(mailboxA.ID)
|
||||
if err != nil || len(passwords) != 1 {
|
||||
t.Fatalf("expected exactly one app password, got %d (err=%v)", len(passwords), err)
|
||||
}
|
||||
if len(passwords[0].PasswordHash) == 0 {
|
||||
t.Fatal("expected a password hash to be stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedAdminCannotCreateAliasOnUnownedDomain(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domainA, domainB, _, _ := setupTwoTenants(t, app)
|
||||
mailboxA := createMailboxFor(t, app, "dave@"+domainA.DomainName, domainA.ID)
|
||||
|
||||
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
|
||||
|
||||
form := url.Values{
|
||||
"local_part": {"evilalias"},
|
||||
"domain_id": {strconv.FormatInt(domainB.ID, 10)}, // not theirs
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, Prefix+"/mailboxes/"+strconv.FormatInt(mailboxA.ID, 10)+"/aliases/add", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 creating an alias on an unowned domain, got %d", rec.Code)
|
||||
}
|
||||
if a, _ := app.DB.GetAliasByEmail("evilalias@" + domainB.DomainName); a != nil {
|
||||
t.Fatal("alias must not have been created on a domain outside the admin's scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppPasswordCannotBeRevokedFromAnotherMailbox(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
domainA, domainB, _, _ := setupTwoTenants(t, app)
|
||||
mailboxA := createMailboxFor(t, app, "dave@"+domainA.DomainName, domainA.ID)
|
||||
mailboxB := createMailboxFor(t, app, "carol@"+domainB.DomainName, domainB.ID)
|
||||
|
||||
pwID, err := app.DB.CreateAppPassword(mailboxB.ID, "carol's laptop", "irrelevant-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Attempting to remove mailboxB's app password while scoped to mailboxA must be a
|
||||
// no-op — RemoveAppPassword is scoped by (id, mailboxID) precisely to prevent this.
|
||||
if err := app.DB.RemoveAppPassword(pwID, mailboxA.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remaining, err := app.DB.ListAppPasswordsForMailbox(mailboxB.ID)
|
||||
if err != nil || len(remaining) != 1 {
|
||||
t.Fatalf("expected mailboxB's app password to survive a delete scoped to mailboxA, got %d remaining (err=%v)", len(remaining), err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var validConditionFields = map[string]bool{"from": true, "to": true, "subject": true}
|
||||
var validConditionOps = map[string]bool{"contains": true, "equals": true, "starts_with": true}
|
||||
var validActions = map[string]bool{"move_to_folder": true, "delete": true, "mark_read": true}
|
||||
|
||||
func (a *App) rulesList(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rules, err := a.DB.ListRulesForMailbox(mailbox.ID)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error loading rules")
|
||||
}
|
||||
a.render(w, r, "mailbox_rules.html", M{"active": "mailboxes", "mailbox": mailbox, "rules": rules})
|
||||
}
|
||||
|
||||
// addRule mirrors the compact list-CRUD pattern used elsewhere: no separate edit
|
||||
// page, just add/remove — a rule needing changes is removed and re-added.
|
||||
func (a *App) addRule(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
priority, _ := strconv.Atoi(r.FormValue("priority"))
|
||||
field := r.FormValue("condition_field")
|
||||
op := r.FormValue("condition_op")
|
||||
value := strings.TrimSpace(r.FormValue("condition_value"))
|
||||
action := r.FormValue("action")
|
||||
actionValue := strings.TrimSpace(r.FormValue("action_value"))
|
||||
|
||||
if !validConditionFields[field] || !validConditionOps[op] || value == "" || !validActions[action] {
|
||||
setFlash(w, "error", "Please fill in a valid condition and action")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/rules", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if action == "move_to_folder" && actionValue == "" {
|
||||
setFlash(w, "error", "Please name the folder to move matching mail into")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/rules", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if _, err := a.DB.CreateRule(mailbox.ID, priority, field, op, value, action, actionValue); err != nil {
|
||||
setFlash(w, "error", "Error creating rule")
|
||||
} else {
|
||||
setFlash(w, "success", "Rule added")
|
||||
}
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/rules", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) removeRule(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ruleID := int64(atoi(r.PathValue("rule_id")))
|
||||
if err := a.DB.RemoveRule(ruleID, mailbox.ID); err != nil {
|
||||
setFlash(w, "error", "Error removing rule")
|
||||
} else {
|
||||
setFlash(w, "success", "Rule removed")
|
||||
}
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/rules", http.StatusFound)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/mailstore"
|
||||
)
|
||||
|
||||
const bytesPerGB = 1024 * 1024 * 1024
|
||||
|
||||
// buildMailboxEmail mirrors buildSenderEmail exactly: the domain is always resolved
|
||||
// server-side by ID, never trusted as free text, so a mailbox can never end up
|
||||
// assigned to a domain its own address doesn't belong to.
|
||||
func (a *App) buildMailboxEmail(localPart string, domainID int64) (string, error) {
|
||||
dom, err := a.DB.GetDomainByID(domainID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if dom == nil || !validLocalPart.MatchString(localPart) {
|
||||
return "", nil
|
||||
}
|
||||
return localPart + "@" + dom.DomainName, nil
|
||||
}
|
||||
|
||||
func (a *App) mailboxesList(w http.ResponseWriter, r *http.Request) {
|
||||
mailboxes, err := a.DB.ListMailboxes()
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error loading mailboxes")
|
||||
}
|
||||
scope := scopeFromContext(r)
|
||||
var pairs [][2]any
|
||||
for _, m := range mailboxes {
|
||||
if !scope.Allowed(m.DomainID) {
|
||||
continue
|
||||
}
|
||||
pctFull := 0.0
|
||||
if m.QuotaBytes > 0 {
|
||||
pctFull = float64(m.UsedBytes) / float64(m.QuotaBytes) * 100
|
||||
}
|
||||
pairs = append(pairs, [2]any{m.Mailbox, M{"domain_name": m.DomainName, "pct_full": pctFull}})
|
||||
}
|
||||
a.render(w, r, "mailboxes.html", M{"active": "mailboxes", "mailboxes": pairs})
|
||||
}
|
||||
|
||||
func (a *App) addMailboxForm(w http.ResponseWriter, r *http.Request) {
|
||||
domains, _ := a.accessibleDomains(r)
|
||||
a.render(w, r, "add_mailbox.html", M{"active": "mailboxes", "domains": domains})
|
||||
}
|
||||
|
||||
// addMailbox mirrors addSender's shape: local_part + domain_id resolved server-side
|
||||
// into the real email, a random per-mailbox encryption key generated and sealed with
|
||||
// the server master key (see internal/mailstore), and quota defaulting to the owning
|
||||
// domain's configured default when left blank.
|
||||
func (a *App) addMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
localPart := strings.TrimSpace(r.FormValue("local_part"))
|
||||
password := r.FormValue("password")
|
||||
domainID := int64(atoi(r.FormValue("domain_id")))
|
||||
quotaGB := r.FormValue("quota_gb")
|
||||
|
||||
if !requireDomainAccess(w, r, domainID) {
|
||||
return
|
||||
}
|
||||
email, err := a.buildMailboxEmail(localPart, domainID)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error creating mailbox")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if email == "" || password == "" {
|
||||
setFlash(w, "error", "All fields are required and the local part may only contain letters, numbers, and . _ % + -")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if exists, _ := a.DB.MailboxEmailExists(email, -1); exists {
|
||||
setFlash(w, "error", "A mailbox with this email already exists")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
quotaBytes := parseQuotaGB(quotaGB)
|
||||
if quotaBytes <= 0 {
|
||||
quotaBytes, _ = a.DB.GetDomainDefaultQuota(domainID)
|
||||
}
|
||||
|
||||
hash, err := db.HashPassword(password)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error creating mailbox")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
dek := mailstore.GenerateDEK()
|
||||
wrapped, nonce, err := a.Mailstore.WrapDEK(dek)
|
||||
if err != nil {
|
||||
a.Logger.Error("wrap mailbox DEK: %v", err)
|
||||
setFlash(w, "error", "Error creating mailbox")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if _, err := a.DB.CreateMailbox(email, hash, domainID, quotaBytes, wrapped, nonce); err != nil {
|
||||
setFlash(w, "error", "Error creating mailbox")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
setFlash(w, "success", "Mailbox added successfully")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
|
||||
}
|
||||
|
||||
func parseQuotaGB(s string) int64 {
|
||||
gb, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
if err != nil || gb <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(gb * bytesPerGB)
|
||||
}
|
||||
|
||||
// mailboxWithAccess mirrors senderWithAccess.
|
||||
func (a *App) mailboxWithAccess(w http.ResponseWriter, r *http.Request) (mailbox *db.Mailbox, ok bool) {
|
||||
mailbox, err := a.DB.GetMailboxByID(pathID(r))
|
||||
if err != nil || mailbox == nil {
|
||||
http.NotFound(w, r)
|
||||
return nil, false
|
||||
}
|
||||
if !requireDomainAccess(w, r, mailbox.DomainID) {
|
||||
return nil, false
|
||||
}
|
||||
return mailbox, true
|
||||
}
|
||||
|
||||
func (a *App) disableMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.DB.SetMailboxActive(mailbox.ID, false); err != nil {
|
||||
setFlash(w, "error", "Error disabling mailbox")
|
||||
} else {
|
||||
setFlash(w, "success", "Mailbox disabled")
|
||||
}
|
||||
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) enableMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.DB.SetMailboxActive(mailbox.ID, true); err != nil {
|
||||
setFlash(w, "error", "Error enabling mailbox")
|
||||
} else {
|
||||
setFlash(w, "success", "Mailbox enabled")
|
||||
}
|
||||
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
|
||||
}
|
||||
|
||||
// removeMailbox deletes every stored message's on-disk ciphertext via mailstore first
|
||||
// (so nothing is orphaned on disk), then hard-deletes the mailbox row and everything
|
||||
// that references it.
|
||||
func (a *App) removeMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
uids, err := a.DB.ListMessageUIDsForMailbox(mailbox.ID)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error removing mailbox")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
|
||||
return
|
||||
}
|
||||
for _, uid := range uids {
|
||||
if err := a.Mailstore.DeleteMessage(mailbox.ID, uid); err != nil {
|
||||
a.Logger.Error("delete message %d for mailbox %d: %v", uid, mailbox.ID, err)
|
||||
}
|
||||
}
|
||||
if err := a.DB.RemoveMailboxCascade(mailbox.ID); err != nil {
|
||||
setFlash(w, "error", "Error removing mailbox")
|
||||
} else {
|
||||
setFlash(w, "success", "Mailbox permanently removed")
|
||||
}
|
||||
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) editMailboxForm(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
domains, _ := a.accessibleDomains(r)
|
||||
a.render(w, r, "edit_mailbox.html", M{
|
||||
"active": "mailboxes", "mailbox": mailbox, "domains": domains,
|
||||
"local_part": localPartOf(mailbox.Email), "quota_gb": float64(mailbox.QuotaBytes) / bytesPerGB,
|
||||
})
|
||||
}
|
||||
|
||||
// editMailbox allows changing the portal password and quota. The local part/domain
|
||||
// (and so the address itself) are intentionally NOT editable here — the IMAP/SMTP app
|
||||
// passwords and encryption key are already bound to this mailbox's identity, and
|
||||
// renaming it out from under those would orphan them. Remove and recreate instead.
|
||||
func (a *App) editMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
password := r.FormValue("password")
|
||||
quotaBytes := parseQuotaGB(r.FormValue("quota_gb"))
|
||||
if quotaBytes <= 0 {
|
||||
quotaBytes = mailbox.QuotaBytes
|
||||
}
|
||||
if err := a.DB.SetMailboxQuota(mailbox.ID, quotaBytes); err != nil {
|
||||
setFlash(w, "error", "Error updating mailbox")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if password != "" {
|
||||
hash, err := db.HashPassword(password)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error updating mailbox")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := a.DB.SetMailboxPasswordHash(mailbox.ID, hash); err != nil {
|
||||
setFlash(w, "error", "Error updating mailbox")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
setFlash(w, "success", "Mailbox updated successfully")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package webui
|
||||
|
||||
import "net/http"
|
||||
|
||||
// navCounts are the small per-resource counts shown as sidebar badges on every page
|
||||
// (and reused by the dashboard's own stat tiles, which use the same numbers) — scoped
|
||||
// to the current admin exactly like every list page already is.
|
||||
type navCounts struct {
|
||||
DomainCount, SenderCount, MailboxCount, IPCount, DKIMCount int
|
||||
}
|
||||
|
||||
func (a *App) computeNavCounts(r *http.Request) navCounts {
|
||||
scope := scopeFromContext(r)
|
||||
var c navCounts
|
||||
|
||||
if scope.Global {
|
||||
c.DomainCount, _ = a.DB.CountActiveDomains()
|
||||
c.SenderCount, _ = a.DB.CountActiveSenders()
|
||||
c.DKIMCount, _ = a.DB.CountActiveDKIMKeys()
|
||||
} else {
|
||||
domains, _ := a.DB.ListDomains()
|
||||
for _, d := range domains {
|
||||
if d.IsActive && scope.Allowed(d.ID) {
|
||||
c.DomainCount++
|
||||
}
|
||||
}
|
||||
senders, _ := a.DB.ListSenders()
|
||||
for _, s := range senders {
|
||||
if s.IsActive && scope.Allowed(s.DomainID) {
|
||||
c.SenderCount++
|
||||
}
|
||||
}
|
||||
keys, _ := a.DB.ListActiveDKIMKeysWithDomain()
|
||||
for _, k := range keys {
|
||||
if scope.Allowed(k.DomainID) {
|
||||
c.DKIMCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mailboxes and IPs always need a per-row pass regardless of scope.Global (no
|
||||
// dedicated CountActive* helpers exist for them), same as dashboard.go already did
|
||||
// for mailboxes before this was centralized.
|
||||
mailboxes, _ := a.DB.ListMailboxes()
|
||||
for _, m := range mailboxes {
|
||||
if m.IsActive && (scope.Global || scope.Allowed(m.DomainID)) {
|
||||
c.MailboxCount++
|
||||
}
|
||||
}
|
||||
ips, _ := a.DB.ListWhitelistedIPs()
|
||||
for _, ip := range ips {
|
||||
if ip.IsActive && (scope.Global || scope.Allowed(ip.DomainID)) {
|
||||
c.IPCount++
|
||||
}
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
@@ -20,7 +20,21 @@ type M map[string]any
|
||||
func (a *App) funcMap() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"formatDatetime": func(t time.Time) string { return formatDatetimeInZone(t, a.Cfg) },
|
||||
"strftime": func(layout string, t time.Time) string {
|
||||
// strftime accepts either time.Time or *time.Time (nullable DB columns like
|
||||
// MailboxAppPassword.LastUsedAt) so callers don't need a separate deref helper.
|
||||
"strftime": func(layout string, v any) string {
|
||||
var t time.Time
|
||||
switch tv := v.(type) {
|
||||
case time.Time:
|
||||
t = tv
|
||||
case *time.Time:
|
||||
if tv == nil {
|
||||
return ""
|
||||
}
|
||||
t = *tv
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
@@ -109,16 +123,21 @@ func humanFileSize(size int64) string {
|
||||
var pages = []string{
|
||||
"dashboard.html", "domains.html", "add_domain.html", "edit_domain.html",
|
||||
"senders.html", "add_sender.html", "edit_sender.html",
|
||||
"mailboxes.html", "add_mailbox.html", "edit_mailbox.html", "mailbox_apppasswords.html", "mailbox_aliases.html",
|
||||
"mailbox_lists.html", "mailbox_rules.html",
|
||||
"ips.html", "add_ip.html", "edit_ip.html",
|
||||
"dkim.html", "edit_dkim.html",
|
||||
"settings.html", "logs.html", "view_message_content.html", "error.html",
|
||||
"settings.html", "letsencrypt.html", "logs.html", "view_message_content.html", "error.html",
|
||||
"account.html", "first_login.html", "totp_setup.html",
|
||||
"admins.html", "add_admin.html", "edit_admin.html",
|
||||
}
|
||||
|
||||
// standalonePages are pre-login screens — they intentionally don't use base.html's
|
||||
// sidebar/dashboard chrome, since the visitor isn't authenticated yet.
|
||||
var standalonePages = []string{"login.html", "login_mfa.html"}
|
||||
var standalonePages = []string{
|
||||
"login.html", "login_mfa.html",
|
||||
"webmail_login.html", "webmail_login_mfa.html", "webmail_account.html", "webmail_totp_setup.html",
|
||||
}
|
||||
|
||||
// loadTemplates parses from the embedded assets FS (see embed.go), not the
|
||||
// filesystem — the binary carries its own templates, so it runs from any working
|
||||
@@ -173,6 +192,15 @@ func (a *App) render(w http.ResponseWriter, r *http.Request, page string, data M
|
||||
}
|
||||
data["flashes"] = popFlashes(w, r)
|
||||
data["health"] = a.checkHealth()
|
||||
// Sidebar badge counts (Domains/Senders/Mailboxes/IPs/DKIM Keys) — computed here,
|
||||
// centrally, so every authenticated page shows them, not just the dashboard (which
|
||||
// used to compute these itself and nowhere else did).
|
||||
counts := a.computeNavCounts(r)
|
||||
data["domain_count"] = counts.DomainCount
|
||||
data["sender_count"] = counts.SenderCount
|
||||
data["mailbox_count"] = counts.MailboxCount
|
||||
data["ip_count"] = counts.IPCount
|
||||
data["dkim_count"] = counts.DKIMCount
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, "base.html", data); err != nil {
|
||||
a.Logger.Error("template render error (%s): %v", page, err)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{{define "title"}}Add Mailbox - Email Server{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header"><h4 class="mb-0"><i class="bi bi-mailbox me-2"></i>Add New Mailbox</h4></div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="local_part" class="form-label">Email Address</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="local_part" name="local_part" required placeholder="user"
|
||||
pattern="[a-zA-Z0-9._%+-]+" title="Letters, numbers, and . _ % + - only">
|
||||
<span class="input-group-text">@</span>
|
||||
<select class="form-select" id="domain_id" name="domain_id" required style="max-width: 260px;">
|
||||
<option value="">Select a domain...</option>
|
||||
{{range .domains}}<option value="{{.ID}}">{{.DomainName}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-text">This is the mailbox's login username — it never changes, even if aliases are added later.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required minlength="8">
|
||||
<div class="form-text">Used for the self-service account portal only — never for IMAP/SMTP clients. Those use a separate app password (create one after adding this mailbox).</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="quota_gb" class="form-label">Storage Quota (GB)</label>
|
||||
<input type="number" class="form-control" id="quota_gb" name="quota_gb" min="0.1" step="0.1" placeholder="5">
|
||||
<div class="form-text">Leave blank to use the domain's default quota.</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Mailboxes</a>
|
||||
<button type="submit" class="btn btn-success"><i class="bi bi-mailbox me-2"></i>Add Mailbox</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -5,6 +5,14 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{block "title" .}}Email Server Management{{end}}</title>
|
||||
|
||||
<script>
|
||||
// Applied before first paint so an unpinned sidebar starts collapsed, not
|
||||
// pinned-then-flashing-collapsed once the stylesheet/JS below catches up.
|
||||
if (localStorage.getItem('sidebarPinned') === 'false') {
|
||||
document.documentElement.classList.add('sidebar-unpinned');
|
||||
}
|
||||
</script>
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
@@ -13,6 +21,17 @@
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||
.main-container { display: flex; min-height: 100vh; }
|
||||
.content-area { flex: 1; margin-left: var(--sidebar-width); padding: 20px; transition: margin-left 0.3s ease; }
|
||||
/* Unpinned sidebar: collapsed off-screen, floats over full-width content until
|
||||
pinned again. Revealed by the toggle button or by moving the mouse to the
|
||||
left edge (see JS below); hidden again on mouseleave. */
|
||||
html.sidebar-unpinned .sidebar { transform: translateX(-100%); }
|
||||
html.sidebar-unpinned .sidebar.sidebar-open { transform: translateX(0); box-shadow: 4px 0 24px rgba(0, 0, 0, 0.5); }
|
||||
html.sidebar-unpinned .content-area { margin-left: 0 !important; }
|
||||
/* Sits in the page-title bar, not fixed over the page — so when the sidebar
|
||||
peeks open (z-index above content), it naturally covers this button instead
|
||||
of the button floating on top of the sidebar's own header. */
|
||||
.sidebar-toggle-btn { display: none; }
|
||||
html.sidebar-unpinned .sidebar-toggle-btn { display: inline-flex; }
|
||||
.navbar-brand { color: #fff !important; }
|
||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
|
||||
@@ -52,7 +71,10 @@
|
||||
<div class="content-area">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand mb-0 h1">
|
||||
<span class="navbar-brand mb-0 h1 d-flex align-items-center">
|
||||
<button id="sidebarToggleBtn" class="btn btn-sm btn-outline-light me-2 sidebar-toggle-btn" title="Show sidebar" onclick="showSidebarPeek()">
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
||||
<i class="bi bi-envelope-fill me-2"></i>
|
||||
{{block "page_title" .}}Email Server Management{{end}}
|
||||
</span>
|
||||
@@ -118,6 +140,47 @@
|
||||
setInterval(updateTime, 1000);
|
||||
updateTime();
|
||||
|
||||
// Sidebar pin/unpin — pinned (default) keeps today's always-visible behavior.
|
||||
// Unpinned collapses it off-screen so the content area gets the full width;
|
||||
// it re-appears as an overlay via the toggle button or by nudging the mouse to
|
||||
// the left edge, and hides again once the mouse leaves it.
|
||||
function isSidebarPinned() { return localStorage.getItem('sidebarPinned') !== 'false'; }
|
||||
function updateSidebarPinUI() {
|
||||
const icon = document.getElementById('sidebarPinIcon');
|
||||
const btn = document.getElementById('sidebarPinBtn');
|
||||
if (!icon || !btn) return;
|
||||
const pinned = isSidebarPinned();
|
||||
icon.className = pinned ? 'bi bi-pin-angle-fill' : 'bi bi-pin-angle';
|
||||
btn.title = pinned ? 'Unpin sidebar (auto-hide)' : 'Pin sidebar (keep open)';
|
||||
}
|
||||
function hideSidebarPeek() {
|
||||
const sidebarEl = document.querySelector('.sidebar');
|
||||
if (sidebarEl) sidebarEl.classList.remove('sidebar-open');
|
||||
}
|
||||
function showSidebarPeek() {
|
||||
const sidebarEl = document.querySelector('.sidebar');
|
||||
if (sidebarEl) sidebarEl.classList.add('sidebar-open');
|
||||
}
|
||||
function toggleSidebarPin() {
|
||||
const pinned = !isSidebarPinned();
|
||||
localStorage.setItem('sidebarPinned', pinned ? 'true' : 'false');
|
||||
document.documentElement.classList.toggle('sidebar-unpinned', !pinned);
|
||||
updateSidebarPinUI();
|
||||
hideSidebarPeek();
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateSidebarPinUI();
|
||||
const sidebarEl = document.querySelector('.sidebar');
|
||||
if (sidebarEl) {
|
||||
sidebarEl.addEventListener('mouseleave', function() {
|
||||
if (!isSidebarPinned()) hideSidebarPeek();
|
||||
});
|
||||
}
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!isSidebarPinned() && e.clientX <= 15) showSidebarPeek();
|
||||
});
|
||||
});
|
||||
|
||||
// Notifications auto-dismiss after 5s, but hovering (reading, or selecting
|
||||
// text to copy) pauses the timer — it only resumes once the mouse leaves.
|
||||
// Clicking inside never dismisses; only the X button or the timer does.
|
||||
|
||||
@@ -54,6 +54,27 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-6 mb-4">
|
||||
<a href="/pymta-manager/mailboxes" class="text-decoration-none">
|
||||
<div class="card {{if gt .mailboxes_near_quota 0}}border-danger{{else}}border-primary{{end}}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="card-title {{if gt .mailboxes_near_quota 0}}text-danger{{else}}text-primary{{end}} mb-1"><i class="bi bi-inbox me-2"></i>Mailboxes</h5>
|
||||
<h3 class="mb-0">{{.mailbox_count}}</h3>
|
||||
{{if gt .mailboxes_near_quota 0}}
|
||||
<small class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>{{.mailboxes_near_quota}} near quota</small>
|
||||
{{else}}
|
||||
<small class="text-muted">Active mailboxes</small>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="fs-2 {{if gt .mailboxes_near_quota 0}}text-danger{{else}}text-primary{{end}} opacity-50"><i class="bi bi-inbox"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-6 mb-4">
|
||||
<div class="card border-info">
|
||||
<div class="card-body">
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{{define "title"}}Edit Mailbox - SMTP Management{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-mailbox2 me-2"></i>Edit Mailbox</h5></div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email Address</label>
|
||||
<input type="text" class="form-control" value="{{.mailbox.Email}}" disabled>
|
||||
<div class="form-text">The address can't be changed here — app passwords and stored mail are bound to it. Remove and recreate the mailbox instead.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" placeholder="Leave blank to keep current password">
|
||||
<div class="form-text">Only enter a password if you want to change it — used for the self-service portal, never IMAP/SMTP.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="quota_gb" class="form-label">Storage Quota (GB)</label>
|
||||
<input type="number" class="form-control" id="quota_gb" name="quota_gb" min="0.1" step="0.1" value="{{printf "%.2f" .quota_gb}}">
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Update Mailbox</button>
|
||||
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-x-lg me-1"></i>Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><h6 class="mb-0"><i class="bi bi-info-circle me-2"></i>Current Mailbox Details</h6></div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-5">Email:</dt><dd class="col-sm-7"><code>{{.mailbox.Email}}</code></dd>
|
||||
<dt class="col-sm-5">Domain:</dt>
|
||||
<dd class="col-sm-7">{{range .domains}}{{if eq .ID $.mailbox.DomainID}}<span class="badge bg-secondary">{{.DomainName}}</span>{{end}}{{end}}</dd>
|
||||
<dt class="col-sm-5">Status:</dt>
|
||||
<dd class="col-sm-7">{{if .mailbox.IsActive}}<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Active</span>{{else}}<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Inactive</span>{{end}}</dd>
|
||||
<dt class="col-sm-5">Storage:</dt>
|
||||
<dd class="col-sm-7"><small class="text-muted">{{filesize .mailbox.UsedBytes}} / {{filesize .mailbox.QuotaBytes}}</small></dd>
|
||||
<dt class="col-sm-5">Created:</dt><dd class="col-sm-7"><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .mailbox.CreatedAt}}</small></dd>
|
||||
</dl>
|
||||
<a href="/pymta-manager/mailboxes/{{.mailbox.ID}}/apppasswords" class="btn btn-outline-secondary btn-sm w-100 mt-2"><i class="bi bi-key me-1"></i>Manage App Passwords</a>
|
||||
<a href="/pymta-manager/mailboxes/{{.mailbox.ID}}/aliases" class="btn btn-outline-secondary btn-sm w-100 mt-2"><i class="bi bi-signpost-split me-1"></i>Manage Aliases</a>
|
||||
<a href="/pymta-manager/mailboxes/{{.mailbox.ID}}/lists" class="btn btn-outline-secondary btn-sm w-100 mt-2"><i class="bi bi-shield-exclamation me-1"></i>Allow/Block List</a>
|
||||
<a href="/pymta-manager/mailboxes/{{.mailbox.ID}}/rules" class="btn btn-outline-secondary btn-sm w-100 mt-2"><i class="bi bi-funnel me-1"></i>Filter Rules</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -7,12 +7,10 @@
|
||||
<a href="/pymta-manager/ips/add" class="btn btn-success"><i class="bi bi-plus-circle me-2"></i>Add IP Address</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list me-2"></i>Whitelisted IP Addresses</h5></div>
|
||||
<div class="card-body">
|
||||
{{if .ips}}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list me-2"></i>Whitelisted IP Addresses</h5></div>
|
||||
<div class="card-body">
|
||||
{{if .ips}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead><tr><th>IP Address</th><th>Domain</th><th>Status</th><th>Storage Type</th><th>Added</th><th>Actions</th></tr></thead>
|
||||
@@ -56,14 +54,14 @@
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header"><h6 class="mb-0"><i class="bi bi-info-circle me-2"></i>IP Whitelist Information</h6></div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
<div class="alert alert-info mb-0">
|
||||
<h6 class="alert-heading"><i class="bi bi-shield-check me-2"></i>How IP Whitelisting Works</h6>
|
||||
<ul class="mb-0 small">
|
||||
<li>Whitelisted IPs can send emails without username/password authentication</li>
|
||||
@@ -74,8 +72,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><h6 class="mb-0"><i class="bi bi-geo-alt me-2"></i>Your Current IP</h6></div>
|
||||
<div class="card-body">
|
||||
<div class="text-center">
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
{{define "title"}}Let's Encrypt - Email Server Management{{end}}
|
||||
{{define "page_title"}}Let's Encrypt{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-patch-check me-2"></i>Let's Encrypt</h2>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-check me-2"></i>Status</h5></div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-3">
|
||||
<dt class="col-sm-3">Mode</dt>
|
||||
<dd class="col-sm-9">
|
||||
{{if .status.Enabled}}
|
||||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Let's Encrypt {{if .status.Staging}}(staging){{end}}</span>
|
||||
{{else}}
|
||||
<span class="badge bg-secondary"><i class="bi bi-dash-circle me-1"></i>Self-signed (Let's Encrypt disabled)</span>
|
||||
{{end}}
|
||||
</dd>
|
||||
<dt class="col-sm-3">Domains</dt>
|
||||
<dd class="col-sm-9">{{if .status.Domains}}{{range .status.Domains}}<code>{{.}}</code> {{end}}{{else}}<span class="text-muted">none configured</span>{{end}}</dd>
|
||||
<dt class="col-sm-3">Provider</dt>
|
||||
<dd class="col-sm-9">{{if .status.Provider}}{{.status.Provider}}{{else}}<span class="text-muted">none selected</span>{{end}}</dd>
|
||||
<dt class="col-sm-3">Certificate expires</dt>
|
||||
<dd class="col-sm-9">{{if .status.NotAfter.IsZero}}<span class="text-muted">unknown</span>{{else}}{{strftime "%Y-%m-%d %H:%M" .status.NotAfter}}{{end}}</dd>
|
||||
<dt class="col-sm-3">Last attempt</dt>
|
||||
<dd class="col-sm-9">
|
||||
{{if .status.LastAttempt.IsZero}}
|
||||
<span class="text-muted">none yet this run</span>
|
||||
{{else if .status.LastError}}
|
||||
<span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>{{strftime "%Y-%m-%d %H:%M" .status.LastAttempt}} — {{.status.LastError}}</span>
|
||||
{{else}}
|
||||
<span class="text-success"><i class="bi bi-check-circle me-1"></i>{{strftime "%Y-%m-%d %H:%M" .status.LastAttempt}} — success</span>
|
||||
{{end}}
|
||||
</dd>
|
||||
</dl>
|
||||
<form method="post" action="/pymta-manager/letsencrypt/obtain">
|
||||
<button type="submit" class="btn btn-primary" data-confirm="Obtain or renew the certificate now using the saved configuration?"><i class="bi bi-arrow-repeat me-1"></i>Obtain / Renew Now</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/pymta-manager/letsencrypt/save">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-gear me-2"></i>Configuration</h5></div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Enable Let's Encrypt</label>
|
||||
<select class="form-select" name="enabled">
|
||||
<option value="false" {{if ne .le.enabled "true"}}selected{{end}}>No — keep the self-signed certificate</option>
|
||||
<option value="true" {{if eq .le.enabled "true"}}selected{{end}}>Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Staging mode</label>
|
||||
<select class="form-select" name="staging">
|
||||
<option value="false" {{if ne .le.staging "true"}}selected{{end}}>No — request a real, trusted certificate</option>
|
||||
<option value="true" {{if eq .le.staging "true"}}selected{{end}}>Yes — untrusted test certificate, no rate limits</option>
|
||||
</select>
|
||||
<div class="form-text">Recommended while testing a new configuration.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Contact Email</label>
|
||||
<input type="email" class="form-control" name="contact_email" value="{{.le.contact_email}}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Domains</label>
|
||||
<input type="text" class="form-control font-monospace" name="domains" value="{{.le.domains}}" placeholder="mail.example.com,*.mail.example.com">
|
||||
<div class="form-text">Comma-separated. Include a wildcard entry (e.g. <code>*.mail.example.com</code>) alongside its bare domain to cover both with one certificate.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">DNS Provider</label>
|
||||
<select class="form-select" name="dns_provider" id="le_provider">
|
||||
<option value="">Select a provider...</option>
|
||||
<option value="cloudflare" {{if eq .le.dns_provider "cloudflare"}}selected{{end}}>Cloudflare</option>
|
||||
<option value="route53" {{if eq .le.dns_provider "route53"}}selected{{end}}>AWS Route53</option>
|
||||
<option value="digitalocean" {{if eq .le.dns_provider "digitalocean"}}selected{{end}}>DigitalOcean</option>
|
||||
<option value="gcloud" {{if eq .le.dns_provider "gcloud"}}selected{{end}}>Google Cloud DNS</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="provider-fields" id="fields-cloudflare">
|
||||
<div class="setting-section mb-3">
|
||||
<h6>Cloudflare</h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">API Token</label>
|
||||
<input type="password" class="form-control" name="cloudflare_api_token" placeholder="Leave blank to keep the current value">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="provider-fields" id="fields-route53">
|
||||
<div class="setting-section mb-3">
|
||||
<h6>AWS Route53</h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Access Key ID</label>
|
||||
<input type="password" class="form-control" name="route53_access_key_id" placeholder="Leave blank to keep the current value, or blank both keys to use the host's AWS credential chain">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Secret Access Key</label>
|
||||
<input type="password" class="form-control" name="route53_secret_access_key" placeholder="Leave blank to keep the current value">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Region</label>
|
||||
<input type="text" class="form-control" name="route53_region" value="{{.le.route53_region}}" placeholder="us-east-1">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Hosted Zone ID (optional)</label>
|
||||
<input type="text" class="form-control" name="route53_hosted_zone_id" value="{{.le.route53_hosted_zone_id}}" placeholder="Leave blank to auto-discover">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="provider-fields" id="fields-digitalocean">
|
||||
<div class="setting-section mb-3">
|
||||
<h6>DigitalOcean</h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">API Token</label>
|
||||
<input type="password" class="form-control" name="digitalocean_api_token" placeholder="Leave blank to keep the current value">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="provider-fields" id="fields-gcloud">
|
||||
<div class="setting-section mb-3">
|
||||
<h6>Google Cloud DNS</h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Project ID</label>
|
||||
<input type="text" class="form-control" name="gcloud_project" value="{{.le.gcloud_project}}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Service Account Key (optional)</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace" name="gcloud_service_account_json_path" id="gcloud_sa_path" placeholder="Leave blank to use Application Default Credentials">
|
||||
<input type="file" class="d-none" id="gcloudKeyUpload" accept=".json">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('gcloudKeyUpload').click()"><i class="bi bi-upload"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success"><i class="bi bi-check-lg me-1"></i>Save Configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
{{define "extra_js"}}
|
||||
<script>
|
||||
function updateProviderFields() {
|
||||
const selected = document.getElementById('le_provider').value;
|
||||
document.querySelectorAll('.provider-fields').forEach(function(el) {
|
||||
el.style.display = (el.id === 'fields-' + selected) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
document.getElementById('le_provider').addEventListener('change', updateProviderFields);
|
||||
updateProviderFields();
|
||||
|
||||
document.getElementById('gcloudKeyUpload').addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append('gcloud_key_file', file);
|
||||
fetch('/pymta-manager/api/letsencrypt/upload_gcloud_key', { method: 'POST', body: formData })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') { document.getElementById('gcloud_sa_path').value = data.filepath; showToast('Service account key uploaded', 'success'); }
|
||||
else { showToast(data.message || 'Failed to upload key', 'danger'); }
|
||||
}).catch(() => showToast('Failed to upload key', 'danger'));
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,76 @@
|
||||
{{define "title"}}Aliases - Email Server{{end}}
|
||||
{{define "page_title"}}Aliases{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-signpost-split me-2"></i>Aliases <small class="text-muted fs-6">{{.mailbox.Email}}</small></h2>
|
||||
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Mailboxes</a>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>An alias lets this mailbox receive mail at another address. Enable "send as" to also let it send mail using that address. The login address is always <code>{{.mailbox.Email}}</code> — aliases never change that.
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Add Alias</h5></div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/aliases/add">
|
||||
<div class="mb-3">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="local_part" name="local_part" required placeholder="alias"
|
||||
pattern="[a-zA-Z0-9._%+-]+" title="Letters, numbers, and . _ % + - only">
|
||||
<span class="input-group-text">@</span>
|
||||
<select class="form-select" id="domain_id" name="domain_id" required style="max-width: 260px;">
|
||||
<option value="">Select a domain...</option>
|
||||
{{range .domains}}<option value="{{.ID}}">{{.DomainName}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="can_send_as" name="can_send_as">
|
||||
<label class="form-check-label" for="can_send_as"><strong>Allow sending as this address</strong></label>
|
||||
<div class="form-text">If enabled, this mailbox can use MAIL FROM with this alias once authenticated with its app password.</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success"><i class="bi bi-signpost-split me-2"></i>Add Alias</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Existing Aliases</h5></div>
|
||||
<div class="card-body p-0">
|
||||
{{if .aliases}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead><tr><th>Address</th><th>Permissions</th><th>Created</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .aliases}}
|
||||
<tr>
|
||||
<td>{{.Email}}</td>
|
||||
<td>
|
||||
{{if .CanSendAs}}<span class="badge bg-warning text-dark"><i class="bi bi-send me-1"></i>Receive & Send</span>
|
||||
{{else}}<span class="badge bg-secondary"><i class="bi bi-inbox me-1"></i>Receive Only</span>{{end}}
|
||||
</td>
|
||||
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .CreatedAt}}</small></td>
|
||||
<td>
|
||||
<form method="post" action="/pymta-manager/mailboxes/{{$.mailbox.ID}}/aliases/{{.ID}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove alias {{.Email}}?"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-signpost-split text-muted" style="font-size: 4rem;"></i>
|
||||
<h4 class="text-muted mt-3">No aliases yet</h4>
|
||||
<p class="text-muted">Add one above to let this mailbox receive mail at another address.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,62 @@
|
||||
{{define "title"}}App Passwords - Email Server{{end}}
|
||||
{{define "page_title"}}App Passwords{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-key me-2"></i>App Passwords <small class="text-muted fs-6">{{.mailbox.Email}}</small></h2>
|
||||
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Mailboxes</a>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>Use an app password (never the mailbox's own password) to set this mailbox up in Thunderbird or any other IMAP/SMTP client. Each one is shown only once, right after you create it.
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Create App Password</h5></div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/apppasswords/add" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label for="label" class="form-label">Label</label>
|
||||
<input type="text" class="form-control" id="label" name="label" placeholder="e.g. Thunderbird laptop">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-success"><i class="bi bi-key me-2"></i>Generate</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Existing App Passwords</h5></div>
|
||||
<div class="card-body p-0">
|
||||
{{if .passwords}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead><tr><th>Label</th><th>Created</th><th>Last Used</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .passwords}}
|
||||
<tr>
|
||||
<td>{{.Label}}</td>
|
||||
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .CreatedAt}}</small></td>
|
||||
<td><small class="text-muted">{{if .LastUsedAt}}{{strftime "%Y-%m-%d %H:%M" .LastUsedAt}}{{else}}Never{{end}}</small></td>
|
||||
<td>{{if .IsActive}}<span class="badge bg-success">Active</span>{{else}}<span class="badge bg-danger">Revoked</span>{{end}}</td>
|
||||
<td>
|
||||
<form method="post" action="/pymta-manager/mailboxes/{{$.mailbox.ID}}/apppasswords/{{.ID}}/revoke" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="Revoke" data-confirm="Revoke app password "{{.Label}}"? Any client using it will stop working."><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-key text-muted" style="font-size: 4rem;"></i>
|
||||
<h4 class="text-muted mt-3">No app passwords yet</h4>
|
||||
<p class="text-muted">Create one above to connect a mail client to this mailbox.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,69 @@
|
||||
{{define "title"}}Allow/Block List - Email Server{{end}}
|
||||
{{define "page_title"}}Allow/Block List{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-shield-exclamation me-2"></i>Allow/Block List <small class="text-muted fs-6">{{.mailbox.Email}}</small></h2>
|
||||
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Mailboxes</a>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>A pattern is either an exact address (<code>spam@evil.com</code>) or a whole domain (<code>@evil.com</code>). Block entries reject mail at RCPT time; allow entries bypass spam scoring entirely for that sender.
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0 text-success"><i class="bi bi-check-circle me-2"></i>Allow List</h5></div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/lists/add" class="d-flex gap-2 mb-3">
|
||||
<input type="hidden" name="list_type" value="allow">
|
||||
<input type="text" class="form-control" name="pattern" placeholder="friend@example.com or @example.com" required>
|
||||
<button type="submit" class="btn btn-success"><i class="bi bi-plus-lg"></i></button>
|
||||
</form>
|
||||
{{if .allow}}
|
||||
<ul class="list-group list-group-flush">
|
||||
{{range .allow}}
|
||||
<li class="list-group-item list-group-item-dark d-flex justify-content-between align-items-center">
|
||||
<code>{{.Pattern}}</code>
|
||||
<form method="post" action="/pymta-manager/mailboxes/{{$.mailbox.ID}}/lists/{{.ID}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Remove {{.Pattern}} from the allow list?"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="text-muted mb-0">No allow-list entries.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0 text-danger"><i class="bi bi-x-circle me-2"></i>Block List</h5></div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/lists/add" class="d-flex gap-2 mb-3">
|
||||
<input type="hidden" name="list_type" value="block">
|
||||
<input type="text" class="form-control" name="pattern" placeholder="spam@evil.com or @evil.com" required>
|
||||
<button type="submit" class="btn btn-danger"><i class="bi bi-plus-lg"></i></button>
|
||||
</form>
|
||||
{{if .block}}
|
||||
<ul class="list-group list-group-flush">
|
||||
{{range .block}}
|
||||
<li class="list-group-item list-group-item-dark d-flex justify-content-between align-items-center">
|
||||
<code>{{.Pattern}}</code>
|
||||
<form method="post" action="/pymta-manager/mailboxes/{{$.mailbox.ID}}/lists/{{.ID}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Remove {{.Pattern}} from the block list?"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="text-muted mb-0">No block-list entries.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,104 @@
|
||||
{{define "title"}}Filter Rules - Email Server{{end}}
|
||||
{{define "page_title"}}Filter Rules{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-funnel me-2"></i>Filter Rules <small class="text-muted fs-6">{{.mailbox.Email}}</small></h2>
|
||||
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Mailboxes</a>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>Rules run in priority order (lowest first) at delivery time; the first match wins. "Move to folder" delivers into a separate IMAP folder instead of INBOX — your mail client will show it once mail has actually landed there.
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Add Rule</h5></div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/rules/add" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Priority</label>
|
||||
<input type="number" class="form-control" name="priority" value="0" style="width: 90px;">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label">If</label>
|
||||
<select class="form-select" name="condition_field">
|
||||
<option value="from">From</option>
|
||||
<option value="to">To</option>
|
||||
<option value="subject">Subject</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select class="form-select" name="condition_op">
|
||||
<option value="contains">contains</option>
|
||||
<option value="equals">equals</option>
|
||||
<option value="starts_with">starts with</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="text" class="form-control" name="condition_value" placeholder="value" required>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Then</label>
|
||||
<select class="form-select" name="action" id="rule_action">
|
||||
<option value="move_to_folder">Move to folder</option>
|
||||
<option value="delete">Delete</option>
|
||||
<option value="mark_read">Mark as read</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="text" class="form-control" name="action_value" id="rule_action_value" placeholder="folder name">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-success"><i class="bi bi-funnel me-2"></i>Add Rule</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Existing Rules</h5></div>
|
||||
<div class="card-body p-0">
|
||||
{{if .rules}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead><tr><th>Priority</th><th>Condition</th><th>Action</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .rules}}
|
||||
<tr>
|
||||
<td>{{.Priority}}</td>
|
||||
<td><code>{{.ConditionField}} {{.ConditionOp}} "{{.ConditionValue}}"</code></td>
|
||||
<td>
|
||||
{{if eq .Action "move_to_folder"}}Move to <strong>{{.ActionValue}}</strong>
|
||||
{{else if eq .Action "delete"}}<span class="text-danger">Delete</span>
|
||||
{{else}}Mark as read{{end}}
|
||||
</td>
|
||||
<td>{{if .IsActive}}<span class="badge bg-success">Active</span>{{else}}<span class="badge bg-secondary">Inactive</span>{{end}}</td>
|
||||
<td>
|
||||
<form method="post" action="/pymta-manager/mailboxes/{{$.mailbox.ID}}/rules/{{.ID}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove this rule?"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-funnel text-muted" style="font-size: 4rem;"></i>
|
||||
<h4 class="text-muted mt-3">No rules yet</h4>
|
||||
<p class="text-muted">Add one above to automatically sort or act on incoming mail.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "extra_js"}}
|
||||
<script>
|
||||
document.getElementById('rule_action').addEventListener('change', function(e) {
|
||||
const valueInput = document.getElementById('rule_action_value');
|
||||
valueInput.style.display = e.target.value === 'move_to_folder' ? '' : 'none';
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,70 @@
|
||||
{{define "title"}}Mailboxes - Email Server Management{{end}}
|
||||
{{define "page_title"}}Mailbox Management{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-inbox me-2"></i>Mailboxes</h2>
|
||||
<a href="/pymta-manager/mailboxes/add" class="btn btn-primary"><i class="bi bi-mailbox me-2"></i>Add Mailbox</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>All Mailboxes</h5></div>
|
||||
<div class="card-body p-0">
|
||||
{{if .mailboxes}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead><tr><th>Email</th><th>Domain</th><th>Status</th><th>Storage</th><th>Created</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .mailboxes}}
|
||||
{{$mailbox := index . 0}}{{$extra := index . 1}}
|
||||
<tr>
|
||||
<td><div class="fw-bold">{{$mailbox.Email}}</div></td>
|
||||
<td><span class="badge bg-secondary">{{$extra.domain_name}}</span></td>
|
||||
<td>
|
||||
{{if $mailbox.IsActive}}<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Active</span>
|
||||
{{else}}<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Inactive</span>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">{{filesize $mailbox.UsedBytes}} / {{filesize $mailbox.QuotaBytes}}</small>
|
||||
{{if ge $extra.pct_full 90.0}}
|
||||
<span class="badge bg-danger ms-1"><i class="bi bi-exclamation-octagon me-1"></i>{{printf "%.0f" $extra.pct_full}}% full</span>
|
||||
{{else if ge $extra.pct_full 75.0}}
|
||||
<span class="badge bg-warning text-dark ms-1"><i class="bi bi-exclamation-triangle me-1"></i>{{printf "%.0f" $extra.pct_full}}% full</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" $mailbox.CreatedAt}}</small></td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="/pymta-manager/mailboxes/{{$mailbox.ID}}/apppasswords" class="btn btn-outline-secondary btn-sm" title="App Passwords"><i class="bi bi-key"></i></a>
|
||||
<a href="/pymta-manager/mailboxes/{{$mailbox.ID}}/aliases" class="btn btn-outline-secondary btn-sm" title="Aliases"><i class="bi bi-signpost-split"></i></a>
|
||||
<a href="/pymta-manager/mailboxes/{{$mailbox.ID}}/edit" class="btn btn-outline-primary btn-sm" title="Edit Mailbox"><i class="bi bi-pencil"></i></a>
|
||||
{{if $mailbox.IsActive}}
|
||||
<form method="post" action="/pymta-manager/mailboxes/{{$mailbox.ID}}/delete" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-warning btn-sm" title="Disable Mailbox" data-confirm="Disable mailbox {{$mailbox.Email}}?"><i class="bi bi-pause-circle"></i></button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="post" action="/pymta-manager/mailboxes/{{$mailbox.ID}}/enable" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-success btn-sm" title="Enable Mailbox" data-confirm="Enable mailbox {{$mailbox.Email}}?"><i class="bi bi-play-circle"></i></button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/pymta-manager/mailboxes/{{$mailbox.ID}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="Permanently Remove Mailbox" data-confirm="Permanently remove mailbox {{$mailbox.Email}} and all its stored mail? This cannot be undone!"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox text-muted" style="font-size: 4rem;"></i>
|
||||
<h4 class="text-muted mt-3">No mailboxes configured</h4>
|
||||
<p class="text-muted">Add a mailbox to let a client app (Thunderbird, etc.) receive mail via IMAP</p>
|
||||
<a href="/pymta-manager/mailboxes/add" class="btn btn-primary"><i class="bi bi-mailbox me-2"></i>Add Your First Mailbox</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,12 +1,17 @@
|
||||
{{define "sidebar_email.html"}}
|
||||
<nav class="sidebar bg-dark border-end border-secondary position-fixed h-100" style="width: var(--sidebar-width); z-index: 1000;">
|
||||
<div class="d-flex flex-column h-100">
|
||||
<div class="p-3 border-bottom border-secondary">
|
||||
<h5 class="text-white mb-0">
|
||||
<i class="bi bi-server me-2"></i>
|
||||
SMTP Server
|
||||
</h5>
|
||||
<small class="text-muted">Management Console</small>
|
||||
<div class="p-3 border-bottom border-secondary d-flex align-items-start justify-content-between">
|
||||
<div>
|
||||
<h5 class="text-white mb-0">
|
||||
<i class="bi bi-server me-2"></i>
|
||||
SMTP Server
|
||||
</h5>
|
||||
<small class="text-muted">Management Console</small>
|
||||
</div>
|
||||
<button id="sidebarPinBtn" class="btn btn-sm btn-outline-secondary" title="Unpin sidebar (auto-hide)" onclick="toggleSidebarPin()">
|
||||
<i class="bi bi-pin-angle-fill" id="sidebarPinIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow-1 overflow-auto">
|
||||
@@ -41,6 +46,14 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item mb-1">
|
||||
<a href="/pymta-manager/mailboxes" class="nav-link text-white {{if eq (dget . "active") "mailboxes"}}active{{end}}">
|
||||
<i class="bi bi-inbox me-2"></i>
|
||||
Mailboxes
|
||||
<span class="badge bg-secondary ms-auto">{{dget . "mailbox_count"}}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item mb-1">
|
||||
<a href="/pymta-manager/ips" class="nav-link text-white {{if eq (dget . "active") "ips"}}active{{end}}">
|
||||
<i class="bi bi-router me-2"></i>
|
||||
@@ -57,6 +70,13 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item mb-1">
|
||||
<a href="/pymta-manager/letsencrypt" class="nav-link text-white {{if eq (dget . "active") "letsencrypt"}}active{{end}}">
|
||||
<i class="bi bi-patch-check me-2"></i>
|
||||
Let's Encrypt
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item mb-1">
|
||||
<a href="/pymta-manager/logs" class="nav-link text-white {{if eq (dget . "active") "logs"}}active{{end}}">
|
||||
<i class="bi bi-journal-text me-2"></i>
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
{{define "webmail_account.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.mailbox.Email}} - Webmail</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
||||
<form method="post" action="/webmail/logout" class="ms-auto">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
||||
{{range .flashes}}
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
||||
{{.Message}}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="container pb-5">
|
||||
<div class="row">
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-hdd me-2"></i>Storage</h5></div>
|
||||
<div class="card-body">
|
||||
<div class="progress mb-2" style="height: 1.25rem;">
|
||||
<div class="progress-bar {{if ge .pct_full 90.0}}bg-danger{{else if ge .pct_full 75.0}}bg-warning{{else}}bg-success{{end}}" style="width: {{printf "%.0f" .pct_full}}%">{{printf "%.0f" .pct_full}}%</div>
|
||||
</div>
|
||||
<small class="text-muted">{{filesize .mailbox.UsedBytes}} of {{filesize .mailbox.QuotaBytes}} used</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-key-fill me-2"></i>Change Password</h5></div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/webmail/account/password">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Current Password</label>
|
||||
<input type="password" class="form-control" name="current_password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New Password</label>
|
||||
<input type="password" class="form-control" name="new_password" required minlength="10">
|
||||
<div class="form-text">At least 10 characters, with a letter, a number, and a symbol.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Confirm New Password</label>
|
||||
<input type="password" class="form-control" name="new_password_confirm" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Update Password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>Two-Factor Authentication</h5></div>
|
||||
<div class="card-body">
|
||||
<h6>Authenticator App</h6>
|
||||
{{if .mailbox.TOTPEnabled}}
|
||||
<p class="text-success"><i class="bi bi-check-circle me-1"></i>Enabled</p>
|
||||
<form method="post" action="/webmail/account/totp/disable">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Disable authenticator app MFA?">Disable</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="text-muted">Not enabled.</p>
|
||||
<form method="post" action="/webmail/account/totp/setup">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm"><i class="bi bi-qr-code me-1"></i>Set Up</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
<hr>
|
||||
<h6>Passkeys</h6>
|
||||
{{if .passkeys}}
|
||||
<ul class="list-group list-group-flush mb-3">
|
||||
{{range .passkeys}}
|
||||
<li class="list-group-item list-group-item-dark d-flex justify-content-between align-items-center">
|
||||
{{.Name}}
|
||||
<form method="post" action="/webmail/account/passkey/{{.ID}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Remove passkey "{{.Name}}"?"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="text-muted">No passkeys registered.</p>
|
||||
{{end}}
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="passkey-add-btn"><i class="bi bi-fingerprint me-1"></i>Add a Passkey</button>
|
||||
<div id="passkey-error" class="alert alert-danger d-none mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-key me-2"></i>App Passwords</h5></div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>Use an app password (never your account password) to set up this mailbox in Thunderbird or any other mail client.
|
||||
</div>
|
||||
<form method="POST" action="/webmail/account/apppasswords/add" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Label</label>
|
||||
<input type="text" class="form-control" name="label" placeholder="e.g. Thunderbird laptop">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-success"><i class="bi bi-key me-2"></i>Generate</button>
|
||||
</div>
|
||||
</form>
|
||||
{{if .passwords}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead><tr><th>Label</th><th>Last Used</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .passwords}}
|
||||
<tr>
|
||||
<td>{{.Label}}</td>
|
||||
<td><small class="text-muted">{{if .LastUsedAt}}{{strftime "%Y-%m-%d %H:%M" .LastUsedAt}}{{else}}Never{{end}}</small></td>
|
||||
<td>
|
||||
<form method="post" action="/webmail/account/apppasswords/{{.ID}}/revoke" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Revoke app password "{{.Label}}"?"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-muted mb-0">No app passwords yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-question-circle me-2"></i>Confirm Action</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="confirmationModalBody">Are you sure you want to proceed?</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmationModalConfirm">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const TOAST_AUTOHIDE_MS = 5000;
|
||||
function armToastAutoDismiss(toastEl, bsToast) {
|
||||
let timer = null;
|
||||
const start = () => { timer = setTimeout(() => bsToast.hide(), TOAST_AUTOHIDE_MS); };
|
||||
const stop = () => { if (timer) { clearTimeout(timer); timer = null; } };
|
||||
toastEl.addEventListener('mouseenter', stop);
|
||||
toastEl.addEventListener('mouseleave', start);
|
||||
start();
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.toast').forEach(function(el) {
|
||||
const toast = new bootstrap.Toast(el);
|
||||
toast.show();
|
||||
armToastAutoDismiss(el, toast);
|
||||
});
|
||||
});
|
||||
|
||||
function showConfirmation(message) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.getElementById('confirmationModal');
|
||||
document.getElementById('confirmationModalBody').textContent = message;
|
||||
const confirmButton = document.getElementById('confirmationModalConfirm');
|
||||
const handleConfirm = () => { resolve(true); bootstrap.Modal.getInstance(modal).hide(); cleanup(); };
|
||||
const handleCancel = () => { resolve(false); cleanup(); };
|
||||
const cleanup = () => {
|
||||
confirmButton.removeEventListener('click', handleConfirm);
|
||||
modal.removeEventListener('hidden.bs.modal', handleCancel);
|
||||
};
|
||||
confirmButton.addEventListener('click', handleConfirm);
|
||||
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
|
||||
new bootstrap.Modal(modal).show();
|
||||
});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('[data-confirm]').forEach(function(button) {
|
||||
button.addEventListener('click', async function(e) {
|
||||
e.preventDefault();
|
||||
if (await showConfirmation(this.getAttribute('data-confirm'))) {
|
||||
const form = this.closest('form');
|
||||
if (form) form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function b64urlToBuf(s) {
|
||||
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (s.length % 4) s += '=';
|
||||
const bin = atob(s);
|
||||
const buf = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
||||
return buf.buffer;
|
||||
}
|
||||
function bufToB64url(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = '';
|
||||
bytes.forEach(b => bin += String.fromCharCode(b));
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
const passkeyAddBtn = document.getElementById('passkey-add-btn');
|
||||
if (passkeyAddBtn) {
|
||||
passkeyAddBtn.addEventListener('click', async function() {
|
||||
const errEl = document.getElementById('passkey-error');
|
||||
errEl.classList.add('d-none');
|
||||
try {
|
||||
const beginResp = await fetch('/webmail/account/passkey/begin', { method: 'POST' });
|
||||
if (!beginResp.ok) throw new Error((await beginResp.json()).error || 'Could not start passkey registration');
|
||||
const options = await beginResp.json();
|
||||
const publicKey = options.publicKey;
|
||||
publicKey.challenge = b64urlToBuf(publicKey.challenge);
|
||||
publicKey.user.id = b64urlToBuf(publicKey.user.id);
|
||||
if (publicKey.excludeCredentials) {
|
||||
publicKey.excludeCredentials = publicKey.excludeCredentials.map(c => ({ ...c, id: b64urlToBuf(c.id) }));
|
||||
}
|
||||
const cred = await navigator.credentials.create({ publicKey });
|
||||
const body = {
|
||||
id: cred.id,
|
||||
rawId: bufToB64url(cred.rawId),
|
||||
type: cred.type,
|
||||
response: {
|
||||
attestationObject: bufToB64url(cred.response.attestationObject),
|
||||
clientDataJSON: bufToB64url(cred.response.clientDataJSON),
|
||||
},
|
||||
};
|
||||
const finishResp = await fetch('/webmail/account/passkey/finish', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
if (!finishResp.ok) throw new Error((await finishResp.json()).error || 'Could not save passkey');
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message || 'Passkey registration failed';
|
||||
errEl.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,45 @@
|
||||
{{define "webmail_login.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign in - Webmail</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
|
||||
.login-card { max-width: 420px; margin: 0 auto; width: 100%; }
|
||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container login-card">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-inbox-fill" style="font-size: 2.5rem;"></i>
|
||||
<h4 class="mt-2">Webmail</h4>
|
||||
<p class="text-muted">Manage your mailbox account</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body p-4">
|
||||
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||
<form method="POST" action="/webmail/login">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" value="{{.email}}" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
<div class="form-text">This is your mailbox account password — not an app password.</div>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-box-arrow-in-right me-1"></i>Sign in</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,113 @@
|
||||
{{define "webmail_login_mfa.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Verify it's you - Webmail</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
|
||||
.login-card { max-width: 420px; margin: 0 auto; width: 100%; }
|
||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container login-card">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-shield-lock-fill" style="font-size: 2.5rem;"></i>
|
||||
<h4 class="mt-2">Verify it's you</h4>
|
||||
<p class="text-muted">One more step to finish signing in</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body p-4">
|
||||
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||
<div id="passkey-error" class="alert alert-danger d-none"></div>
|
||||
|
||||
{{if .has_passkeys}}
|
||||
<div class="d-grid mb-3">
|
||||
<button type="button" class="btn btn-outline-primary" id="passkey-btn">
|
||||
<i class="bi bi-fingerprint me-1"></i>Use a passkey / security key
|
||||
</button>
|
||||
</div>
|
||||
{{if .totp_enabled}}<div class="text-center text-muted mb-3">or</div>{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .totp_enabled}}
|
||||
<form method="POST" action="/webmail/login/mfa">
|
||||
<div class="mb-3">
|
||||
<label for="code" class="form-label">6-digit authenticator code</label>
|
||||
<input type="text" class="form-control" id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autofocus>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-shield-check me-1"></i>Verify</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function b64urlToBuf(s) {
|
||||
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (s.length % 4) s += '=';
|
||||
const bin = atob(s);
|
||||
const buf = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
||||
return buf.buffer;
|
||||
}
|
||||
function bufToB64url(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = '';
|
||||
bytes.forEach(b => bin += String.fromCharCode(b));
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
const passkeyBtn = document.getElementById('passkey-btn');
|
||||
if (passkeyBtn) {
|
||||
passkeyBtn.addEventListener('click', async function() {
|
||||
const errEl = document.getElementById('passkey-error');
|
||||
errEl.classList.add('d-none');
|
||||
try {
|
||||
const beginResp = await fetch('/webmail/login/passkey/begin');
|
||||
if (!beginResp.ok) throw new Error((await beginResp.json()).error || 'Could not start passkey login');
|
||||
const options = await beginResp.json();
|
||||
|
||||
const publicKey = options.publicKey;
|
||||
publicKey.challenge = b64urlToBuf(publicKey.challenge);
|
||||
if (publicKey.allowCredentials) {
|
||||
publicKey.allowCredentials = publicKey.allowCredentials.map(c => ({ ...c, id: b64urlToBuf(c.id) }));
|
||||
}
|
||||
|
||||
const assertion = await navigator.credentials.get({ publicKey });
|
||||
|
||||
const body = {
|
||||
id: assertion.id,
|
||||
rawId: bufToB64url(assertion.rawId),
|
||||
type: assertion.type,
|
||||
response: {
|
||||
authenticatorData: bufToB64url(assertion.response.authenticatorData),
|
||||
clientDataJSON: bufToB64url(assertion.response.clientDataJSON),
|
||||
signature: bufToB64url(assertion.response.signature),
|
||||
userHandle: assertion.response.userHandle ? bufToB64url(assertion.response.userHandle) : null,
|
||||
},
|
||||
};
|
||||
|
||||
const finishResp = await fetch('/webmail/login/passkey/finish', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
if (!finishResp.ok) throw new Error((await finishResp.json()).error || 'Passkey verification failed');
|
||||
|
||||
window.location.href = '/webmail/';
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message || 'Passkey login failed';
|
||||
errEl.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,45 @@
|
||||
{{define "webmail_totp_setup.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Set up authenticator app - Webmail</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-qr-code me-2"></i>Scan with your authenticator app</h5></div>
|
||||
<div class="card-body text-center">
|
||||
{{if .qr_data_uri}}
|
||||
<img src="{{.qr_data_uri}}" alt="TOTP QR code" class="img-fluid mb-3" style="max-width: 256px; background: white; padding: 8px; border-radius: 8px;">
|
||||
{{end}}
|
||||
<p class="text-muted">Can't scan? Enter this key manually:</p>
|
||||
<code class="d-block mb-4" style="word-break: break-all;">{{.secret}}</code>
|
||||
|
||||
<form method="POST" action="/webmail/account/totp/confirm" class="text-start">
|
||||
<div class="mb-3">
|
||||
<label for="code" class="form-label">Enter the 6-digit code from your app to confirm</label>
|
||||
<input type="text" class="form-control" id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autofocus>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/webmail/" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Confirm and enable</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,158 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// webmailDashboard is the mailbox owner's single self-service page: their own quota
|
||||
// usage, password change, TOTP MFA enable/disable, registered passkeys, and app
|
||||
// passwords for IMAP/SMTP clients — everything scoped to reusing the app-password
|
||||
// CRUD already built for the admin-managed mailbox pages (db.ListAppPasswordsForMailbox
|
||||
// etc.), just presented for self-service instead of admin management.
|
||||
func (a *App) webmailDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
passkeys, _ := a.DB.ListMailboxWebAuthnCredentials(mbox.ID)
|
||||
passwords, _ := a.DB.ListAppPasswordsForMailbox(mbox.ID)
|
||||
pctFull := 0.0
|
||||
if mbox.QuotaBytes > 0 {
|
||||
pctFull = float64(mbox.UsedBytes) / float64(mbox.QuotaBytes) * 100
|
||||
}
|
||||
// webmail_account.html is a standalone page (own <head>, no admin base.html/sidebar)
|
||||
// so render() doesn't auto-populate flashes for it the way admin pages get — pop
|
||||
// them explicitly here instead.
|
||||
a.render(w, r, "webmail_account.html", M{
|
||||
"mailbox": mbox, "passkeys": passkeys, "passwords": passwords, "pct_full": pctFull,
|
||||
"flashes": popFlashes(w, r),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) webmailChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
current := r.FormValue("current_password")
|
||||
newPassword := r.FormValue("new_password")
|
||||
confirm := r.FormValue("new_password_confirm")
|
||||
|
||||
if !db.CheckPassword(current, mbox.PasswordHash) {
|
||||
setFlash(w, "error", "Current password is incorrect")
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if !isStrongPassword(newPassword) {
|
||||
setFlash(w, "error", "New password must be at least 10 characters and include a letter, a number, and a symbol")
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if newPassword != confirm {
|
||||
setFlash(w, "error", "New passwords don't match")
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
hash, err := db.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Something went wrong")
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := a.DB.SetMailboxPasswordHash(mbox.ID, hash); err != nil {
|
||||
setFlash(w, "error", "Something went wrong")
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
setFlash(w, "success", "Password updated")
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) webmailTOTPSetupBegin(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
key, err := totp.Generate(totp.GenerateOpts{Issuer: "mailgoserver", AccountName: mbox.Email})
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Could not generate a TOTP secret")
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := a.DB.SetMailboxTOTPSecret(mbox.ID, key.Secret(), false); err != nil {
|
||||
setFlash(w, "error", "Could not save the TOTP secret")
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
img, err := key.Image(256, 256)
|
||||
qrDataURI := ""
|
||||
if err == nil {
|
||||
var buf bytes.Buffer
|
||||
if png.Encode(&buf, img) == nil {
|
||||
qrDataURI = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
}
|
||||
}
|
||||
a.render(w, r, "webmail_totp_setup.html", M{"secret": key.Secret(), "qr_data_uri": qrDataURI})
|
||||
}
|
||||
|
||||
func (a *App) webmailTOTPSetupConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
code := strings.TrimSpace(r.FormValue("code"))
|
||||
if mbox.TOTPSecret == "" || !totp.Validate(code, mbox.TOTPSecret) {
|
||||
setFlash(w, "error", "That code didn't match — try scanning the QR code again")
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := a.DB.SetMailboxTOTPSecret(mbox.ID, mbox.TOTPSecret, true); err != nil {
|
||||
setFlash(w, "error", "Something went wrong enabling MFA")
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
setFlash(w, "success", "Authenticator app MFA enabled")
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) webmailTOTPDisable(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
if err := a.DB.DisableMailboxTOTP(mbox.ID); err != nil {
|
||||
setFlash(w, "error", "Something went wrong")
|
||||
} else {
|
||||
setFlash(w, "success", "Authenticator app MFA disabled")
|
||||
}
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
}
|
||||
|
||||
// webmailAddAppPassword mirrors addAppPassword (mailbox_apppasswords.go) but for
|
||||
// self-service — same generation/storage, just reached from the mailbox's own portal
|
||||
// instead of an admin managing it on their behalf.
|
||||
func (a *App) webmailAddAppPassword(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
label := strings.TrimSpace(r.FormValue("label"))
|
||||
if label == "" {
|
||||
label = "App password"
|
||||
}
|
||||
minLen := a.Cfg.Section("Mailstore").Key("app_password_min_length").MustInt(25)
|
||||
secret := db.GenerateAppPassword(minLen)
|
||||
hash, err := db.HashPassword(secret)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error creating app password")
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if _, err := a.DB.CreateAppPassword(mbox.ID, label, hash); err != nil {
|
||||
setFlash(w, "error", "Error creating app password")
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
setFlash(w, "success", "App password created — copy it now, it will not be shown again: "+secret)
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) webmailRevokeAppPassword(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
pwID := int64(atoi(r.PathValue("pw_id")))
|
||||
if err := a.DB.RemoveAppPassword(pwID, mbox.ID); err != nil {
|
||||
setFlash(w, "error", "Error revoking app password")
|
||||
} else {
|
||||
setFlash(w, "success", "App password revoked")
|
||||
}
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// MailboxPrefix is the self-service webmail portal's URL prefix — a mailbox owner's
|
||||
// login/account area, entirely separate from the admin dashboard at Prefix.
|
||||
const MailboxPrefix = "/webmail"
|
||||
|
||||
const mailboxSessionCookieName = "mailgoserver_mailbox_session"
|
||||
|
||||
// mailboxCtxKey is its own type (not webui's ctxKey) so a mailbox session can never
|
||||
// collide with or be confused for an admin session in request context — the two
|
||||
// actor types are deliberately kept fully separate, per the parallel-schema design.
|
||||
type mailboxCtxKey int
|
||||
|
||||
const ctxMailboxKey mailboxCtxKey = iota
|
||||
|
||||
func setMailboxSessionCookie(w http.ResponseWriter, token string, secure bool) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: mailboxSessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func clearMailboxSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{Name: mailboxSessionCookieName, Value: "", Path: "/", MaxAge: -1})
|
||||
}
|
||||
|
||||
// currentMailboxSession loads the session + mailbox for the request's cookie, if any
|
||||
// and valid. A nil session/mailbox (no error) means "not logged in".
|
||||
func (a *App) currentMailboxSession(r *http.Request) (*db.MailboxSession, *db.Mailbox, error) {
|
||||
c, err := r.Cookie(mailboxSessionCookieName)
|
||||
if err != nil || c.Value == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
sess, err := a.DB.GetMailboxSession(c.Value)
|
||||
if err != nil || sess == nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if time.Now().After(sess.ExpiresAt) {
|
||||
_ = a.DB.DeleteMailboxSession(sess.Token)
|
||||
return nil, nil, nil
|
||||
}
|
||||
mbox, err := a.DB.GetMailboxByID(sess.MailboxID)
|
||||
if err != nil || mbox == nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return sess, mbox, nil
|
||||
}
|
||||
|
||||
func mailboxFromContext(r *http.Request) *db.Mailbox {
|
||||
m, _ := r.Context().Value(ctxMailboxKey).(*db.Mailbox)
|
||||
return m
|
||||
}
|
||||
|
||||
// requireMailboxAuth gates every webmail route behind a valid, fully-authenticated
|
||||
// mailbox session: logged in, and second factor satisfied if one is enabled.
|
||||
func (a *App) requireMailboxAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sess, mbox, err := a.currentMailboxSession(r)
|
||||
if err != nil {
|
||||
a.Logger.Error("mailbox session lookup: %v", err)
|
||||
}
|
||||
if sess == nil || mbox == nil {
|
||||
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
needsMFA := mbox.TOTPEnabled
|
||||
if !needsMFA {
|
||||
if n, _ := a.DB.CountMailboxWebAuthnCredentials(mbox.ID); n > 0 {
|
||||
needsMFA = true
|
||||
}
|
||||
}
|
||||
if needsMFA && !sess.MFAVerified {
|
||||
http.Redirect(w, r, MailboxPrefix+"/login/mfa", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), ctxMailboxKey, mbox)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// mailboxPendingMFACookieName mirrors pendingMFACookieName for the mailbox portal —
|
||||
// kept fully separate so an unfinished mailbox login can never be confused with (or
|
||||
// promoted into) an admin session, and vice versa.
|
||||
const mailboxPendingMFACookieName = "mailgoserver_mailbox_pending_mfa"
|
||||
|
||||
func setMailboxPendingMFACookie(w http.ResponseWriter, mailboxID string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: mailboxPendingMFACookieName, Value: mailboxID, Path: "/", HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode, MaxAge: 10 * 60,
|
||||
})
|
||||
}
|
||||
func clearMailboxPendingMFACookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{Name: mailboxPendingMFACookieName, Value: "", Path: "/", MaxAge: -1})
|
||||
}
|
||||
func pendingMailboxMFAID(r *http.Request) int64 {
|
||||
c, err := r.Cookie(mailboxPendingMFACookieName)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(atoi(c.Value))
|
||||
}
|
||||
|
||||
func (a *App) webmailLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
if sess, mbox, _ := a.currentMailboxSession(r); sess != nil && mbox != nil {
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
a.render(w, r, "webmail_login.html", M{})
|
||||
}
|
||||
|
||||
// webmailLoginSubmit checks email+password against the mailbox's own portal
|
||||
// password (never an app password — that's for IMAP/SMTP clients only).
|
||||
func (a *App) webmailLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
password := r.FormValue("password")
|
||||
|
||||
fail := func(msg string) {
|
||||
a.render(w, r, "webmail_login.html", M{"error": msg, "email": email})
|
||||
}
|
||||
|
||||
mbox, err := a.DB.GetMailboxByEmail(email)
|
||||
if err != nil {
|
||||
a.Logger.Error("webmail login lookup: %v", err)
|
||||
fail("Something went wrong. Try again.")
|
||||
return
|
||||
}
|
||||
if mbox == nil || !db.CheckPassword(password, mbox.PasswordHash) {
|
||||
fail("Incorrect email or password.")
|
||||
return
|
||||
}
|
||||
|
||||
needsMFA := mbox.TOTPEnabled
|
||||
if !needsMFA {
|
||||
if n, _ := a.DB.CountMailboxWebAuthnCredentials(mbox.ID); n > 0 {
|
||||
needsMFA = true
|
||||
}
|
||||
}
|
||||
|
||||
if !needsMFA {
|
||||
token, err := a.DB.CreateMailboxSession(mbox.ID, true, sessionTTL)
|
||||
if err != nil {
|
||||
fail("Something went wrong. Try again.")
|
||||
return
|
||||
}
|
||||
setMailboxSessionCookie(w, token, r.TLS != nil)
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
setMailboxPendingMFACookie(w, strconv.FormatInt(mbox.ID, 10))
|
||||
http.Redirect(w, r, MailboxPrefix+"/login/mfa", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) webmailMFAForm(w http.ResponseWriter, r *http.Request) {
|
||||
mailboxID := pendingMailboxMFAID(r)
|
||||
if mailboxID == 0 {
|
||||
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
mbox, _ := a.DB.GetMailboxByID(mailboxID)
|
||||
if mbox == nil {
|
||||
clearMailboxPendingMFACookie(w)
|
||||
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
hasPasskeys, _ := a.DB.CountMailboxWebAuthnCredentials(mailboxID)
|
||||
a.render(w, r, "webmail_login_mfa.html", M{"totp_enabled": mbox.TOTPEnabled, "has_passkeys": hasPasskeys > 0})
|
||||
}
|
||||
|
||||
func (a *App) webmailMFASubmit(w http.ResponseWriter, r *http.Request) {
|
||||
mailboxID := pendingMailboxMFAID(r)
|
||||
if mailboxID == 0 {
|
||||
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
mbox, err := a.DB.GetMailboxByID(mailboxID)
|
||||
if err != nil || mbox == nil {
|
||||
clearMailboxPendingMFACookie(w)
|
||||
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(r.FormValue("code"))
|
||||
if !mbox.TOTPEnabled || !totp.Validate(code, mbox.TOTPSecret) {
|
||||
hasPasskeys, _ := a.DB.CountMailboxWebAuthnCredentials(mailboxID)
|
||||
a.render(w, r, "webmail_login_mfa.html", M{"totp_enabled": mbox.TOTPEnabled, "has_passkeys": hasPasskeys > 0, "error": "Invalid code."})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := a.DB.CreateMailboxSession(mbox.ID, true, sessionTTL)
|
||||
if err != nil {
|
||||
a.Logger.Error("create mailbox session: %v", err)
|
||||
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
clearMailboxPendingMFACookie(w)
|
||||
setMailboxSessionCookie(w, token, r.TLS != nil)
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) webmailLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(mailboxSessionCookieName); err == nil {
|
||||
_ = a.DB.DeleteMailboxSession(c.Value)
|
||||
}
|
||||
clearMailboxSessionCookie(w)
|
||||
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/mailstore"
|
||||
)
|
||||
|
||||
// createTestMailboxWithPassword mirrors createMailboxFor but with a known plaintext
|
||||
// portal password, for webmail login tests.
|
||||
func createTestMailboxWithPassword(t *testing.T, app *App, email string, domainID int64, password string) int64 {
|
||||
t.Helper()
|
||||
hash, err := db.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dek := mailstore.GenerateDEK()
|
||||
wrapped, nonce, err := app.Mailstore.WrapDEK(dek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := app.DB.CreateMailbox(email, hash, domainID, 5*1024*1024*1024, wrapped, nonce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestWebmailLoginSucceedsAndReachesDashboard(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domains, _ := app.DB.ListDomains()
|
||||
mailboxID := createTestMailboxWithPassword(t, app, "portaluser@example.com", domains[0].ID, "portal-password-123!")
|
||||
_ = mailboxID
|
||||
|
||||
form := url.Values{"email": {"portaluser@example.com"}, "password": {"portal-password-123!"}}
|
||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("expected redirect after login, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == mailboxSessionCookieName {
|
||||
sessionCookie = c
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("expected a mailbox session cookie to be set")
|
||||
}
|
||||
|
||||
req2 := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/", nil)
|
||||
req2.AddCookie(sessionCookie)
|
||||
rec2 := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("expected dashboard to render, got %d: %s", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec2.Body.String(), "portaluser@example.com") {
|
||||
t.Fatal("expected the dashboard to show the mailbox's own email")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebmailLoginRejectsAppPassword(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domains, _ := app.DB.ListDomains()
|
||||
mailboxID := createTestMailboxWithPassword(t, app, "portaluser2@example.com", domains[0].ID, "portal-password-123!")
|
||||
|
||||
appPwHash, err := db.HashPassword("an-app-password-not-the-portal-one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := app.DB.CreateAppPassword(mailboxID, "test", appPwHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := url.Values{"email": {"portaluser2@example.com"}, "password": {"an-app-password-not-the-portal-one"}}
|
||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Incorrect") {
|
||||
t.Fatalf("expected login to reject an app password (portal login only accepts the portal password), got status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebmailUnauthenticatedRedirectsToLogin(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("expected redirect, got %d", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); !strings.HasPrefix(loc, MailboxPrefix+"/login") {
|
||||
t.Fatalf("expected redirect to webmail login, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebmailAndAdminSessionsAreIsolated confirms the two session systems really are
|
||||
// separate: an admin session cookie doesn't grant webmail access and vice versa.
|
||||
func TestWebmailAndAdminSessionsAreIsolated(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domains, _ := app.DB.ListDomains()
|
||||
createTestMailboxWithPassword(t, app, "portaluser3@example.com", domains[0].ID, "portal-password-123!")
|
||||
|
||||
adminCookie := loginSession(t, app)
|
||||
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: mailboxSessionCookieName, Value: adminCookie.Value})
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("expected an admin session token to NOT grant webmail access, got status %d", rec.Code)
|
||||
}
|
||||
|
||||
form := url.Values{"email": {"portaluser3@example.com"}, "password": {"portal-password-123!"}}
|
||||
loginReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
|
||||
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
loginRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(loginRec, loginReq)
|
||||
var mailboxCookie *http.Cookie
|
||||
for _, c := range loginRec.Result().Cookies() {
|
||||
if c.Name == mailboxSessionCookieName {
|
||||
mailboxCookie = c
|
||||
}
|
||||
}
|
||||
if mailboxCookie == nil {
|
||||
t.Fatal("expected a mailbox session cookie")
|
||||
}
|
||||
req2 := httptest.NewRequest(http.MethodGet, Prefix+"/domains", nil)
|
||||
req2.AddCookie(&http.Cookie{Name: sessionCookieName, Value: mailboxCookie.Value})
|
||||
rec2 := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusFound {
|
||||
t.Fatalf("expected a mailbox session token to NOT grant admin access, got status %d", rec2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebmailChangePassword(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domains, _ := app.DB.ListDomains()
|
||||
mailboxID := createTestMailboxWithPassword(t, app, "portaluser4@example.com", domains[0].ID, "old-password-123!")
|
||||
|
||||
cookie := webmailLoginSession(t, app, mailboxID)
|
||||
form := url.Values{
|
||||
"current_password": {"old-password-123!"},
|
||||
"new_password": {"new-password-456!"},
|
||||
"new_password_confirm": {"new-password-456!"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/password", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("expected redirect after password change, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
mbox, err := app.DB.GetMailboxByID(mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.CheckPassword("new-password-456!", mbox.PasswordHash) {
|
||||
t.Fatal("expected the new password to have been saved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebmailAppPasswordSelfService(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domains, _ := app.DB.ListDomains()
|
||||
mailboxID := createTestMailboxWithPassword(t, app, "portaluser5@example.com", domains[0].ID, "password-123!")
|
||||
cookie := webmailLoginSession(t, app, mailboxID)
|
||||
|
||||
form := url.Values{"label": {"my laptop"}}
|
||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/apppasswords/add", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("expected redirect after creating app password, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
passwords, err := app.DB.ListAppPasswordsForMailbox(mailboxID)
|
||||
if err != nil || len(passwords) != 1 {
|
||||
t.Fatalf("expected exactly 1 app password, got %d (err=%v)", len(passwords), err)
|
||||
}
|
||||
|
||||
revokeReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/apppasswords/"+itoa(passwords[0].ID)+"/revoke", nil)
|
||||
revokeReq.AddCookie(cookie)
|
||||
revokeRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(revokeRec, revokeReq)
|
||||
if revokeRec.Code != http.StatusFound {
|
||||
t.Fatalf("expected redirect after revoking, got %d", revokeRec.Code)
|
||||
}
|
||||
remaining, err := app.DB.ListAppPasswordsForMailbox(mailboxID)
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("expected 0 app passwords after revoke, got %d (err=%v)", len(remaining), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebmailMFAGateRequiresCodeAfterTOTPEnabled(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domains, _ := app.DB.ListDomains()
|
||||
mailboxID := createTestMailboxWithPassword(t, app, "portaluser6@example.com", domains[0].ID, "password-123!")
|
||||
if err := app.DB.SetMailboxTOTPSecret(mailboxID, "JBSWY3DPEHPK3PXP", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := url.Values{"email": {"portaluser6@example.com"}, "password": {"password-123!"}}
|
||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("expected redirect to MFA step, got %d", rec.Code)
|
||||
}
|
||||
loc := rec.Header().Get("Location")
|
||||
if loc != MailboxPrefix+"/login/mfa" {
|
||||
t.Fatalf("expected redirect to %s, got %q", MailboxPrefix+"/login/mfa", loc)
|
||||
}
|
||||
|
||||
// No fully-verified session cookie should exist yet — only the pending-MFA cookie.
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == mailboxSessionCookieName {
|
||||
t.Fatal("a fully-verified session must not be issued before MFA is satisfied")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// webmailLoginSession creates a fully-verified (no MFA enrolled) mailbox session
|
||||
// directly via the DB, mirroring loginSession's admin equivalent.
|
||||
func webmailLoginSession(t *testing.T, app *App, mailboxID int64) *http.Cookie {
|
||||
t.Helper()
|
||||
token, err := app.DB.CreateMailboxSession(mailboxID, true, sessionTTL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &http.Cookie{Name: mailboxSessionCookieName, Value: token}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// mailboxWebauthnSessionCookie mirrors webauthnSessionCookie but kept separate so an
|
||||
// in-progress admin passkey ceremony and an in-progress mailbox one (e.g. different
|
||||
// browser tabs) can never collide.
|
||||
const mailboxWebauthnSessionCookie = "mailgoserver_mailbox_webauthn_session"
|
||||
|
||||
// mailboxWebauthnUser adapts a Mailbox + its stored credentials to webauthn.User,
|
||||
// mirroring webauthnUser.
|
||||
type mailboxWebauthnUser struct {
|
||||
mailbox *db.Mailbox
|
||||
creds []db.MailboxWebAuthnCredential
|
||||
}
|
||||
|
||||
func (u *mailboxWebauthnUser) WebAuthnID() []byte {
|
||||
sum := sha256.Sum256([]byte("mailbox-" + strconv.FormatInt(u.mailbox.ID, 10)))
|
||||
return sum[:]
|
||||
}
|
||||
func (u *mailboxWebauthnUser) WebAuthnName() string { return u.mailbox.Email }
|
||||
func (u *mailboxWebauthnUser) WebAuthnDisplayName() string { return u.mailbox.Email }
|
||||
func (u *mailboxWebauthnUser) WebAuthnCredentials() []webauthn.Credential {
|
||||
out := make([]webauthn.Credential, 0, len(u.creds))
|
||||
for _, c := range u.creds {
|
||||
var cred webauthn.Credential
|
||||
if err := json.Unmarshal([]byte(c.CredentialData), &cred); err == nil {
|
||||
out = append(out, cred)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) mailboxWebauthnUserFor(mbox *db.Mailbox) (*mailboxWebauthnUser, error) {
|
||||
creds, err := a.DB.ListMailboxWebAuthnCredentials(mbox.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mailboxWebauthnUser{mailbox: mbox, creds: creds}, nil
|
||||
}
|
||||
|
||||
func saveMailboxWebauthnSession(w http.ResponseWriter, s *webauthn.SessionData) error {
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: mailboxWebauthnSessionCookie, Value: base64.URLEncoding.EncodeToString(b),
|
||||
Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 5 * 60,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadMailboxWebauthnSession(r *http.Request) (*webauthn.SessionData, error) {
|
||||
c, err := r.Cookie(mailboxWebauthnSessionCookie)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := base64.URLEncoding.DecodeString(c.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s webauthn.SessionData
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func clearMailboxWebauthnSession(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{Name: mailboxWebauthnSessionCookie, Value: "", Path: "/", MaxAge: -1})
|
||||
}
|
||||
|
||||
func (a *App) webmailPasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
wa, err := a.buildWebAuthn()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, M{"error": "WebAuthn is not configured correctly: " + err.Error()})
|
||||
return
|
||||
}
|
||||
wu, err := a.mailboxWebauthnUserFor(mbox)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not load account"})
|
||||
return
|
||||
}
|
||||
creation, session, err := wa.BeginRegistration(wu)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := saveMailboxWebauthnSession(w, session); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start registration"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, creation)
|
||||
}
|
||||
|
||||
func (a *App) webmailPasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
wa, err := a.buildWebAuthn()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
session, err := loadMailboxWebauthnSession(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, M{"error": "Registration session expired — try again"})
|
||||
return
|
||||
}
|
||||
wu, err := a.mailboxWebauthnUserFor(mbox)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not load account"})
|
||||
return
|
||||
}
|
||||
cred, err := wa.FinishRegistration(wu, *session, r)
|
||||
clearMailboxWebauthnSession(w)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, M{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(cred)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not save passkey"})
|
||||
return
|
||||
}
|
||||
name := r.URL.Query().Get("name")
|
||||
if name == "" {
|
||||
name = "Passkey"
|
||||
}
|
||||
if err := a.DB.CreateMailboxWebAuthnCredential(mbox.ID, name, base64.URLEncoding.EncodeToString(cred.ID), string(data)); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not save passkey"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, M{"success": true})
|
||||
}
|
||||
|
||||
func (a *App) webmailPasskeyRemove(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
if err := a.DB.DeleteMailboxWebAuthnCredential(pathID(r), mbox.ID); err != nil {
|
||||
setFlash(w, "error", "Could not remove passkey")
|
||||
} else {
|
||||
setFlash(w, "success", "Passkey removed")
|
||||
}
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
}
|
||||
|
||||
// webmailPasskeyLoginBegin starts the passkey ceremony for the mailbox that's already
|
||||
// passed its password and is now at the MFA step.
|
||||
func (a *App) webmailPasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||
mailboxID := pendingMailboxMFAID(r)
|
||||
if mailboxID == 0 {
|
||||
writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"})
|
||||
return
|
||||
}
|
||||
mbox, err := a.DB.GetMailboxByID(mailboxID)
|
||||
if err != nil || mbox == nil {
|
||||
writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"})
|
||||
return
|
||||
}
|
||||
wa, err := a.buildWebAuthn()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
wu, err := a.mailboxWebauthnUserFor(mbox)
|
||||
if err != nil || len(wu.creds) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, M{"error": "No passkeys registered"})
|
||||
return
|
||||
}
|
||||
assertion, session, err := wa.BeginLogin(wu)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := saveMailboxWebauthnSession(w, session); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start login"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, assertion)
|
||||
}
|
||||
|
||||
func (a *App) webmailPasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
mailboxID := pendingMailboxMFAID(r)
|
||||
if mailboxID == 0 {
|
||||
writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"})
|
||||
return
|
||||
}
|
||||
mbox, err := a.DB.GetMailboxByID(mailboxID)
|
||||
if err != nil || mbox == nil {
|
||||
writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"})
|
||||
return
|
||||
}
|
||||
wa, err := a.buildWebAuthn()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
session, err := loadMailboxWebauthnSession(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, M{"error": "Login session expired — try again"})
|
||||
return
|
||||
}
|
||||
wu, err := a.mailboxWebauthnUserFor(mbox)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not load account"})
|
||||
return
|
||||
}
|
||||
if _, err := wa.FinishLogin(wu, *session, r); err != nil {
|
||||
clearMailboxWebauthnSession(w)
|
||||
writeJSON(w, http.StatusUnauthorized, M{"error": "Passkey verification failed"})
|
||||
return
|
||||
}
|
||||
clearMailboxWebauthnSession(w)
|
||||
|
||||
token, err := a.DB.CreateMailboxSession(mbox.ID, true, sessionTTL)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start session"})
|
||||
return
|
||||
}
|
||||
clearMailboxPendingMFACookie(w)
|
||||
setMailboxSessionCookie(w, token, r.TLS != nil)
|
||||
writeJSON(w, http.StatusOK, M{"success": true})
|
||||
}
|
||||
+55
-2
@@ -9,8 +9,10 @@ import (
|
||||
"time"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
"mailgoserver/internal/acmecert"
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/dkim"
|
||||
"mailgoserver/internal/mailstore"
|
||||
"mailgoserver/internal/toolbox"
|
||||
)
|
||||
|
||||
@@ -21,6 +23,8 @@ const Prefix = "/pymta-manager"
|
||||
type App struct {
|
||||
DB *db.DB
|
||||
DKIM *dkim.Manager
|
||||
Mailstore *mailstore.Store
|
||||
ACME *acmecert.Manager
|
||||
Cfg *ini.File
|
||||
ConfigPath string
|
||||
Logger *toolbox.Logger
|
||||
@@ -31,8 +35,8 @@ type App struct {
|
||||
|
||||
// New builds the web UI. Templates and static assets come from the embedded
|
||||
// filesystem (embed.go), not disk, so no directory paths are needed for them.
|
||||
func New(database *db.DB, dkimMgr *dkim.Manager, cfg *ini.File, configPath string, logger *toolbox.Logger, smtpUp func() bool) (*App, error) {
|
||||
a := &App{DB: database, DKIM: dkimMgr, Cfg: cfg, ConfigPath: configPath, Logger: logger, SMTPUp: smtpUp}
|
||||
func New(database *db.DB, dkimMgr *dkim.Manager, mstore *mailstore.Store, acmeMgr *acmecert.Manager, cfg *ini.File, configPath string, logger *toolbox.Logger, smtpUp func() bool) (*App, error) {
|
||||
a := &App{DB: database, DKIM: dkimMgr, Mailstore: mstore, ACME: acmeMgr, Cfg: cfg, ConfigPath: configPath, Logger: logger, SMTPUp: smtpUp}
|
||||
if err := a.loadTemplates(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -87,6 +91,29 @@ func (a *App) Mux() *http.ServeMux {
|
||||
outer.HandleFunc("POST "+Prefix+"/login/passkey/finish", a.passkeyLoginFinish)
|
||||
outer.HandleFunc("POST "+Prefix+"/logout", a.logout)
|
||||
|
||||
// Self-service webmail portal — entirely separate prefix, session cookie, and
|
||||
// context keys from the admin dashboard above (see webmail_auth.go).
|
||||
outer.HandleFunc("GET "+MailboxPrefix+"/login", a.webmailLoginForm)
|
||||
outer.HandleFunc("POST "+MailboxPrefix+"/login", a.webmailLoginSubmit)
|
||||
outer.HandleFunc("GET "+MailboxPrefix+"/login/mfa", a.webmailMFAForm)
|
||||
outer.HandleFunc("POST "+MailboxPrefix+"/login/mfa", a.webmailMFASubmit)
|
||||
outer.HandleFunc("GET "+MailboxPrefix+"/login/passkey/begin", a.webmailPasskeyLoginBegin)
|
||||
outer.HandleFunc("POST "+MailboxPrefix+"/login/passkey/finish", a.webmailPasskeyLoginFinish)
|
||||
outer.HandleFunc("POST "+MailboxPrefix+"/logout", a.webmailLogout)
|
||||
|
||||
webmailMux := http.NewServeMux()
|
||||
webmailMux.HandleFunc("GET "+MailboxPrefix+"/", a.webmailDashboard)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/password", a.webmailChangePassword)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/setup", a.webmailTOTPSetupBegin)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/confirm", a.webmailTOTPSetupConfirm)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/disable", a.webmailTOTPDisable)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/passkey/begin", a.webmailPasskeyRegisterBegin)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/passkey/finish", a.webmailPasskeyRegisterFinish)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/passkey/{id}/remove", a.webmailPasskeyRemove)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/apppasswords/add", a.webmailAddAppPassword)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/apppasswords/{pw_id}/revoke", a.webmailRevokeAppPassword)
|
||||
outer.Handle(MailboxPrefix+"/", a.requireMailboxAuth(webmailMux))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET "+Prefix+"/", a.dashboard)
|
||||
@@ -127,6 +154,27 @@ func (a *App) Mux() *http.ServeMux {
|
||||
mux.HandleFunc("GET "+Prefix+"/senders/{id}/edit", a.editSenderForm)
|
||||
mux.HandleFunc("POST "+Prefix+"/senders/{id}/edit", a.editSender)
|
||||
|
||||
mux.HandleFunc("GET "+Prefix+"/mailboxes", a.mailboxesList)
|
||||
mux.HandleFunc("GET "+Prefix+"/mailboxes/add", a.addMailboxForm)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/add", a.addMailbox)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/delete", a.disableMailbox)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/enable", a.enableMailbox)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/remove", a.removeMailbox)
|
||||
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/edit", a.editMailboxForm)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/edit", a.editMailbox)
|
||||
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/apppasswords", a.appPasswordsList)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/apppasswords/add", a.addAppPassword)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/apppasswords/{pw_id}/revoke", a.revokeAppPassword)
|
||||
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/aliases", a.aliasesList)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/aliases/add", a.addAlias)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/aliases/{alias_id}/remove", a.removeAlias)
|
||||
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/lists", a.listsPage)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/lists/add", a.addAllowBlockEntry)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/lists/{entry_id}/remove", a.removeAllowBlockEntry)
|
||||
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/rules", a.rulesList)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/rules/add", a.addRule)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/rules/{rule_id}/remove", a.removeRule)
|
||||
|
||||
mux.HandleFunc("GET "+Prefix+"/ips", a.ipsList)
|
||||
mux.HandleFunc("GET "+Prefix+"/ips/add", a.addIPForm)
|
||||
mux.HandleFunc("POST "+Prefix+"/ips/add", a.addIP)
|
||||
@@ -146,6 +194,11 @@ func (a *App) Mux() *http.ServeMux {
|
||||
mux.HandleFunc("POST "+Prefix+"/dkim/check_dns", a.checkDKIMDNS)
|
||||
mux.HandleFunc("POST "+Prefix+"/dkim/check_spf", a.checkSPFDNS)
|
||||
|
||||
mux.HandleFunc("GET "+Prefix+"/letsencrypt", a.letsEncryptPage)
|
||||
mux.HandleFunc("POST "+Prefix+"/letsencrypt/save", a.letsEncryptSave)
|
||||
mux.HandleFunc("POST "+Prefix+"/letsencrypt/obtain", a.letsEncryptObtainNow)
|
||||
mux.HandleFunc("POST "+Prefix+"/api/letsencrypt/upload_gcloud_key", a.uploadGCloudServiceAccount)
|
||||
|
||||
mux.HandleFunc("GET "+Prefix+"/logs", a.logs)
|
||||
|
||||
mux.HandleFunc("GET "+Prefix+"/settings", a.settingsPage)
|
||||
|
||||
@@ -11,8 +11,10 @@ import (
|
||||
"time"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
"mailgoserver/internal/acmecert"
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/dkim"
|
||||
"mailgoserver/internal/mailstore"
|
||||
"mailgoserver/internal/toolbox"
|
||||
)
|
||||
|
||||
@@ -72,6 +74,17 @@ func newTestApp(t *testing.T) *App {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mstore := mailstore.New(database, mailstore.GenerateDEK(), filepath.Join(dir, "mailstore"))
|
||||
mdek := mailstore.GenerateDEK()
|
||||
mwrapped, mnonce, err := mstore.WrapDEK(mdek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mboxID, err := database.CreateMailbox("inbox@example.com", hash, domainID, 5*1024*1024*1024, mwrapped, mnonce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := ini.Empty()
|
||||
serverSec, _ := cfg.NewSection("Server")
|
||||
serverSec.NewKey("smtp_port", "4025")
|
||||
@@ -96,17 +109,22 @@ func newTestApp(t *testing.T) *App {
|
||||
dkimSec.NewKey("spf_server_ip", "192.168.1.1")
|
||||
attSec, _ := cfg.NewSection("Attachments")
|
||||
attSec.NewKey("attachments_path", filepath.Join(dir, "attachments"))
|
||||
mailstoreSec, _ := cfg.NewSection("Mailstore")
|
||||
mailstoreSec.NewKey("app_password_min_length", "25")
|
||||
mailstoreSec.NewKey("spam_reject_score", "5")
|
||||
|
||||
configPath := filepath.Join(dir, "settings.ini")
|
||||
cfg.SaveTo(configPath)
|
||||
|
||||
app, err := New(database, dkimMgr, cfg, configPath, toolbox.GetLogger("test"), func() bool { return true })
|
||||
acmeMgr := acmecert.New(cfg, filepath.Join(dir, "server.crt"), filepath.Join(dir, "server.key"), filepath.Join(dir, "acme"), nil, toolbox.GetLogger("test"))
|
||||
app, err := New(database, dkimMgr, mstore, acmeMgr, cfg, configPath, toolbox.GetLogger("test"), func() bool { return true })
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
_ = senderID
|
||||
_ = key
|
||||
_ = mboxID
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -136,11 +154,12 @@ func TestAllPagesRender(t *testing.T) {
|
||||
|
||||
domains, _ := app.DB.ListDomains()
|
||||
senders, _ := app.DB.ListSenders()
|
||||
mailboxes, _ := app.DB.ListMailboxes()
|
||||
ips, _ := app.DB.ListWhitelistedIPs()
|
||||
keys, _ := app.DB.ListActiveDKIMKeysWithDomain()
|
||||
logs, _ := app.DB.ListEmailLogsPage(0, 10)
|
||||
if len(domains) == 0 || len(senders) == 0 || len(ips) == 0 || len(keys) == 0 || len(logs) == 0 {
|
||||
t.Fatalf("seed data missing: domains=%d senders=%d ips=%d keys=%d logs=%d", len(domains), len(senders), len(ips), len(keys), len(logs))
|
||||
if len(domains) == 0 || len(senders) == 0 || len(mailboxes) == 0 || len(ips) == 0 || len(keys) == 0 || len(logs) == 0 {
|
||||
t.Fatalf("seed data missing: domains=%d senders=%d mailboxes=%d ips=%d keys=%d logs=%d", len(domains), len(senders), len(mailboxes), len(ips), len(keys), len(logs))
|
||||
}
|
||||
|
||||
pagesToCheck := []string{
|
||||
@@ -148,10 +167,13 @@ func TestAllPagesRender(t *testing.T) {
|
||||
"/account",
|
||||
"/domains", "/domains/add", "/domains/" + itoa(domains[0].ID) + "/edit",
|
||||
"/senders", "/senders/add", "/senders/" + itoa(senders[0].ID) + "/edit",
|
||||
"/mailboxes", "/mailboxes/add", "/mailboxes/" + itoa(mailboxes[0].ID) + "/edit", "/mailboxes/" + itoa(mailboxes[0].ID) + "/apppasswords", "/mailboxes/" + itoa(mailboxes[0].ID) + "/aliases",
|
||||
"/mailboxes/" + itoa(mailboxes[0].ID) + "/lists", "/mailboxes/" + itoa(mailboxes[0].ID) + "/rules",
|
||||
"/ips", "/ips/add", "/ips/" + itoa(ips[0].ID) + "/edit",
|
||||
"/dkim", "/dkim/" + itoa(keys[0].ID) + "/edit",
|
||||
"/logs", "/logs?type=emails", "/logs?type=auth",
|
||||
"/settings",
|
||||
"/letsencrypt",
|
||||
"/msg/content/" + itoa(logs[0].ID),
|
||||
"/admins", "/admins/add",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user