added IMAP, LetsEncrypt, update layout

This commit is contained in:
2026-08-12 21:14:19 +01:00
parent 6e103959b0
commit 70fa1a5f2c
222 changed files with 42947 additions and 14038 deletions
+146
View File
@@ -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 := &registration.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")
}
}
+213
View File
@@ -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
}
+49
View File
@@ -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)
}
}
+79
View File
@@ -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, &reg); err == nil {
user.Registration = &reg
}
}
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)
}
+61
View File
@@ -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
+76
View File
@@ -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
}
+117
View File
@@ -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
}
+72
View File
@@ -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()
}
+141
View File
@@ -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()
}
+36
View File
@@ -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
}
+90
View File
@@ -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
}
+179
View File
@@ -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()
}
+108
View File
@@ -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
View File
@@ -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)
+17
View File
@@ -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)
+25
View File
@@ -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}
}
+264
View File
@@ -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)
}
}
+33
View File
@@ -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,
})
}
+563
View File
@@ -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, &not) {
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, " ")
}
+122
View File
@@ -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
}
+160
View File
@@ -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)
}
}
+19
View File
@@ -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
}
+17
View File
@@ -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)
}
+35
View File
@@ -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
}
+54
View File
@@ -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
}
+58
View File
@@ -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
}
+52
View File
@@ -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)
}
})
}
}
+130
View File
@@ -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
}
+109
View File
@@ -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)
}
+29 -8
View File
@@ -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
+155
View File
@@ -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)
}
}
+203
View File
@@ -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
View File
@@ -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 {
+48
View File
@@ -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")
}
}
+51
View File
@@ -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
View File
@@ -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,
})
}
+53
View File
@@ -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")
}
}
+115
View File
@@ -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})
}
+77
View File
@@ -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")
}
}
+79
View File
@@ -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)
}
+70
View File
@@ -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")
}
+63
View File
@@ -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)
}
+166
View File
@@ -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)
}
}
+69
View File
@@ -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)
}
+231
View File
@@ -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)
}
+58
View File
@@ -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
}
+31 -3
View File
@@ -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)
+44
View File
@@ -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}}
+64 -1
View File
@@ -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.
+21
View File
@@ -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}}
+11 -11
View File
@@ -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">
+173
View File
@@ -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 &amp; 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 &quot;{{.Label}}&quot;? 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}}
+104
View File
@@ -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}}
+70
View File
@@ -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}}
+26 -6
View File
@@ -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 &quot;{{.Name}}&quot;?"><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 &quot;{{.Label}}&quot;?"><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}}
+158
View File
@@ -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)
}
+94
View File
@@ -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))
})
}
+138
View File
@@ -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)
}
+254
View File
@@ -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}
}
+232
View File
@@ -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
View File
@@ -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)
+25 -3
View File
@@ -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",
}