first commit

This commit is contained in:
2026-08-12 12:56:22 +01:00
commit ff96be2708
153 changed files with 27779 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
// Package config loads and generates settings.ini, mirroring email_server/settings_loader.py.
package config
import (
"os"
"path/filepath"
"gopkg.in/ini.v1"
)
// defaultKV is one key/value pair with the comment line Python renders above it.
type defaultKV struct {
Key string
Value string
Comment string
}
// defaults mirrors settings_loader.py's DEFAULTS table section-by-section, in order.
// The [Attachments] section does not exist in the Python defaults (a bug: it crashes
// attachment storage there) — it is added here deliberately, per the approved plan.
var defaults = []struct {
Section string
Keys []defaultKV
}{
{"Server", []defaultKV{
{"", "", "Server configuration for SMTP ports and hostname"},
{"", "", "Plain SMTP port for internal/whitelisted IPs"},
{"SMTP_PORT", "4025", ""},
{"", "", "TLS SMTP port for authenticated users"},
{"SMTP_TLS_PORT", "40465", ""},
{"", "", "Server hostname for HELO/EHLO identification"},
{"HOSTNAME", "mail.example.com", ""},
{"", "", "Override HELO hostname"},
{"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", ""},
{"", "", `Custom server banner (to make it empty use "" must be double quotes)`},
{"server_banner", "", ""},
{"", "", "Time zone for the server"},
{"TIME_ZONE", "Europe/London", ""},
}},
{"Database", []defaultKV{
{"", "", "Database configuration"},
{"DATABASE_URL", "sqlite:///server_data/smtp_server.db", ""},
}},
{"Logging", []defaultKV{
{"", "", "Logging configuration"},
{"", "", "Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL"},
{"LOG_LEVEL", "INFO", ""},
{"", "", "Hide verbose aiosmtpd-equivalent INFO messages when LOG_LEVEL = INFO"},
{"hide_info_aiosmtpd", "true", ""},
}},
{"Relay", []defaultKV{
{"", "", "Timeout in seconds for external SMTP connections"},
{"RELAY_TIMEOUT", "30", ""},
}},
{"TLS", []defaultKV{
{"", "", "TLS/SSL certificate configuration"},
{"TLS_CERT_FILE", "ssl_certs/server.crt", ""},
{"TLS_KEY_FILE", "ssl_certs/server.key", ""},
}},
{"DKIM", []defaultKV{
{"", "", "DKIM signing configuration"},
{"", "", "RSA key size for DKIM keys (1024, 2048, 4096)"},
{"DKIM_KEY_SIZE", "2048", ""},
{"", "", "Provide Public IP address of server, used for SPF in case detection fails"},
{"SPF_SERVER_IP", "192.168.1.1", ""},
}},
{"Attachments", []defaultKV{
{"", "", "Directory where stored message attachments are written (fixed: missing in the Python defaults)"},
{"attachments_path", "server_data/attachments", ""},
}},
{"Auth", []defaultKV{
{"", "", "Admin dashboard login / passkey (WebAuthn) configuration"},
{"", "", "Must match the domain the admin dashboard is actually accessed at — passkeys are bound to this"},
{"rp_id", "localhost", ""},
{"", "", "Display name shown in the authenticator/passkey prompt"},
{"rp_display_name", "mailgoserver", ""},
{"", "", `Full origin (scheme+host+port) the dashboard is served at, e.g. "https://mail.example.com"`},
{"rp_origin", "http://localhost:5000", ""},
}},
}
// GenerateSettingsIni writes settings.ini with default values and comments if it does
// not already exist. Mirrors settings_loader.generate_settings_ini: never overwrites or
// merges into an existing file.
func GenerateSettingsIni(path string) error {
if _, err := os.Stat(path); err == nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
cfg := ini.Empty()
for _, sec := range defaults {
section, err := cfg.NewSection(sec.Section)
if err != nil {
return err
}
for _, kv := range sec.Keys {
if kv.Key == "" {
// Comment-only line, e.g. a section header comment. No trailing
// newline: ini.v1's writer splits Comment on "\n" and indexes
// line[0] unconditionally, so a trailing separator produces an
// empty final line and panics.
if section.Comment != "" {
section.Comment += "\n"
}
section.Comment += kv.Comment
continue
}
key, err := section.NewKey(kv.Key, kv.Value)
if err != nil {
return err
}
if kv.Comment != "" {
key.Comment = kv.Comment
}
}
}
return cfg.SaveTo(path)
}
// Load reads settings.ini at path, generating it with defaults first if missing.
// Mirrors settings_loader.load_settings: always regenerate-if-missing, then read fresh.
func Load(path string) (*ini.File, error) {
if err := GenerateSettingsIni(path); err != nil {
return nil, err
}
return ini.Load(path)
}
// AbsoluteSQLitePath converts a "sqlite:///relative/path" database URL into an absolute
// filesystem path resolved against root, mirroring app.py's _get_absolute_database_url.
func AbsoluteSQLitePath(databaseURL, root string) string {
const prefix = "sqlite:///"
if len(databaseURL) < len(prefix) || databaseURL[:len(prefix)] != prefix {
return databaseURL
}
rel := databaseURL[len(prefix):]
if filepath.IsAbs(rel) {
return rel
}
return filepath.Join(root, rel)
}
+48
View File
@@ -0,0 +1,48 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestGenerateAndLoadRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "settings.ini")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load (generate): %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("settings.ini was not written: %v", err)
}
if got := cfg.Section("Server").Key("SMTP_PORT").String(); got != "4025" {
t.Errorf("SMTP_PORT = %q, want 4025", got)
}
if got := cfg.Section("Attachments").Key("attachments_path").String(); got == "" {
t.Error("Attachments.attachments_path default is missing (the approved bug fix)")
}
// Load again against the now-existing file — must not regenerate/overwrite.
cfg2, err := Load(path)
if err != nil {
t.Fatalf("Load (existing): %v", err)
}
if got := cfg2.Section("Server").Key("SMTP_PORT").String(); got != "4025" {
t.Errorf("second Load: SMTP_PORT = %q, want 4025", got)
}
}
func TestAbsoluteSQLitePath(t *testing.T) {
cases := []struct{ url, root, want string }{
{"sqlite:///server_data/db.sqlite", "/app", "/app/server_data/db.sqlite"},
{"sqlite:////abs/db.sqlite", "/app", "/abs/db.sqlite"},
{"mysql://x", "/app", "mysql://x"},
}
for _, c := range cases {
if got := AbsoluteSQLitePath(c.url, c.root); got != c.want {
t.Errorf("AbsoluteSQLitePath(%q, %q) = %q, want %q", c.url, c.root, got, c.want)
}
}
}
+32
View File
@@ -0,0 +1,32 @@
package db
import "time"
type AdminUser struct {
ID int64
Username string
PasswordHash string
MustChangePassword bool
TOTPSecret string
TOTPEnabled bool
IsGlobalAdmin bool
CreatedBy *int64
CreatedAt time.Time
}
type AdminSession struct {
Token string
UserID int64
MFAVerified bool
CreatedAt time.Time
ExpiresAt time.Time
}
type WebAuthnCredential struct {
ID int64
UserID int64
Name string
CredentialID string
CredentialData string
CreatedAt time.Time
}
+58
View File
@@ -0,0 +1,58 @@
package db
import (
"database/sql"
"errors"
)
// GetDomainByNameExact looks up a domain by exact (case-sensitive) name, regardless of
// is_active, mirroring the raw `filter_by(domain_name=domain_name)` query used inside
// DKIMManager.generate_dkim_keypair (unlike get_domain_by_name, which is case-insensitive
// and active-only).
func (d *DB) GetDomainByNameExact(name string) (*Domain, error) {
row := d.QueryRow(`SELECT `+domainColumns+` FROM esrv_domains WHERE domain_name = ?`, name)
return scanDomain(row)
}
// GetDomainByID looks up a domain by primary key, regardless of is_active — used by the
// admin web UI's edit/delete/toggle actions, which operate on a specific row by id.
func (d *DB) GetDomainByID(id int64) (*Domain, error) {
row := d.QueryRow(`SELECT `+domainColumns+` FROM esrv_domains WHERE id = ?`, id)
return scanDomain(row)
}
func (d *DB) GetDKIMKeyByDomainAndSelector(domainID int64, selector string) (*DKIMKey, error) {
row := d.QueryRow(`SELECT id, domain_id, selector, private_key, public_key, is_active, created_at, replaced_at
FROM esrv_dkim_keys WHERE domain_id = ? AND selector = ?`, domainID, selector)
return scanDKIMKey(row)
}
func (d *DB) GetActiveDKIMKeyByDomainID(domainID int64) (*DKIMKey, error) {
row := d.QueryRow(`SELECT id, domain_id, selector, private_key, public_key, is_active, created_at, replaced_at
FROM esrv_dkim_keys WHERE domain_id = ? AND is_active = 1`, domainID)
return scanDKIMKey(row)
}
func (d *DB) GetDKIMKeyByID(id int64) (*DKIMKey, error) {
row := d.QueryRow(`SELECT id, domain_id, selector, private_key, public_key, is_active, created_at, replaced_at
FROM esrv_dkim_keys WHERE id = ?`, id)
return scanDKIMKey(row)
}
func scanDKIMKey(row *sql.Row) (*DKIMKey, error) {
var k DKIMKey
var createdAt string
var replacedAt sql.NullString
if err := row.Scan(&k.ID, &k.DomainID, &k.Selector, &k.PrivateKey, &k.PublicKey, &k.IsActive, &createdAt, &replacedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
k.CreatedAt, _ = parseTime(createdAt)
if replacedAt.Valid {
t, _ := parseTime(replacedAt.String)
k.ReplacedAt = &t
}
return &k, nil
}
+328
View File
@@ -0,0 +1,328 @@
package db
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"time"
)
const adminUserColumns = `id, username, password_hash, must_change_password, totp_secret, totp_enabled, is_global_admin, created_by, created_at`
func scanAdminUser(row *sql.Row) (*AdminUser, error) {
var u AdminUser
var createdAt string
var createdBy sql.NullInt64
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.MustChangePassword, &u.TOTPSecret, &u.TOTPEnabled, &u.IsGlobalAdmin, &createdBy, &createdAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
u.CreatedAt, _ = parseTime(createdAt)
if createdBy.Valid {
u.CreatedBy = &createdBy.Int64
}
return &u, nil
}
func (d *DB) CountAdminUsers() (int, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_admin_users`).Scan(&n)
return n, err
}
// DefaultAdminUsername/Password are the seeded first-run credentials — the admin is
// forced to change both before they can use the rest of the dashboard (see
// AdminUser.MustChangePassword and the login flow).
const (
DefaultAdminUsername = "admin"
DefaultAdminPassword = "Password123!"
)
// SeedDefaultAdminIfEmpty creates the default admin account on a brand-new install
// (no admin users yet at all) with must_change_password set, so the default
// credentials can never be left in place silently.
func (d *DB) SeedDefaultAdminIfEmpty() error {
n, err := d.CountAdminUsers()
if err != nil {
return err
}
if n > 0 {
return nil
}
hash, err := HashPassword(DefaultAdminPassword)
if err != nil {
return err
}
_, err = d.CreateAdminUser(DefaultAdminUsername, hash, true)
return err
}
// CreateAdminUser inserts a new global-admin account (full access, no domain
// restriction). mustChangePassword should be true for the seeded default account so
// it can't keep running on default credentials.
func (d *DB) CreateAdminUser(username, passwordHash string, mustChangePassword bool) (int64, error) {
res, err := d.Exec(`INSERT INTO esrv_admin_users (username, password_hash, must_change_password, is_global_admin) VALUES (?, ?, ?, 1)`,
username, passwordHash, mustChangePassword)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// CreateScopedAdminUser inserts a new domain-scoped admin (delegated access), owned by
// createdBy, and grants it access to exactly domainIDs — mirrors the delegation flow:
// a scoped admin can create other scoped admins limited to domains within their own.
func (d *DB) CreateScopedAdminUser(username, passwordHash string, createdBy int64, domainIDs []int64) (int64, error) {
tx, err := d.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
res, err := tx.Exec(`INSERT INTO esrv_admin_users (username, password_hash, must_change_password, is_global_admin, created_by) VALUES (?, ?, 1, 0, ?)`,
username, passwordHash, createdBy)
if err != nil {
return 0, err
}
id, err := res.LastInsertId()
if err != nil {
return 0, err
}
for _, domainID := range domainIDs {
if _, err := tx.Exec(`INSERT INTO esrv_admin_domain_access (admin_user_id, domain_id) VALUES (?, ?)`, id, domainID); err != nil {
return 0, err
}
}
return id, tx.Commit()
}
// ListAllAdminUsers returns every admin account — for a global admin's user-management
// view.
func (d *DB) ListAllAdminUsers() ([]AdminUser, error) {
rows, err := d.Query(`SELECT ` + adminUserColumns + ` FROM esrv_admin_users ORDER BY username`)
if err != nil {
return nil, err
}
return scanAdminUsers(rows)
}
// ListScopedAdminUsers returns every non-global admin. Combined with AccessibleDomainIDs
// per user, this lets the caller compute "which of these can I (a scoped admin)
// manage" — the subset check happens in Go since the admin counts here are always
// small (a handful of delegated accounts, not enterprise scale).
func (d *DB) ListScopedAdminUsers() ([]AdminUser, error) {
rows, err := d.Query(`SELECT ` + adminUserColumns + ` FROM esrv_admin_users WHERE is_global_admin = 0 ORDER BY username`)
if err != nil {
return nil, err
}
return scanAdminUsers(rows)
}
func scanAdminUsers(rows *sql.Rows) ([]AdminUser, error) {
defer rows.Close()
var out []AdminUser
for rows.Next() {
var u AdminUser
var createdAt string
var createdBy sql.NullInt64
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.MustChangePassword, &u.TOTPSecret, &u.TOTPEnabled, &u.IsGlobalAdmin, &createdBy, &createdAt); err != nil {
return nil, err
}
u.CreatedAt, _ = parseTime(createdAt)
if createdBy.Valid {
u.CreatedBy = &createdBy.Int64
}
out = append(out, u)
}
return out, rows.Err()
}
// AccessibleDomainIDs returns the domains a scoped admin can see/manage. Meaningless
// for a global admin (they can access everything regardless of this table).
func (d *DB) AccessibleDomainIDs(userID int64) ([]int64, error) {
rows, err := d.Query(`SELECT domain_id FROM esrv_admin_domain_access WHERE admin_user_id = ?`, userID)
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()
}
// GrantDomainAccess mirrors auto-assigning a newly-created domain to the scoped admin
// who created it.
func (d *DB) GrantDomainAccess(userID, domainID int64) error {
_, err := d.Exec(`INSERT OR IGNORE INTO esrv_admin_domain_access (admin_user_id, domain_id) VALUES (?, ?)`, userID, domainID)
return err
}
// SetAdminDomainAccess replaces a scoped admin's entire domain assignment set.
func (d *DB) SetAdminDomainAccess(userID int64, domainIDs []int64) error {
tx, err := d.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM esrv_admin_domain_access WHERE admin_user_id = ?`, userID); err != nil {
return err
}
for _, id := range domainIDs {
if _, err := tx.Exec(`INSERT INTO esrv_admin_domain_access (admin_user_id, domain_id) VALUES (?, ?)`, userID, id); err != nil {
return err
}
}
return tx.Commit()
}
// DeleteAdminUser removes an admin account and everything tied to it.
func (d *DB) DeleteAdminUser(id int64) error {
tx, err := d.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, stmt := range []string{
`DELETE FROM esrv_admin_domain_access WHERE admin_user_id = ?`,
`DELETE FROM esrv_admin_sessions WHERE user_id = ?`,
`DELETE FROM esrv_webauthn_credentials WHERE user_id = ?`,
`DELETE FROM esrv_admin_users WHERE id = ?`,
} {
if _, err := tx.Exec(stmt, id); err != nil {
return err
}
}
return tx.Commit()
}
func (d *DB) GetAdminUserByUsername(username string) (*AdminUser, error) {
row := d.QueryRow(`SELECT `+adminUserColumns+` FROM esrv_admin_users WHERE lower(username) = lower(?)`, username)
return scanAdminUser(row)
}
func (d *DB) GetAdminUserByID(id int64) (*AdminUser, error) {
row := d.QueryRow(`SELECT `+adminUserColumns+` FROM esrv_admin_users WHERE id = ?`, id)
return scanAdminUser(row)
}
// UpdateAdminCredentials mirrors the forced first-login change: new username,
// password hash, and clears must_change_password in one step.
func (d *DB) UpdateAdminCredentials(id int64, username, passwordHash string) error {
_, err := d.Exec(`UPDATE esrv_admin_users SET username = ?, password_hash = ?, must_change_password = 0 WHERE id = ?`, username, passwordHash, id)
return err
}
func (d *DB) UpdateAdminPassword(id int64, passwordHash string) error {
_, err := d.Exec(`UPDATE esrv_admin_users SET password_hash = ? WHERE id = ?`, passwordHash, id)
return err
}
func (d *DB) SetAdminTOTPSecret(id int64, secret string, enabled bool) error {
_, err := d.Exec(`UPDATE esrv_admin_users SET totp_secret = ?, totp_enabled = ? WHERE id = ?`, secret, enabled, id)
return err
}
func (d *DB) DisableAdminTOTP(id int64) error {
_, err := d.Exec(`UPDATE esrv_admin_users SET totp_secret = '', totp_enabled = 0 WHERE id = ?`, id)
return err
}
// --- Sessions ---
func newSessionToken() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
// CreateSession mirrors starting a new login session; mfaVerified should be true only
// when the account has no second factor enabled (nothing left to verify) or the second
// factor was just satisfied.
func (d *DB) CreateSession(userID int64, mfaVerified bool, ttl time.Duration) (string, error) {
token := newSessionToken()
_, err := d.Exec(`INSERT INTO esrv_admin_sessions (token, user_id, mfa_verified, expires_at) VALUES (?, ?, ?, ?)`,
token, userID, mfaVerified, time.Now().Add(ttl))
if err != nil {
return "", err
}
return token, nil
}
func (d *DB) GetSession(token string) (*AdminSession, error) {
row := d.QueryRow(`SELECT token, user_id, mfa_verified, created_at, expires_at FROM esrv_admin_sessions WHERE token = ?`, token)
var s AdminSession
var createdAt, expiresAt string
if err := row.Scan(&s.Token, &s.UserID, &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) MarkSessionMFAVerified(token string) error {
_, err := d.Exec(`UPDATE esrv_admin_sessions SET mfa_verified = 1 WHERE token = ?`, token)
return err
}
func (d *DB) DeleteSession(token string) error {
_, err := d.Exec(`DELETE FROM esrv_admin_sessions WHERE token = ?`, token)
return err
}
// DeleteExpiredSessions is a lightweight best-effort sweep, called opportunistically
// rather than on a schedule — this admin UI has at most a handful of sessions ever.
func (d *DB) DeleteExpiredSessions() error {
_, err := d.Exec(`DELETE FROM esrv_admin_sessions WHERE expires_at < ?`, time.Now())
return err
}
// --- WebAuthn credentials ---
func (d *DB) ListWebAuthnCredentials(userID int64) ([]WebAuthnCredential, error) {
rows, err := d.Query(`SELECT id, user_id, name, credential_id, credential_data, created_at FROM esrv_webauthn_credentials WHERE user_id = ? ORDER BY created_at`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []WebAuthnCredential
for rows.Next() {
var c WebAuthnCredential
var createdAt string
if err := rows.Scan(&c.ID, &c.UserID, &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) CreateWebAuthnCredential(userID int64, name, credentialID, credentialData string) error {
_, err := d.Exec(`INSERT INTO esrv_webauthn_credentials (user_id, name, credential_id, credential_data) VALUES (?, ?, ?, ?)`,
userID, name, credentialID, credentialData)
return err
}
func (d *DB) DeleteWebAuthnCredential(id, userID int64) error {
_, err := d.Exec(`DELETE FROM esrv_webauthn_credentials WHERE id = ? AND user_id = ?`, id, userID)
return err
}
func (d *DB) CountWebAuthnCredentials(userID int64) (int, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_webauthn_credentials WHERE user_id = ?`, userID).Scan(&n)
return n, err
}
+19
View File
@@ -0,0 +1,19 @@
package db
func (d *DB) CountSendersForDomain(domainID int64) (int, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_senders WHERE domain_id = ?`, domainID).Scan(&n)
return n, err
}
func (d *DB) HasActiveDKIMForDomain(domainID int64) (bool, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_dkim_keys WHERE domain_id = ? AND is_active = 1`, domainID).Scan(&n)
return n > 0, err
}
func (d *DB) HasAnyDKIMForDomain(domainID int64) (bool, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_dkim_keys WHERE domain_id = ?`, domainID).Scan(&n)
return n > 0, err
}
+86
View File
@@ -0,0 +1,86 @@
package db
type DKIMKeyWithDomain struct {
DKIMKey
DomainName string
}
func (d *DB) ListActiveDKIMKeysWithDomain() ([]DKIMKeyWithDomain, error) {
rows, err := d.Query(`SELECT k.id, k.domain_id, k.selector, k.private_key, k.public_key, k.is_active, k.created_at, k.replaced_at, dm.domain_name
FROM esrv_dkim_keys k JOIN esrv_domains dm ON dm.id = k.domain_id WHERE k.is_active = 1 ORDER BY dm.domain_name`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanDKIMKeysWithDomain(rows)
}
func (d *DB) ListInactiveDKIMKeysWithDomain() ([]DKIMKeyWithDomain, error) {
rows, err := d.Query(`SELECT k.id, k.domain_id, k.selector, k.private_key, k.public_key, k.is_active, k.created_at, k.replaced_at, dm.domain_name
FROM esrv_dkim_keys k JOIN esrv_domains dm ON dm.id = k.domain_id WHERE k.is_active = 0
ORDER BY dm.domain_name, (k.replaced_at IS NULL), k.replaced_at DESC, k.created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanDKIMKeysWithDomain(rows)
}
func scanDKIMKeysWithDomain(rows interface {
Next() bool
Scan(...any) error
Err() error
}) ([]DKIMKeyWithDomain, error) {
var out []DKIMKeyWithDomain
for rows.Next() {
var k DKIMKeyWithDomain
var createdAt string
var replacedAt *string
if err := rows.Scan(&k.ID, &k.DomainID, &k.Selector, &k.PrivateKey, &k.PublicKey, &k.IsActive, &createdAt, &replacedAt, &k.DomainName); err != nil {
return nil, err
}
k.CreatedAt, _ = parseTime(createdAt)
if replacedAt != nil {
t, _ := parseTime(*replacedAt)
k.ReplacedAt = &t
}
out = append(out, k)
}
return out, rows.Err()
}
func (d *DB) CountActiveDKIMKeys() (int, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_dkim_keys WHERE is_active = 1`).Scan(&n)
return n, err
}
func (d *DB) DeactivateActiveDKIMKeysForDomain(domainID int64, replacedAt any) error {
_, err := d.Exec(`UPDATE esrv_dkim_keys SET is_active = 0, replaced_at = ? WHERE domain_id = ? AND is_active = 1`, replacedAt, domainID)
return err
}
func (d *DB) SetDKIMKeyActive(id int64, active bool, replacedAt any) error {
if active {
_, err := d.Exec(`UPDATE esrv_dkim_keys SET is_active = 1, replaced_at = NULL WHERE id = ?`, id)
return err
}
_, err := d.Exec(`UPDATE esrv_dkim_keys SET is_active = 0, replaced_at = ? WHERE id = ?`, replacedAt, id)
return err
}
func (d *DB) UpdateDKIMKeySelector(id int64, selector string) error {
_, err := d.Exec(`UPDATE esrv_dkim_keys SET selector = ? WHERE id = ?`, selector, id)
return err
}
func (d *DB) SelectorExistsForDomain(domainID int64, selector string, excludeID int64) (bool, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_dkim_keys WHERE domain_id = ? AND selector = ? AND is_active = 1 AND id != ?`, domainID, selector, excludeID).Scan(&n)
return n > 0, err
}
func (d *DB) RemoveDKIMKey(id int64) error {
_, err := d.Exec(`DELETE FROM esrv_dkim_keys WHERE id = ?`, id)
return err
}
+132
View File
@@ -0,0 +1,132 @@
package db
import (
"crypto/rand"
"encoding/hex"
"time"
)
func (d *DB) ListDomains() ([]Domain, error) {
rows, err := d.Query(`SELECT ` + domainColumns + ` FROM esrv_domains ORDER BY domain_name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Domain
for rows.Next() {
var dm Domain
var createdAt string
var verifiedAt *string
if err := rows.Scan(&dm.ID, &dm.DomainName, &dm.IsActive, &createdAt, &dm.VerificationToken, &dm.IsVerified, &verifiedAt); err != nil {
return nil, err
}
dm.CreatedAt, _ = parseTime(createdAt)
if verifiedAt != nil {
t, _ := parseTime(*verifiedAt)
dm.VerifiedAt = &t
}
out = append(out, dm)
}
return out, rows.Err()
}
// ListActiveDomains mirrors the `domains` query used to populate <select> lists on the
// add/edit sender and IP forms.
func (d *DB) ListActiveDomains() ([]Domain, error) {
all, err := d.ListDomains()
if err != nil {
return nil, err
}
var out []Domain
for _, dm := range all {
if dm.IsActive {
out = append(out, dm)
}
}
return out, nil
}
func (d *DB) CountActiveDomains() (int, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_domains WHERE is_active = 1`).Scan(&n)
return n, err
}
// generateVerificationToken returns a random 32-hex-char token for the DNS TXT
// ownership check, mirroring the randomness quality already used for DKIM selectors.
func generateVerificationToken() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
// CreateDomain inserts a new, unverified domain with a freshly generated DNS
// verification token.
func (d *DB) CreateDomain(name string) (int64, error) {
res, err := d.Exec(`INSERT INTO esrv_domains (domain_name, is_active, verification_token, is_verified) VALUES (?, 1, ?, 0)`,
name, generateVerificationToken())
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (d *DB) UpdateDomain(id int64, name string, requiresAuth bool) error {
_, err := d.Exec(`UPDATE esrv_domains SET domain_name = ? WHERE id = ?`, name, id)
return err
}
func (d *DB) SetDomainActive(id int64, active bool) error {
_, err := d.Exec(`UPDATE esrv_domains SET is_active = ? WHERE id = ?`, active, id)
return err
}
// SetDomainVerified mirrors marking a domain as DNS-ownership-verified (or reverting
// it, e.g. if an admin wants to force re-verification).
func (d *DB) SetDomainVerified(id int64, verified bool) error {
if verified {
_, err := d.Exec(`UPDATE esrv_domains SET is_verified = 1, verified_at = ? WHERE id = ?`, time.Now(), id)
return err
}
_, err := d.Exec(`UPDATE esrv_domains SET is_verified = 0, verified_at = NULL WHERE id = ?`, id)
return err
}
// RegenerateVerificationToken mirrors resetting a domain back to a fresh, unverified
// token — used if an admin wants a new TXT value (e.g. suspected leak, or restarting
// the ownership proof).
func (d *DB) RegenerateVerificationToken(id int64) (string, error) {
token := generateVerificationToken()
_, err := d.Exec(`UPDATE esrv_domains SET verification_token = ?, is_verified = 0, verified_at = NULL WHERE id = ?`, token, id)
return token, err
}
// RemoveDomainCascade hard-deletes a domain and every row that references it, mirroring
// domains.remove_domain. Returns counts removed for the flash message.
func (d *DB) RemoveDomainCascade(id int64) (senders, ips, dkimKeys, headers int, err error) {
tx, err := d.Begin()
if err != nil {
return
}
defer tx.Rollback()
for table, count := range map[string]*int{
"esrv_senders": &senders,
"esrv_whitelisted_ips": &ips,
"esrv_dkim_keys": &dkimKeys,
"esrv_custom_headers": &headers,
} {
row := tx.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE domain_id = ?`, id)
if err = row.Scan(count); err != nil {
return
}
if _, err = tx.Exec(`DELETE FROM `+table+` WHERE domain_id = ?`, id); err != nil {
return
}
}
if _, err = tx.Exec(`DELETE FROM esrv_domains WHERE id = ?`, id); err != nil {
return
}
err = tx.Commit()
return
}
+74
View File
@@ -0,0 +1,74 @@
package db
import (
"database/sql"
"errors"
)
type WhitelistedIPWithDomain struct {
WhitelistedIP
DomainName string
}
func (d *DB) ListWhitelistedIPs() ([]WhitelistedIPWithDomain, error) {
rows, err := d.Query(`SELECT w.id, w.ip_address, w.domain_id, w.is_active, w.created_at, w.store_message_content, dm.domain_name
FROM esrv_whitelisted_ips w JOIN esrv_domains dm ON dm.id = w.domain_id ORDER BY w.ip_address`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []WhitelistedIPWithDomain
for rows.Next() {
var w WhitelistedIPWithDomain
var createdAt string
if err := rows.Scan(&w.ID, &w.IPAddress, &w.DomainID, &w.IsActive, &createdAt, &w.StoreMessageContent, &w.DomainName); err != nil {
return nil, err
}
w.CreatedAt, _ = parseTime(createdAt)
out = append(out, w)
}
return out, rows.Err()
}
func (d *DB) GetWhitelistedIPByID(id int64) (*WhitelistedIP, error) {
row := d.QueryRow(`SELECT id, ip_address, domain_id, is_active, created_at, store_message_content FROM esrv_whitelisted_ips WHERE id = ?`, id)
var w WhitelistedIP
var createdAt string
if err := row.Scan(&w.ID, &w.IPAddress, &w.DomainID, &w.IsActive, &createdAt, &w.StoreMessageContent); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
w.CreatedAt, _ = parseTime(createdAt)
return &w, nil
}
func (d *DB) IPPairExists(ip string, domainID, excludeID int64) (bool, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_whitelisted_ips WHERE ip_address = ? AND domain_id = ? AND id != ?`, ip, domainID, excludeID).Scan(&n)
return n > 0, err
}
func (d *DB) CreateWhitelistedIP(ip string, domainID int64, storeMessageContent bool) (int64, error) {
res, err := d.Exec(`INSERT INTO esrv_whitelisted_ips (ip_address, domain_id, is_active, store_message_content) VALUES (?, ?, 1, ?)`, ip, domainID, storeMessageContent)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (d *DB) UpdateWhitelistedIP(id int64, ip string, domainID int64, storeMessageContent bool) error {
_, err := d.Exec(`UPDATE esrv_whitelisted_ips SET ip_address = ?, domain_id = ?, store_message_content = ? WHERE id = ?`, ip, domainID, storeMessageContent, id)
return err
}
func (d *DB) SetWhitelistedIPActive(id int64, active bool) error {
_, err := d.Exec(`UPDATE esrv_whitelisted_ips SET is_active = ? WHERE id = ?`, active, id)
return err
}
func (d *DB) RemoveWhitelistedIP(id int64) error {
_, err := d.Exec(`DELETE FROM esrv_whitelisted_ips WHERE id = ?`, id)
return err
}
+125
View File
@@ -0,0 +1,125 @@
package db
import (
"database/sql"
"errors"
)
func (d *DB) GetEmailLogByID(id int64) (*EmailLog, error) {
row := d.QueryRow(`SELECT id, message_id, timestamp, peer_ip, mail_from, to_address, cc_addresses, bcc_addresses, subject, email_headers, message_body, status, dkim_signed, username, created_at
FROM esrv_email_logs WHERE id = ?`, id)
return scanEmailLog(row)
}
func scanEmailLog(row *sql.Row) (*EmailLog, error) {
var l EmailLog
var ts, createdAt string
if err := row.Scan(&l.ID, &l.MessageID, &ts, &l.PeerIP, &l.MailFrom, &l.ToAddress, &l.CcAddresses, &l.BccAddresses, &l.Subject, &l.EmailHeaders, &l.MessageBody, &l.Status, &l.DKIMSigned, &l.Username, &createdAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
l.Timestamp, _ = parseTime(ts)
l.CreatedAt, _ = parseTime(createdAt)
return &l, nil
}
func (d *DB) ListEmailLogsPage(offset, limit int) ([]EmailLog, error) {
rows, err := d.Query(`SELECT id, message_id, timestamp, peer_ip, mail_from, to_address, cc_addresses, bcc_addresses, subject, email_headers, message_body, status, dkim_signed, username, created_at
FROM esrv_email_logs ORDER BY created_at DESC LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []EmailLog
for rows.Next() {
var l EmailLog
var ts, createdAt string
if err := rows.Scan(&l.ID, &l.MessageID, &ts, &l.PeerIP, &l.MailFrom, &l.ToAddress, &l.CcAddresses, &l.BccAddresses, &l.Subject, &l.EmailHeaders, &l.MessageBody, &l.Status, &l.DKIMSigned, &l.Username, &createdAt); err != nil {
return nil, err
}
l.Timestamp, _ = parseTime(ts)
l.CreatedAt, _ = parseTime(createdAt)
out = append(out, l)
}
return out, rows.Err()
}
func (d *DB) ListAuthLogsPage(offset, limit int) ([]AuthLog, error) {
rows, err := d.Query(`SELECT id, auth_type, identifier, ip_address, success, message, created_at FROM esrv_auth_logs ORDER BY created_at DESC LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AuthLog
for rows.Next() {
var a AuthLog
var createdAt string
if err := rows.Scan(&a.ID, &a.AuthType, &a.Identifier, &a.IPAddress, &a.Success, &a.Message, &createdAt); err != nil {
return nil, err
}
a.CreatedAt, _ = parseTime(createdAt)
out = append(out, a)
}
return out, rows.Err()
}
func (d *DB) ListRecentAuthLogs(limit int) ([]AuthLog, error) {
return d.ListAuthLogsPage(0, limit)
}
func (d *DB) ListRecipientLogsForEmail(emailLogID int64) ([]EmailRecipientLog, error) {
rows, err := d.Query(`SELECT id, email_log_id, recipient, recipient_type, status, error_code, error_message, server_response FROM esrv_email_recipient_logs WHERE email_log_id = ?`, emailLogID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []EmailRecipientLog
for rows.Next() {
var r EmailRecipientLog
if err := rows.Scan(&r.ID, &r.EmailLogID, &r.Recipient, &r.RecipientType, &r.Status, &r.ErrorCode, &r.ErrorMessage, &r.ServerResponse); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (d *DB) ListAttachmentsForEmail(emailLogID int64) ([]EmailAttachment, error) {
rows, err := d.Query(`SELECT id, email_log_id, filename, content_type, file_path, size, uploaded_at FROM esrv_email_attachments WHERE email_log_id = ?`, emailLogID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []EmailAttachment
for rows.Next() {
var a EmailAttachment
var uploadedAt string
if err := rows.Scan(&a.ID, &a.EmailLogID, &a.Filename, &a.ContentType, &a.FilePath, &a.Size, &uploadedAt); err != nil {
return nil, err
}
a.UploadedAt, _ = parseTime(uploadedAt)
out = append(out, a)
}
return out, rows.Err()
}
func (d *DB) GetAttachmentByID(id int64) (*EmailAttachment, error) {
row := d.QueryRow(`SELECT id, email_log_id, filename, content_type, file_path, size, uploaded_at FROM esrv_email_attachments WHERE id = ?`, id)
var a EmailAttachment
var uploadedAt string
if err := row.Scan(&a.ID, &a.EmailLogID, &a.Filename, &a.ContentType, &a.FilePath, &a.Size, &uploadedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
a.UploadedAt, _ = parseTime(uploadedAt)
return &a, nil
}
func (d *DB) RemoveAttachment(id int64) error {
_, err := d.Exec(`DELETE FROM esrv_email_attachments WHERE id = ?`, id)
return err
}
+90
View File
@@ -0,0 +1,90 @@
package db
import (
"database/sql"
"errors"
)
// SenderWithDomain joins a Sender with its Domain's name, mirroring the
// Sender+Domain join used by senders.py's list view.
type SenderWithDomain struct {
Sender
DomainName string
}
func (d *DB) ListSenders() ([]SenderWithDomain, error) {
rows, err := d.Query(`SELECT s.id, s.email, s.password_hash, s.domain_id, s.can_send_as_domain, s.is_active, s.created_at, s.store_message_content, dm.domain_name
FROM esrv_senders s JOIN esrv_domains dm ON dm.id = s.domain_id ORDER BY s.email`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SenderWithDomain
for rows.Next() {
var s SenderWithDomain
var createdAt string
if err := rows.Scan(&s.ID, &s.Email, &s.PasswordHash, &s.DomainID, &s.CanSendAsDomain, &s.IsActive, &createdAt, &s.StoreMessageContent, &s.DomainName); err != nil {
return nil, err
}
s.CreatedAt, _ = parseTime(createdAt)
out = append(out, s)
}
return out, rows.Err()
}
func (d *DB) GetSenderByID(id int64) (*Sender, error) {
row := d.QueryRow(`SELECT id, email, password_hash, domain_id, can_send_as_domain, is_active, created_at, store_message_content
FROM esrv_senders WHERE id = ?`, id)
var s Sender
var createdAt string
if err := row.Scan(&s.ID, &s.Email, &s.PasswordHash, &s.DomainID, &s.CanSendAsDomain, &s.IsActive, &createdAt, &s.StoreMessageContent); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
s.CreatedAt, _ = parseTime(createdAt)
return &s, nil
}
func (d *DB) CountActiveSenders() (int, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_senders WHERE is_active = 1`).Scan(&n)
return n, err
}
func (d *DB) EmailExists(email string, excludeID int64) (bool, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_senders WHERE lower(email) = lower(?) AND id != ?`, email, excludeID).Scan(&n)
return n > 0, err
}
func (d *DB) CreateSender(email, passwordHash string, domainID int64, canSendAsDomain, storeMessageContent bool) (int64, error) {
res, err := d.Exec(`INSERT INTO esrv_senders (email, password_hash, domain_id, can_send_as_domain, is_active, store_message_content)
VALUES (?, ?, ?, ?, 1, ?)`, email, passwordHash, domainID, canSendAsDomain, storeMessageContent)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (d *DB) UpdateSender(id int64, email, passwordHash string, domainID int64, canSendAsDomain, storeMessageContent bool) error {
if passwordHash == "" {
_, err := d.Exec(`UPDATE esrv_senders SET email = ?, domain_id = ?, can_send_as_domain = ?, store_message_content = ? WHERE id = ?`,
email, domainID, canSendAsDomain, storeMessageContent, id)
return err
}
_, err := d.Exec(`UPDATE esrv_senders SET email = ?, password_hash = ?, domain_id = ?, can_send_as_domain = ?, store_message_content = ? WHERE id = ?`,
email, passwordHash, domainID, canSendAsDomain, storeMessageContent, id)
return err
}
func (d *DB) SetSenderActive(id int64, active bool) error {
_, err := d.Exec(`UPDATE esrv_senders SET is_active = ? WHERE id = ?`, active, id)
return err
}
func (d *DB) RemoveSender(id int64) error {
_, err := d.Exec(`DELETE FROM esrv_senders WHERE id = ?`, id)
return err
}
+34
View File
@@ -0,0 +1,34 @@
package db
import "time"
// InsertEmailLog mirrors the EmailLog row creation in EmailRelay.log_email. Returns the
// new row's id (needed before recipient/attachment child rows can be inserted).
func (d *DB) InsertEmailLog(l EmailLog) (int64, error) {
res, err := d.Exec(`INSERT INTO esrv_email_logs
(message_id, timestamp, peer_ip, mail_from, to_address, cc_addresses, bcc_addresses, subject, email_headers, message_body, status, dkim_signed, username)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
l.MessageID, l.Timestamp, l.PeerIP, l.MailFrom, l.ToAddress, l.CcAddresses, l.BccAddresses, l.Subject, l.EmailHeaders, l.MessageBody, l.Status, l.DKIMSigned, l.Username)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// InsertEmailRecipientLog mirrors one EmailRecipientLog row creation.
func (d *DB) InsertEmailRecipientLog(l EmailRecipientLog) error {
_, err := d.Exec(`INSERT INTO esrv_email_recipient_logs
(email_log_id, recipient, recipient_type, status, error_code, error_message, server_response)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
l.EmailLogID, l.Recipient, l.RecipientType, l.Status, l.ErrorCode, l.ErrorMessage, l.ServerResponse)
return err
}
// InsertEmailAttachment mirrors one EmailAttachment row creation.
func (d *DB) InsertEmailAttachment(a EmailAttachment) error {
_, err := d.Exec(`INSERT INTO esrv_email_attachments
(email_log_id, filename, content_type, file_path, size, uploaded_at)
VALUES (?, ?, ?, ?, ?, ?)`,
a.EmailLogID, a.Filename, a.ContentType, a.FilePath, a.Size, time.Now())
return err
}
+115
View File
@@ -0,0 +1,115 @@
package db
import "time"
type Domain struct {
ID int64
DomainName string
IsActive bool
CreatedAt time.Time
VerificationToken string
IsVerified bool
VerifiedAt *time.Time
}
type Sender struct {
ID int64
Email string
PasswordHash string
DomainID int64
CanSendAsDomain bool
IsActive bool
CreatedAt time.Time
StoreMessageContent bool
}
// CanSendAs mirrors Sender.can_send_as in models.py.
func (s Sender) CanSendAs(fromAddress string) bool {
if equalFold(fromAddress, s.Email) {
return true
}
if !s.CanSendAsDomain {
return false
}
senderDomain := domainPart(s.Email)
fromDomain := domainPart(fromAddress)
return senderDomain != "" && senderDomain == fromDomain
}
type WhitelistedIP struct {
ID int64
IPAddress string
DomainID int64
IsActive bool
CreatedAt time.Time
StoreMessageContent bool
}
type EmailLog struct {
ID int64
MessageID string
Timestamp time.Time
PeerIP string
MailFrom string
ToAddress string
CcAddresses string
BccAddresses string
Subject string
EmailHeaders string
MessageBody string
Status string
DKIMSigned bool
Username string
CreatedAt time.Time
}
type EmailRecipientLog struct {
ID int64
EmailLogID int64
Recipient string
RecipientType string
Status string
ErrorCode string
ErrorMessage string
ServerResponse string
}
type AuthLog struct {
ID int64
AuthType string
Identifier string
IPAddress string
Success bool
Message string
CreatedAt time.Time
}
type DKIMKey struct {
ID int64
DomainID int64
Selector string
PrivateKey string
PublicKey string
IsActive bool
CreatedAt time.Time
ReplacedAt *time.Time
}
type CustomHeader struct {
ID int64
DomainID int64
HeaderName string
HeaderValue string
IsActive bool
CreatedAt time.Time
}
type EmailAttachment struct {
ID int64
EmailLogID int64
Filename string
ContentType string
FilePath string
Size int64
UploadedAt time.Time
}
+142
View File
@@ -0,0 +1,142 @@
package db
import (
"database/sql"
"errors"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
func equalFold(a, b string) bool { return strings.EqualFold(a, b) }
func domainPart(address string) string {
i := strings.LastIndex(address, "@")
if i < 0 {
return ""
}
return strings.ToLower(address[i+1:])
}
// bcryptCost is pinned to 12 to match Python's bcrypt.gensalt() default, since Go's
// bcrypt.DefaultCost is 10 and would otherwise silently produce weaker hashes.
const bcryptCost = 12
// HashPassword mirrors models.hash_password.
func HashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return "", err
}
return string(b), nil
}
// CheckPassword mirrors models.check_password.
func CheckPassword(password, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// GetSenderByEmail mirrors models.get_sender_by_email: case-insensitive match against
// the lower-cased stored email, active senders only.
func (d *DB) GetSenderByEmail(email string) (*Sender, error) {
row := d.QueryRow(`SELECT id, email, password_hash, domain_id, can_send_as_domain, is_active, created_at, store_message_content
FROM esrv_senders WHERE lower(email) = lower(?) AND is_active = 1`, email)
var s Sender
var createdAt string
if err := row.Scan(&s.ID, &s.Email, &s.PasswordHash, &s.DomainID, &s.CanSendAsDomain, &s.IsActive, &createdAt, &s.StoreMessageContent); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
s.CreatedAt, _ = parseTime(createdAt)
return &s, nil
}
const domainColumns = `id, domain_name, is_active, created_at, verification_token, is_verified, verified_at`
// scanDomain scans a row selected with domainColumns, in that order.
func scanDomain(row *sql.Row) (*Domain, error) {
var dom Domain
var createdAt string
var verifiedAt sql.NullString
if err := row.Scan(&dom.ID, &dom.DomainName, &dom.IsActive, &createdAt, &dom.VerificationToken, &dom.IsVerified, &verifiedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
dom.CreatedAt, _ = parseTime(createdAt)
if verifiedAt.Valid {
t, _ := parseTime(verifiedAt.String)
dom.VerifiedAt = &t
}
return &dom, nil
}
// GetDomainByName mirrors models.get_domain_by_name.
func (d *DB) GetDomainByName(name string) (*Domain, error) {
row := d.QueryRow(`SELECT `+domainColumns+` FROM esrv_domains
WHERE lower(domain_name) = lower(?) AND is_active = 1`, name)
return scanDomain(row)
}
// GetWhitelistedIP mirrors models.get_whitelisted_ip. domainName == "" means no domain
// filter, matching the Python default parameter.
func (d *DB) GetWhitelistedIP(ipAddress, domainName string) (*WhitelistedIP, error) {
var row *sql.Row
if domainName == "" {
row = d.QueryRow(`SELECT id, ip_address, domain_id, is_active, created_at, store_message_content
FROM esrv_whitelisted_ips WHERE ip_address = ? AND is_active = 1`, ipAddress)
} else {
dom, err := d.GetDomainByName(domainName)
if err != nil {
return nil, err
}
if dom == nil {
return nil, nil
}
row = d.QueryRow(`SELECT id, ip_address, domain_id, is_active, created_at, store_message_content
FROM esrv_whitelisted_ips WHERE ip_address = ? AND is_active = 1 AND domain_id = ?`, ipAddress, dom.ID)
}
var w WhitelistedIP
var createdAt string
if err := row.Scan(&w.ID, &w.IPAddress, &w.DomainID, &w.IsActive, &createdAt, &w.StoreMessageContent); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
w.CreatedAt, _ = parseTime(createdAt)
return &w, nil
}
// CanSendForDomain mirrors WhitelistedIP.can_send_for_domain. Not called from the live
// auth path (models.py's own equivalent isn't either) — kept for interface parity.
func (w WhitelistedIP) CanSendForDomain(d *DB, domainName string) (bool, error) {
if !w.IsActive {
return false, nil
}
dom, err := d.GetDomainByName(domainName)
if err != nil || dom == nil {
return false, err
}
return w.DomainID == dom.ID, nil
}
// LogAuthAttempt mirrors models.log_auth_attempt.
func (d *DB) LogAuthAttempt(authType, identifier, ipAddress string, success bool, message string) error {
_, err := d.Exec(`INSERT INTO esrv_auth_logs (auth_type, identifier, ip_address, success, message)
VALUES (?, ?, ?, ?, ?)`, authType, identifier, ipAddress, success, message)
return err
}
func parseTime(s string) (time.Time, error) {
for _, layout := range []string{"2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05", time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
return t, nil
}
}
return time.Time{}, errors.New("unparseable time: " + s)
}
+190
View File
@@ -0,0 +1,190 @@
// Package db is the SQLite data layer, mirroring email_server/models.py. It uses plain
// database/sql + hand-written SQL rather than an ORM — the schema is small and fixed,
// so an ORM would be an unrequested abstraction.
package db
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
// schema creates all esrv_* tables if missing. There is no migration framework here,
// matching the Python precedent (its own migrations/ directory is a single manual SQL
// patch file, never auto-applied) — CREATE TABLE IF NOT EXISTS covers the whole surface.
const schema = `
CREATE TABLE IF NOT EXISTS esrv_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_name TEXT NOT NULL UNIQUE,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
verification_token TEXT NOT NULL DEFAULT '',
is_verified INTEGER NOT NULL DEFAULT 0,
verified_at DATETIME
);
CREATE TABLE IF NOT EXISTS esrv_senders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
can_send_as_domain INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
store_message_content INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS esrv_whitelisted_ips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
store_message_content INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS esrv_email_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT NOT NULL UNIQUE,
timestamp DATETIME NOT NULL,
peer_ip TEXT NOT NULL,
mail_from TEXT NOT NULL,
to_address TEXT NOT NULL DEFAULT '',
cc_addresses TEXT DEFAULT '',
bcc_addresses TEXT DEFAULT '',
subject TEXT,
email_headers TEXT NOT NULL,
message_body TEXT,
status TEXT NOT NULL,
dkim_signed INTEGER NOT NULL DEFAULT 0,
username TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS esrv_email_recipient_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email_log_id INTEGER NOT NULL REFERENCES esrv_email_logs(id),
recipient TEXT NOT NULL,
recipient_type TEXT NOT NULL,
status TEXT NOT NULL,
error_code TEXT,
error_message TEXT,
server_response TEXT
);
CREATE TABLE IF NOT EXISTS esrv_auth_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
auth_type TEXT NOT NULL,
identifier TEXT NOT NULL,
ip_address TEXT,
success INTEGER NOT NULL,
message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS esrv_dkim_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
selector TEXT NOT NULL DEFAULT 'default',
private_key TEXT NOT NULL,
public_key TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
replaced_at DATETIME
);
CREATE TABLE IF NOT EXISTS esrv_custom_headers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
header_name TEXT NOT NULL,
header_value TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS esrv_email_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email_log_id INTEGER NOT NULL REFERENCES esrv_email_logs(id),
filename TEXT NOT NULL,
content_type TEXT,
file_path TEXT NOT NULL,
size INTEGER,
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS esrv_admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
must_change_password INTEGER NOT NULL DEFAULT 0,
totp_secret TEXT NOT NULL DEFAULT '',
totp_enabled INTEGER NOT NULL DEFAULT 0,
is_global_admin INTEGER NOT NULL DEFAULT 0,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Which domains a non-global admin is allowed to see/manage. Global admins have no
-- rows here at all — their access is implicit (AdminUser.IsGlobalAdmin).
CREATE TABLE IF NOT EXISTS esrv_admin_domain_access (
admin_user_id INTEGER NOT NULL REFERENCES esrv_admin_users(id),
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
PRIMARY KEY (admin_user_id, domain_id)
);
CREATE TABLE IF NOT EXISTS esrv_admin_sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES esrv_admin_users(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_webauthn_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES esrv_admin_users(id),
name TEXT NOT NULL DEFAULT '',
credential_id TEXT NOT NULL UNIQUE,
credential_data TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`
// migrateAddedColumns best-effort ALTER TABLEs the columns added to esrv_domains
// after its first release, for dev DBs created before this feature existed.
// CREATE TABLE IF NOT EXISTS doesn't retrofit columns onto an existing table, and
// there's no migration framework here (see the schema comment above) — errors are
// ignored since SQLite has no "ADD COLUMN IF NOT EXISTS" and a duplicate-column
// error just means the column is already there.
func migrateAddedColumns(db *sql.DB) {
stmts := []string{
`ALTER TABLE esrv_domains ADD COLUMN verification_token TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE esrv_domains ADD COLUMN is_verified INTEGER NOT NULL DEFAULT 0`,
`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`,
}
for _, stmt := range stmts {
db.Exec(stmt)
}
}
// DB wraps *sql.DB with the query helpers below.
type DB struct {
*sql.DB
}
// Open opens (creating if needed) the SQLite file at path and ensures the schema exists.
func Open(path string) (*DB, error) {
sqlDB, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if _, err := sqlDB.Exec(schema); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("create tables: %w", err)
}
migrateAddedColumns(sqlDB)
return &DB{sqlDB}, nil
}
+239
View File
@@ -0,0 +1,239 @@
// Package dkim manages per-domain DKIM keys and signs outbound mail, mirroring
// email_server/dkim_manager.py.
package dkim
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"math/big"
"strings"
"time"
msgdkim "github.com/emersion/go-msgauth/dkim"
"mailgoserver/internal/db"
)
// FixedHeaders is the exact 8-header list DKIM signs over, in this fixed order,
// mirroring dkim_manager.sign_email's `headers` list.
var FixedHeaders = []string{
"from", "to", "subject", "date", "message-id", "mime-version", "content-type", "content-transfer-encoding",
}
const selectorChars = "abcdefghijklmnopqrstuvwxyz0123456789"
// GenerateSelector mirrors DKIMManager._generate_random_selector(length=12).
func GenerateSelector() string {
b := make([]byte, 12)
max := big.NewInt(int64(len(selectorChars)))
for i := range b {
n, _ := rand.Int(rand.Reader, max)
b[i] = selectorChars[n.Int64()]
}
return string(b)
}
// Manager mirrors DKIMManager, keyed to a DB handle.
type Manager struct {
DB *db.DB
KeySize int
}
func New(database *db.DB, keySize int) *Manager {
if keySize == 0 {
keySize = 2048
}
return &Manager{DB: database, KeySize: keySize}
}
// GenerateDKIMKeypair mirrors DKIMManager.generate_dkim_keypair. Returns false if the
// domain doesn't exist (looked up by exact name, active or not — matching the Python
// query, which has no is_active filter here).
func (m *Manager) GenerateDKIMKeypair(domainName, selector string, forceNewKey bool) (bool, error) {
dom, err := m.DB.GetDomainByNameExact(domainName)
if err != nil {
return false, err
}
if dom == nil {
return false, nil
}
now := time.Now()
if _, err := m.DB.Exec(`UPDATE esrv_dkim_keys SET is_active = 0, replaced_at = ? WHERE domain_id = ? AND is_active = 1`, now, dom.ID); err != nil {
return false, err
}
if selector == "" {
selector = GenerateSelector()
}
if !forceNewKey {
existing, err := m.DB.GetDKIMKeyByDomainAndSelector(dom.ID, selector)
if err != nil {
return false, err
}
if existing != nil {
if _, err := m.DB.Exec(`UPDATE esrv_dkim_keys SET is_active = 1, replaced_at = NULL WHERE id = ?`, existing.ID); err != nil {
return false, err
}
return true, nil
}
}
priv, err := rsa.GenerateKey(rand.Reader, m.KeySize)
if err != nil {
return false, err
}
privPEM, pubPEM, err := encodeKeyPair(priv)
if err != nil {
return false, err
}
if _, err := m.DB.Exec(`INSERT INTO esrv_dkim_keys (domain_id, selector, private_key, public_key, is_active, created_at)
VALUES (?, ?, ?, ?, 1, ?)`, dom.ID, selector, privPEM, pubPEM, now); err != nil {
return false, err
}
return true, nil
}
func encodeKeyPair(priv *rsa.PrivateKey) (privPEM, pubPEM string, err error) {
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return "", "", err
}
privPEM = string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}))
pubBytes, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
return "", "", err
}
pubPEM = string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}))
return privPEM, pubPEM, nil
}
// GetActiveDKIMKey mirrors DKIMManager.get_active_dkim_key.
func (m *Manager) GetActiveDKIMKey(domainName string) (*db.DKIMKey, error) {
dom, err := m.DB.GetDomainByName(domainName)
if err != nil || dom == nil {
return nil, err
}
return m.DB.GetActiveDKIMKeyByDomainID(dom.ID)
}
// DNSRecord is the DNS TXT record for a domain's active DKIM key, mirroring
// DKIMManager.get_dkim_public_key_record's return shape.
type DNSRecord struct {
Name string
Type string
Value string
}
// GetDKIMPublicKeyRecord mirrors DKIMManager.get_dkim_public_key_record.
func (m *Manager) GetDKIMPublicKeyRecord(domainName string) (*DNSRecord, error) {
key, err := m.GetActiveDKIMKey(domainName)
if err != nil || key == nil {
return nil, err
}
raw := rawBase64FromPEM(key.PublicKey)
return &DNSRecord{
Name: fmt.Sprintf("%s._domainkey.%s", key.Selector, domainName),
Type: "TXT",
Value: fmt.Sprintf(`"v=DKIM1; k=rsa; p=%s"`, raw),
}, nil
}
func rawBase64FromPEM(pemStr string) string {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return ""
}
return base64.StdEncoding.EncodeToString(block.Bytes)
}
// Sign mirrors DKIMManager.sign_email: strips any existing DKIM-Signature header,
// signs over the fixed 8-header list with relaxed/relaxed canonicalization, and
// returns the original content unmodified on any failure (including "no active key").
func (m *Manager) Sign(content, domainName string) string {
key, err := m.GetActiveDKIMKey(domainName)
if err != nil || key == nil {
return content
}
block, _ := pem.Decode([]byte(key.PrivateKey))
if block == nil {
return content
}
privAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return content
}
priv, ok := privAny.(*rsa.PrivateKey)
if !ok {
return content
}
stripped := stripExistingSignature(content)
var out strings.Builder
err = msgdkim.Sign(&out, strings.NewReader(stripped), &msgdkim.SignOptions{
Domain: domainName,
Selector: key.Selector,
Signer: priv,
Hash: crypto.SHA256,
HeaderCanonicalization: msgdkim.CanonicalizationRelaxed,
BodyCanonicalization: msgdkim.CanonicalizationRelaxed,
HeaderKeys: FixedHeaders,
})
if err != nil {
return content
}
return out.String()
}
// stripExistingSignature removes a pre-existing DKIM-Signature header (including any
// folded continuation lines), mirroring the regex in dkim_manager.sign_email.
func stripExistingSignature(content string) string {
lines := strings.Split(content, "\n")
var out []string
skipping := false
for _, line := range lines {
lower := strings.ToLower(line)
if !skipping && strings.HasPrefix(lower, "dkim-signature:") {
skipping = true
continue
}
if skipping {
if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') {
continue // folded continuation line
}
skipping = false
}
out = append(out, line)
}
return strings.Join(out, "\n")
}
// GetActiveCustomHeaders mirrors DKIMManager.get_active_custom_headers.
func (m *Manager) GetActiveCustomHeaders(domainName string) ([][2]string, error) {
dom, err := m.DB.GetDomainByName(domainName)
if err != nil || dom == nil {
return nil, err
}
rows, err := m.DB.Query(`SELECT header_name, header_value FROM esrv_custom_headers WHERE domain_id = ? AND is_active = 1`, dom.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var out [][2]string
for rows.Next() {
var name, value string
if err := rows.Scan(&name, &value); err != nil {
return nil, err
}
out = append(out, [2]string{name, value})
}
return out, rows.Err()
}
+78
View File
@@ -0,0 +1,78 @@
package dkim
import (
"os"
"strings"
"testing"
msgdkim "github.com/emersion/go-msgauth/dkim"
"mailgoserver/internal/db"
)
func TestGenerateSignVerify(t *testing.T) {
f, err := os.CreateTemp("", "dkim-test-*.db")
if err != nil {
t.Fatal(err)
}
f.Close()
defer os.Remove(f.Name())
database, err := db.Open(f.Name())
if err != nil {
t.Fatal(err)
}
defer database.Close()
if _, err := database.Exec(`INSERT INTO esrv_domains (domain_name, is_active) VALUES ('example.com', 1)`); err != nil {
t.Fatal(err)
}
mgr := New(database, 1024) // small key for test speed
ok, err := mgr.GenerateDKIMKeypair("example.com", "sel1", false)
if err != nil || !ok {
t.Fatalf("GenerateDKIMKeypair: ok=%v err=%v", ok, err)
}
rec, err := mgr.GetDKIMPublicKeyRecord("example.com")
if err != nil || rec == nil {
t.Fatalf("GetDKIMPublicKeyRecord: %v %v", rec, err)
}
if rec.Name != "sel1._domainkey.example.com" || rec.Type != "TXT" || !strings.HasPrefix(rec.Value, `"v=DKIM1; k=rsa; p=`) {
t.Fatalf("unexpected DNS record: %+v", rec)
}
msg := "From: sender@example.com\r\nTo: rcpt@example.org\r\nSubject: hi\r\nDate: Mon, 01 Jan 2024 00:00:00 +0000\r\nMessage-ID: <abc@example.com>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nhello world\r\n"
signed := mgr.Sign(msg, "example.com")
if signed == msg {
t.Fatal("Sign did not add a signature")
}
if !strings.HasPrefix(signed, "DKIM-Signature:") {
t.Fatalf("expected DKIM-Signature as first header, got: %s", signed[:60])
}
// Verify against the key we just generated instead of a live DNS lookup
// (example.com has no real TXT record for our test selector).
verifications, err := msgdkim.VerifyWithOptions(strings.NewReader(signed), &msgdkim.VerifyOptions{
LookupTXT: func(domain string) ([]string, error) {
if domain != rec.Name {
t.Fatalf("unexpected TXT lookup domain: %s (want %s)", domain, rec.Name)
}
return []string{strings.Trim(rec.Value, `"`)}, nil
},
})
if err != nil {
t.Fatalf("Verify error: %v", err)
}
if len(verifications) != 1 {
t.Fatalf("expected 1 verification, got %d", len(verifications))
}
if verifications[0].Err != nil {
t.Fatalf("verification failed: %v", verifications[0].Err)
}
// Re-signing must strip the old signature, not stack two.
resigned := mgr.Sign(signed, "example.com")
if strings.Count(resigned, "DKIM-Signature:") != 1 {
t.Fatalf("expected exactly one DKIM-Signature after re-sign, got: %s", resigned)
}
}
+75
View File
@@ -0,0 +1,75 @@
package relay
import (
"mailgoserver/internal/db"
"mailgoserver/internal/toolbox"
"gopkg.in/ini.v1"
)
// LogEmail mirrors EmailRelay.log_email: computes the overall status from per-recipient
// results (relayed/partial/failed) and persists the EmailLog + EmailRecipientLog rows.
func (r *Relay) LogEmail(cfg *ini.File, peerIP, mailFrom, toAddress, ccAddresses, bccAddresses, subject, emailHeaders, messageBody, messageID, username string, dkimSigned bool, results []Result) (int64, error) {
overall := overallStatus(results)
logID, err := r.DB.InsertEmailLog(db.EmailLog{
MessageID: messageID,
Timestamp: toolbox.GetCurrentTime(cfg),
PeerIP: peerIP,
MailFrom: mailFrom,
ToAddress: toAddress,
CcAddresses: ccAddresses,
BccAddresses: bccAddresses,
Subject: subject,
EmailHeaders: emailHeaders,
MessageBody: messageBody,
Status: overall,
DKIMSigned: dkimSigned,
Username: username,
})
if err != nil {
return 0, err
}
for _, res := range results {
recipientType := res.RecipientType
if recipientType == "" {
recipientType = "to"
}
if err := r.DB.InsertEmailRecipientLog(db.EmailRecipientLog{
EmailLogID: logID,
Recipient: res.Recipient,
RecipientType: recipientType,
Status: res.Status,
ErrorCode: res.ErrorCode,
ErrorMessage: res.ErrorMessage,
ServerResponse: res.ServerResponse,
}); err != nil {
r.Logger.Error("Failed to log recipient %s: %v", res.Recipient, err)
}
}
return logID, nil
}
func overallStatus(results []Result) string {
if len(results) == 0 {
return "failed"
}
success, failed := 0, 0
for _, res := range results {
if res.Status == "success" {
success++
} else {
failed++
}
}
switch {
case success > 0 && failed > 0:
return "partial"
case success > 0:
return "relayed"
default:
return "failed"
}
}
+217
View File
@@ -0,0 +1,217 @@
// Package relay resolves MX records and delivers mail directly to recipient servers
// (no smart-host relay), mirroring email_server/email_relay.py.
package relay
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strconv"
"strings"
"time"
"gopkg.in/ini.v1"
"mailgoserver/internal/db"
"mailgoserver/internal/toolbox"
)
const mxPort = 25
// Result mirrors one entry of email_relay's per-recipient results list.
type Result struct {
Recipient string
RecipientType string // "to" | "cc" | "bcc"
Status string // "success" | "failed"
ErrorCode string
ErrorMessage string
ServerResponse string
}
type Relay struct {
DB *db.DB
Timeout time.Duration
// Hostname is used as the outbound EHLO/HELO identity, mirroring
// email_relay.py's self.hostname (helo_hostname, falling back to hostname).
Hostname string
Logger *toolbox.Logger
}
// New builds a Relay from settings.ini. Unlike email_relay.py (which reads
// relay_timeout from the wrong [Server] section and so always falls back to its
// hardcoded default of 30s), this reads the value from [Relay] as the config file's
// own comments say it should — the approved bug fix.
func New(database *db.DB, cfg *ini.File, logger *toolbox.Logger) *Relay {
timeoutSecs := cfg.Section("Relay").Key("RELAY_TIMEOUT").MustInt(30)
hostname := cfg.Section("Server").Key("helo_hostname").String()
if hostname == "" {
hostname = cfg.Section("Server").Key("HOSTNAME").MustString("localhost")
}
return &Relay{DB: database, Timeout: time.Duration(timeoutSecs) * time.Second, Hostname: hostname, Logger: logger}
}
// prepareEmailForRecipient mirrors email_relay._prepare_email_for_recipient: strips any
// Bcc header line from the header block only, leaves the body untouched.
func prepareEmailForRecipient(content string) string {
idx := strings.Index(content, "\r\n\r\n")
sep := "\r\n\r\n"
if idx < 0 {
idx = strings.Index(content, "\n\n")
sep = "\n\n"
if idx < 0 {
idx = len(content)
sep = "\r\n\r\n"
}
}
headerBlock, body := content[:idx], content[idx+len(sep):]
var kept []string
for _, line := range strings.Split(headerBlock, "\n") {
trimmed := strings.TrimRight(line, "\r")
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(trimmed)), "bcc:") {
continue
}
kept = append(kept, trimmed)
}
return strings.Join(kept, "\r\n") + "\r\n\r\n" + body
}
// RelayEmailAsync mirrors email_relay.relay_email_async: TO/CC recipients are grouped
// by domain and delivered in one shared SMTP transaction per domain; each BCC recipient
// gets its own transaction. MX hosts are tried once each, in preference order, with
// opportunistic STARTTLS.
func (r *Relay) RelayEmailAsync(mailFrom string, rcptTos []string, content string, recipientTypes []string) []Result {
if len(recipientTypes) != len(rcptTos) {
recipientTypes = make([]string, len(rcptTos))
for i := range recipientTypes {
recipientTypes[i] = "to"
}
}
type group struct{ to, cc []string }
domainGroups := map[string]*group{}
var bccList []string
for i, rcpt := range rcptTos {
typ := recipientTypes[i]
if typ == "bcc" {
bccList = append(bccList, rcpt)
continue
}
domain := domainOf(rcpt)
g, ok := domainGroups[domain]
if !ok {
g = &group{}
domainGroups[domain] = g
}
if typ == "cc" {
g.cc = append(g.cc, rcpt)
} else {
g.to = append(g.to, rcpt)
}
}
var results []Result
prepared := prepareEmailForRecipient(content)
for domain, g := range domainGroups {
all := append(append([]string{}, g.to...), g.cc...)
if len(all) == 0 {
continue
}
status, serverResp, errCode, errMsg := r.deliverToDomain(domain, mailFrom, all, prepared)
for _, rcpt := range g.to {
results = append(results, Result{Recipient: rcpt, RecipientType: "to", Status: status, ErrorCode: errCode, ErrorMessage: errMsg, ServerResponse: serverResp})
}
for _, rcpt := range g.cc {
results = append(results, Result{Recipient: rcpt, RecipientType: "cc", Status: status, ErrorCode: errCode, ErrorMessage: errMsg, ServerResponse: serverResp})
}
}
for _, bcc := range bccList {
status, serverResp, errCode, errMsg := r.deliverToDomain(domainOf(bcc), mailFrom, []string{bcc}, prepared)
results = append(results, Result{Recipient: bcc, RecipientType: "bcc", Status: status, ErrorCode: errCode, ErrorMessage: errMsg, ServerResponse: serverResp})
}
return results
}
func domainOf(address string) string {
if i := strings.LastIndex(address, "@"); i >= 0 {
return strings.ToLower(address[i+1:])
}
return ""
}
// deliverToDomain resolves MX hosts for domain and tries each in preference order once,
// mirroring the MX-iteration loop in relay_email_async.
func (r *Relay) deliverToDomain(domain, mailFrom string, rcpts []string, content string) (status, serverResponse, errorCode, errorMessage string) {
mxRecords, err := net.LookupMX(domain)
if err != nil || len(mxRecords) == 0 {
return "failed", "", "MX", fmt.Sprintf("MX lookup failed for %s: %v", domain, err)
}
var lastErr error
for _, mx := range mxRecords {
host := strings.TrimSuffix(mx.Host, ".")
resp, err := r.trySend(host, mailFrom, rcpts, content)
if err == nil {
return "success", resp, "", ""
}
lastErr = err
r.Logger.Warning("Relay to %s (%s) failed: %v", host, domain, err)
}
return "failed", "", "RELAY", fmt.Sprintf("%v", lastErr)
}
func (r *Relay) trySend(host, mailFrom string, rcpts []string, content string) (string, error) {
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(mxPort)), r.Timeout)
if err != nil {
return "", err
}
conn.SetDeadline(time.Now().Add(r.Timeout))
defer conn.Close()
c, err := smtp.NewClient(conn, host)
if err != nil {
return "", err
}
defer c.Close()
if err := c.Hello(r.Hostname); err != nil {
return "", err
}
// Opportunistic STARTTLS: upgrade if offered, send in plaintext otherwise —
// mirrors relay_email_async's "if starttls in extensions" check with no hard
// requirement, and no strict certificate verification since arbitrary receiving
// MTAs commonly present certs that don't chain cleanly (matches the Python code,
// which never configures certificate verification for this opportunistic hop).
if ok, _ := c.Extension("STARTTLS"); ok {
tlsConfig := &tls.Config{ServerName: host, InsecureSkipVerify: true}
if err := c.StartTLS(tlsConfig); err != nil {
return "", err
}
}
if err := c.Mail(mailFrom); err != nil {
return "", err
}
for _, rcpt := range rcpts {
if err := c.Rcpt(rcpt); err != nil {
return "", err
}
}
w, err := c.Data()
if err != nil {
return "", err
}
if _, err := w.Write([]byte(content)); err != nil {
return "", err
}
if err := w.Close(); err != nil {
return "", err
}
_ = c.Quit()
return "250 OK", nil
}
+109
View File
@@ -0,0 +1,109 @@
package smtpserver
import (
"bytes"
"io"
"mime"
"mime/multipart"
"net/mail"
"path/filepath"
"strings"
"time"
)
// attachmentStoragePath mirrors smtp_handler.get_attachment_storage_path:
// {base}/{safe_domain}/{username_or_ip}/{YYYY-DD-MMM}/
func attachmentStoragePath(base, domain, usernameOrIP string, now time.Time) string {
safeDomain := sanitizePathSegment(domain, "/\\")
dateFolder := now.Format("2006-02-Jan")
parts := []string{base, safeDomain}
if usernameOrIP != "" {
parts = append(parts, usernameOrIP)
}
parts = append(parts, dateFolder)
return filepath.Join(parts...)
}
func sanitizePathSegment(s string, chars string) string {
for _, c := range chars {
s = strings.ReplaceAll(s, string(c), "_")
}
return s
}
// cleanMessageIDPrefix strips everything from "@" onward, mirroring the
// clean_message_id computation used to build attachment filenames.
func cleanMessageIDPrefix(messageID string) string {
if i := strings.Index(messageID, "@"); i >= 0 {
return messageID[:i]
}
return messageID
}
type attachmentPart struct {
Filename string
ContentType string
Data []byte
}
type parsedMessage struct {
HeaderLines []string // "Name: value" per header, in order
BodyText string // concatenated text/* parts
Attachments []attachmentPart
}
// parseMessage mirrors the repeated BytesParser(policy=policy.default) passes in
// handle_DATA: it extracts header lines for logging, concatenated text body, and any
// attachment parts (Content-Disposition: attachment with a filename).
func parseMessage(raw []byte) (*parsedMessage, error) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, err
}
out := &parsedMessage{}
for k, vs := range msg.Header {
for _, v := range vs {
out.HeaderLines = append(out.HeaderLines, k+": "+v)
}
}
contentType := msg.Header.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = "text/plain"
}
if strings.HasPrefix(mediaType, "multipart/") {
mr := multipart.NewReader(msg.Body, params["boundary"])
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
break
}
data, _ := io.ReadAll(part)
disp, dispParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
partCT := part.Header.Get("Content-Type")
partMediaType, _, _ := mime.ParseMediaType(partCT)
if disp == "attachment" && dispParams["filename"] != "" {
out.Attachments = append(out.Attachments, attachmentPart{
Filename: dispParams["filename"],
ContentType: getContentType(partMediaType, dispParams["filename"]),
Data: data,
})
continue
}
if strings.HasPrefix(partMediaType, "text/") && disp != "attachment" {
out.BodyText += string(data) + "\n"
}
}
} else if strings.HasPrefix(mediaType, "text/") {
data, _ := io.ReadAll(msg.Body)
out.BodyText = string(data)
}
out.BodyText = strings.TrimSpace(out.BodyText)
return out, nil
}
+101
View File
@@ -0,0 +1,101 @@
package smtpserver
import (
"fmt"
"time"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"mailgoserver/internal/db"
)
// loginServer implements the LOGIN SASL mechanism server-side (go-sasl only ships the
// client half), mirroring the state machine aiosmtpd's built-in LOGIN handler drives:
// ask for username (unless an initial response already supplied it), then password.
type loginServer struct {
state int // 0: need username, 1: need password
username string
verify func(username, password string) error
}
func (s *loginServer) Next(response []byte) (challenge []byte, done bool, err error) {
switch s.state {
case 0:
if response == nil {
return []byte("Username:"), false, nil
}
s.username = string(response)
s.state = 1
return []byte("Password:"), false, nil
case 1:
password := string(response)
if err := s.verify(s.username, password); err != nil {
return nil, false, err
}
return nil, true, nil
default:
return nil, false, fmt.Errorf("unexpected LOGIN state")
}
}
// AuthMechanisms mirrors CustomSMTP._get_auth_methods's effective mechanism set
// (aiosmtpd's default LOGIN/PLAIN) once auth is allowed at all — the TLS-required gate
// itself is handled by go-smtp's own AllowInsecureAuth/isTLS check per listener.
func (s *Session) AuthMechanisms() []string {
return []string{sasl.Login, sasl.Plain}
}
// Auth mirrors EnhancedCombinedAuthenticator.__call__ for the LOGIN/PLAIN case (the
// only mechanisms advertised): credentials are always present by the time verify runs,
// so the "no auth_data supplied" fallback branch in the Python version is unreachable
// here and isn't replicated.
func (s *Session) Auth(mech string) (sasl.Server, error) {
switch mech {
case sasl.Login:
return &loginServer{verify: s.authenticate}, nil
case sasl.Plain:
return sasl.NewPlainServer(func(identity, username, password string) error {
return s.authenticate(username, password)
}), nil
default:
return nil, smtp.ErrAuthUnknownMechanism
}
}
// authenticate mirrors EnhancedAuthenticator.__call__: verifies credentials, logs an
// 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.
func (s *Session) authenticate(username, password string) error {
sender, err := s.backend.DB.GetSenderByEmail(username)
if err != nil {
s.backend.Logger.Error("Authentication error: %v", err)
_ = 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")
}
s.authenticatedSender = sender
s.authType = "sender"
s.username = username
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, true, "Successful sender authentication")
return nil
}
// failAuth builds the SMTPError for a failed AUTH attempt and closes the connection
// shortly after go-smtp writes this response, mirroring CustomSMTP.smtp_AUTH's
// transport.close() override. go-smtp writes the response synchronously right after
// this error is returned, so a short delay comfortably outlasts that write without
// needing to intercept the raw connection (which would break TLS detection on the
// implicit-TLS listener — see server.go).
func (s *Session) failAuth(code int, message string) error {
conn := s.conn
go func() {
time.Sleep(100 * time.Millisecond)
conn.Close()
}()
return &smtp.SMTPError{Code: code, EnhancedCode: smtp.NoEnhancedCode, Message: message}
}
+207
View File
@@ -0,0 +1,207 @@
package smtpserver
import (
"fmt"
"mime"
"net/mail"
"strings"
"time"
"mailgoserver/internal/toolbox"
)
// extractMessageID scans the raw content's header block for an existing Message-ID
// header and, if its hostname doesn't match heloHostname, rewrites it to use
// heloHostname — mirroring the pre-scan in smtp_handler.handle_DATA. Unlike the Python
// version, a missing "@" or missing header entirely is handled explicitly instead of
// crashing (the approved bug fix), by falling back to a freshly generated Message-ID.
func extractMessageID(content, heloHostname string) string {
for _, line := range strings.Split(content, "\n") {
line = strings.TrimRight(line, "\r")
if line == "" {
break // end of header block
}
lower := strings.ToLower(line)
if !strings.HasPrefix(lower, "message-id:") {
continue
}
value := strings.TrimSpace(line[len("message-id:"):])
value = strings.Trim(value, "<>")
at := strings.LastIndex(value, "@")
if at < 0 {
break // malformed header, no "@" — fall through to generating a fresh one
}
prefix, hostname := value[:at], value[at+1:]
if !strings.EqualFold(hostname, heloHostname) {
return fmt.Sprintf("%s@%s", prefix, heloHostname)
}
return value
}
return toolbox.GenerateMessageID(heloHostname)
}
// existingHeaders parses the raw header block into a lowercase-keyed map of the first
// value seen per header name, folding continuation lines, mirroring the case-insensitive
// existing-header lookups in _ensure_required_headers.
func existingHeaders(content string) map[string]string {
lines := strings.Split(content, "\n")
out := map[string]string{}
var lastKey string
for _, raw := range lines {
line := strings.TrimRight(raw, "\r")
if line == "" {
break
}
if (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && lastKey != "" {
out[lastKey] += " " + strings.TrimSpace(line)
continue
}
idx := strings.Index(line, ":")
if idx < 0 {
continue
}
key := strings.ToLower(strings.TrimSpace(line[:idx]))
val := strings.TrimSpace(line[idx+1:])
out[key] = val
lastKey = key
}
return out
}
// splitHeadersBody separates the header block from the body on the first blank line,
// accepting either CRLF or bare-LF line endings (source content is LF-only from the
// SMTP DATA decode; ensureRequiredHeaders' own output is CRLF).
func splitHeadersBody(content string) (headerBlock, body string) {
if idx := strings.Index(content, "\r\n\r\n"); idx >= 0 {
return content[:idx], content[idx+4:]
}
if idx := strings.Index(content, "\n\n"); idx >= 0 {
return content[:idx], content[idx+2:]
}
return content, ""
}
// ensureRequiredHeaders performs a full header-block *replacement* (not augmentation),
// mirroring smtp_handler._ensure_required_headers exactly: a fixed, ordered whitelist
// of headers is emitted, copying values from the original message where present and
// defaulting where absent; anything outside that whitelist is dropped, then the
// domain's custom headers plus X-Originating-IP/X-Mailer/X-Priority are appended (only
// if not already present under the same name).
func ensureRequiredHeaders(content, messageID string, envelopeRcptTos []string, mailFrom string, customHeaders [][2]string) string {
headerBlock, body := splitHeadersBody(content)
existing := existingHeaders(headerBlock)
var out []string
out = append(out, "Message-ID: <"+messageID+">")
if v, ok := existing["date"]; ok {
out = append(out, "Date: "+v)
} else {
out = append(out, "Date: "+time.Now().Format(time.RFC1123Z))
}
if v, ok := existing["mime-version"]; ok {
out = append(out, "MIME-Version: "+v)
} else {
out = append(out, "MIME-Version: 1.0")
}
if v, ok := existing["to"]; ok {
out = append(out, "To: "+v)
} else {
out = append(out, "To: "+strings.Join(envelopeRcptTos, ", "))
}
if v, ok := existing["cc"]; ok {
out = append(out, "Cc: "+v)
}
if v, ok := existing["from"]; ok {
out = append(out, "From: "+v)
} else {
out = append(out, "From: "+mailFrom)
}
if v, ok := existing["subject"]; ok {
out = append(out, "Subject: "+v)
} else {
out = append(out, "Subject: ")
}
if v, ok := existing["content-type"]; ok {
out = append(out, "Content-Type: "+v)
} else {
out = append(out, `Content-Type: text/plain; charset=UTF-8; format=flowed`)
}
if v, ok := existing["content-transfer-encoding"]; ok {
out = append(out, "Content-Transfer-Encoding: "+v)
} else {
out = append(out, "Content-Transfer-Encoding: 7bit")
}
for _, kv := range customHeaders {
if _, already := existing[strings.ToLower(kv[0])]; already {
continue
}
out = append(out, kv[0]+": "+kv[1])
}
return strings.Join(out, "\r\n") + "\r\n\r\n" + body
}
// getContentType mirrors smtp_handler.get_content_type: prefer the part's own type,
// fall back to extension sniffing, then a small fixed extension map.
func getContentType(partContentType, filename string) string {
if partContentType != "" && partContentType != "application/octet-stream" {
return partContentType
}
if guessed := mime.TypeByExtension(extOf(filename)); guessed != "" {
return guessed
}
switch strings.ToLower(extOf(filename)) {
case ".txt":
return "text/plain"
case ".csv":
return "text/csv"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".pdf":
return "application/pdf"
case ".json":
return "application/json"
case ".xml":
return "application/xml"
case ".html", ".htm":
return "text/html"
default:
return "application/octet-stream"
}
}
func extOf(filename string) string {
if i := strings.LastIndex(filename, "."); i >= 0 {
return filename[i:]
}
return ""
}
// parseAddressList mirrors the lowercase address parsing used to classify To/Cc/Bcc.
func parseAddressList(headerValue string) []string {
if strings.TrimSpace(headerValue) == "" {
return nil
}
addrs, err := mail.ParseAddressList(headerValue)
if err != nil {
return nil
}
out := make([]string, len(addrs))
for i, a := range addrs {
out[i] = strings.ToLower(a.Address)
}
return out
}
+67
View File
@@ -0,0 +1,67 @@
package smtpserver
import (
"strings"
"testing"
)
func TestEnsureRequiredHeadersFixedOrder(t *testing.T) {
raw := "Subject: hi\r\nX-Custom: drop-me\r\n\r\nbody text"
out := ensureRequiredHeaders(raw, "msg123@host", []string{"rcpt@example.com"}, "from@example.com", nil)
headerBlock, body := splitHeadersBody(out)
var names []string
for _, line := range strings.Split(headerBlock, "\r\n") {
if line == "" {
continue
}
names = append(names, strings.SplitN(line, ":", 2)[0])
}
want := []string{"Message-ID", "Date", "MIME-Version", "To", "From", "Subject", "Content-Type", "Content-Transfer-Encoding"}
if len(names) != len(want) {
t.Fatalf("header names = %v, want %v", names, want)
}
for i := range want {
if names[i] != want[i] {
t.Errorf("header[%d] = %q, want %q", i, names[i], want[i])
}
}
if strings.Contains(headerBlock, "X-Custom") {
t.Error("unwhitelisted header X-Custom should have been dropped, not carried through")
}
if !strings.Contains(headerBlock, "To: rcpt@example.com") {
t.Error("missing To header should be synthesized from envelope recipients")
}
if body != "body text" {
t.Errorf("body = %q, want %q", body, "body text")
}
}
func TestExtractMessageIDDoesNotCrashOnMalformedHeader(t *testing.T) {
// No "@" in the Message-ID value — the fixed bug: Python's original crashes
// here (UnboundLocalError); the Go port must fall back to a generated ID.
raw := "Message-ID: not-an-id\r\nSubject: x\r\n\r\nbody"
id := extractMessageID(raw, "mail.example.com")
if id == "" {
t.Fatal("expected a generated fallback Message-ID, got empty string")
}
if !strings.HasSuffix(id, "@mail.example.com") {
t.Errorf("fallback Message-ID = %q, want suffix @mail.example.com", id)
}
}
func TestExtractMessageIDRehostsOnHostnameMismatch(t *testing.T) {
raw := "Message-ID: <abc123@other-host.com>\r\nSubject: x\r\n\r\nbody"
id := extractMessageID(raw, "mail.example.com")
if id != "abc123@mail.example.com" {
t.Errorf("id = %q, want rehosted to mail.example.com", id)
}
}
func TestExtractMessageIDKeepsMatchingHostname(t *testing.T) {
raw := "Message-ID: <abc123@mail.example.com>\r\nSubject: x\r\n\r\nbody"
id := extractMessageID(raw, "mail.example.com")
if id != "abc123@mail.example.com" {
t.Errorf("id = %q, want unchanged", id)
}
}
+58
View File
@@ -0,0 +1,58 @@
package smtpserver
import (
"crypto/tls"
"strings"
"time"
"github.com/emersion/go-smtp"
"gopkg.in/ini.v1"
)
// ResolveBanner mirrors CustomSMTP's server_banner handling (the '""' literal-quotes
// convention for "explicitly empty"). go-smtp's greeting is always
// "220 <Domain> ESMTP Service Ready" with no hook to drop the " ESMTP Service Ready"
// suffix the way aiosmtpd's raw __ident__ override can — so when no custom banner is
// configured, this falls back to heloHostname (a normal, protocol-correct greeting)
// rather than Python's degenerate literally-empty banner. This is a disclosed, cosmetic
// interface deviation: no test tooling in this project inspects the SMTP banner text.
func ResolveBanner(cfg *ini.File, heloHostname string) string {
raw := cfg.Section("Server").Key("server_banner").String()
if raw == `""` {
raw = ""
}
raw = strings.TrimSpace(raw)
if raw == "" {
return heloHostname
}
return raw
}
// NewPlainServer mirrors server_runner.py's PlainController: no TLS context at all, so
// STARTTLS is never offered, and AUTH is advertised and usable in plaintext
// (auth_require_tls=False).
func NewPlainServer(backend *Backend, addr, banner string) *smtp.Server {
s := smtp.NewServer(backend)
s.Addr = addr
s.Domain = banner
s.AllowInsecureAuth = true
s.ReadTimeout = 5 * time.Minute
s.WriteTimeout = 5 * time.Minute
return s
}
// NewTLSServer mirrors server_runner.py's TLSController: implicit/direct TLS (like
// SMTPS on port 465) — the whole connection is encrypted from the first byte, not
// STARTTLS-negotiated. Call ListenAndServeTLS (not ListenAndServe) to run it.
func NewTLSServer(backend *Backend, addr, banner string, tlsConfig *tls.Config) *smtp.Server {
s := smtp.NewServer(backend)
s.Addr = addr
s.Domain = banner
s.TLSConfig = tlsConfig
// The session is always already TLS on this listener, so AUTH is always allowed
// either way (auth_require_tls=True in Python, which is trivially satisfied here).
s.AllowInsecureAuth = true
s.ReadTimeout = 5 * time.Minute
s.WriteTimeout = 5 * time.Minute
return s
}
+217
View File
@@ -0,0 +1,217 @@
package smtpserver
import (
"net"
"net/smtp"
"os"
"strings"
"testing"
"time"
"gopkg.in/ini.v1"
"mailgoserver/internal/db"
"mailgoserver/internal/dkim"
"mailgoserver/internal/relay"
"mailgoserver/internal/toolbox"
)
func newTestBackend(t *testing.T) *Backend {
t.Helper()
f, err := os.CreateTemp("", "smtp-test-*.db")
if err != nil {
t.Fatal(err)
}
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
database, err := db.Open(f.Name())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { database.Close() })
if _, err := database.Exec(`INSERT INTO esrv_domains (domain_name, is_active, is_verified) VALUES ('example.com', 1, 1)`); err != nil {
t.Fatal(err)
}
hash, err := db.HashPassword("testpass123")
if err != nil {
t.Fatal(err)
}
if _, err := database.Exec(`INSERT INTO esrv_senders (email, password_hash, domain_id, is_active) VALUES (?, ?, 1, 1)`, "test@example.com", hash); err != nil {
t.Fatal(err)
}
if _, err := database.Exec(`INSERT INTO esrv_whitelisted_ips (ip_address, domain_id, is_active) VALUES ('127.0.0.1', 1, 1)`); err != nil {
t.Fatal(err)
}
cfg := ini.Empty()
logger := toolbox.GetLogger("test")
return &Backend{
DB: database,
DKIM: dkim.New(database, 1024),
Relay: relay.New(database, cfg, logger),
Cfg: cfg,
Logger: logger,
HeloHostname: "mail.example.com",
AttachmentsBasePath: t.TempDir(),
}
}
func startTestServer(t *testing.T, backend *Backend) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := NewPlainServer(backend, l.Addr().String(), "mail.example.com")
go srv.Serve(l)
t.Cleanup(func() { srv.Close() })
return l.Addr().String()
}
func TestAuthSuccessAndSenderAuthorization(t *testing.T) {
backend := newTestBackend(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("expected auth success, got: %v", err)
}
if err := c.Mail("test@example.com"); err != nil {
t.Fatalf("expected MAIL FROM as own address to succeed, got: %v", err)
}
if err := c.Rcpt("someone@elsewhere.example"); err != nil {
t.Fatalf("expected RCPT to accept any address, got: %v", err)
}
}
func TestAuthFailureClosesConnection(t *testing.T) {
backend := newTestBackend(t)
addr := startTestServer(t, backend)
c, err := smtp.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer c.Close()
err = c.Auth(smtp.PlainAuth("", "test@example.com", "wrongpassword", "127.0.0.1"))
if err == nil {
t.Fatal("expected auth failure")
}
if !strings.Contains(err.Error(), "535") {
t.Fatalf("expected 535 response, got: %v", err)
}
// The server should close the connection shortly after — a subsequent command
// must fail rather than succeed.
time.Sleep(300 * time.Millisecond)
if err := c.Mail("test@example.com"); err == nil {
t.Fatal("expected connection to have been closed after failed AUTH")
}
}
func TestIPWhitelistFallbackWithoutAuth(t *testing.T) {
backend := newTestBackend(t)
addr := startTestServer(t, backend)
c, err := smtp.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer c.Close()
// No AUTH at all: MAIL FROM a domain whitelisted for our (loopback) peer IP.
if err := c.Mail("anyone@example.com"); err != nil {
t.Fatalf("expected IP-whitelist fallback to authorize, got: %v", err)
}
if err := c.Rcpt("rcpt@elsewhere.example"); err != nil {
t.Fatalf("expected RCPT to accept, got: %v", err)
}
}
func TestMailFromRejectedForUnauthorizedDomain(t *testing.T) {
backend := newTestBackend(t)
addr := startTestServer(t, backend)
c, err := smtp.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer c.Close()
err = c.Mail("nobody@not-whitelisted.example")
if err == nil {
t.Fatal("expected MAIL FROM to be rejected for a non-whitelisted, non-authenticated domain")
}
if !strings.Contains(err.Error(), "550") {
t.Fatalf("expected 550 response, got: %v", err)
}
}
func TestUnverifiedDomainCannotSend(t *testing.T) {
backend := newTestBackend(t)
domainID, err := backend.DB.CreateDomain("unverified.example")
if err != nil {
t.Fatal(err)
}
hash, _ := db.HashPassword("testpass123")
if _, err := backend.DB.CreateSender("sender@unverified.example", hash, domainID, false, false); 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("", "sender@unverified.example", "testpass123", "127.0.0.1")); err != nil {
t.Fatalf("auth: %v", err)
}
err = c.Mail("sender@unverified.example")
if err == nil {
t.Fatal("expected MAIL FROM to be rejected for an unverified domain, even for an authenticated sender")
}
if !strings.Contains(err.Error(), "550") || !strings.Contains(err.Error(), "verif") {
t.Fatalf("expected a 550 mentioning verification, got: %v", err)
}
// Now verify the domain directly (bypassing DNS) and confirm sending is unblocked.
if err := backend.DB.SetDomainVerified(domainID, true); err != nil {
t.Fatal(err)
}
if err := c.Mail("sender@unverified.example"); err != nil {
t.Fatalf("expected MAIL FROM to succeed once domain is verified, got: %v", err)
}
}
func TestSenderCannotSpoofOtherAddress(t *testing.T) {
backend := newTestBackend(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)
}
err = c.Mail("someoneelse@example.com")
if err == nil {
t.Fatal("expected MAIL FROM spoofing another address to be rejected (can_send_as_domain is false)")
}
if !strings.Contains(err.Error(), "550") {
t.Fatalf("expected 550 response, got: %v", err)
}
}
+276
View File
@@ -0,0 +1,276 @@
package smtpserver
import (
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"time"
"github.com/emersion/go-smtp"
"gopkg.in/ini.v1"
"mailgoserver/internal/db"
"mailgoserver/internal/dkim"
"mailgoserver/internal/relay"
"mailgoserver/internal/toolbox"
)
// Backend holds the shared dependencies every connection's Session uses, mirroring the
// constructor args threaded through smtp_handler.EnhancedCustomSMTPHandler /
// email_server/server_runner.py.
type Backend struct {
DB *db.DB
DKIM *dkim.Manager
Relay *relay.Relay
Cfg *ini.File
Logger *toolbox.Logger
HeloHostname string
AttachmentsBasePath string
}
func (b *Backend) NewSession(c *smtp.Conn) (smtp.Session, error) {
host, _, _ := net.SplitHostPort(c.Conn().RemoteAddr().String())
if host == "" {
host = c.Conn().RemoteAddr().String()
}
return &Session{backend: b, conn: c, peerIP: host}, nil
}
// Session implements smtp.Session + smtp.AuthSession for one SMTP connection, mirroring
// EnhancedCustomSMTPHandler's per-connection behavior in smtp_handler.py.
type Session struct {
backend *Backend
conn *smtp.Conn
peerIP string
authenticatedSender *db.Sender
authType string // "sender" | "ip" | ""
authorizedDomain string
username string
mailFrom string
rcptTos []string
}
func (s *Session) Reset() {
s.mailFrom = ""
s.rcptTos = nil
}
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 {
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: message}
}
s.mailFrom = from
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) {
if mailFrom == "" {
return false, "No sender address provided"
}
fromDomain := domainOfAddr(mailFrom)
if fromDomain == "" {
return 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)
}
if dom == nil {
return 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)
}
if s.authenticatedSender != nil {
sender := s.authenticatedSender
if sender.CanSendAs(mailFrom) {
return 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)
}
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)
}
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)
}
_ = 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)
}
func domainOfAddr(address string) string {
i := strings.LastIndex(address, "@")
if i < 0 {
return ""
}
return strings.ToLower(address[i+1:])
}
// Rcpt mirrors handle_RCPT: accepts any address, no validation.
func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
s.rcptTos = append(s.rcptTos, to)
return nil
}
func internalError(msg string) error {
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: msg}
}
// Data mirrors EnhancedCustomSMTPHandler.handle_DATA end to end: Message-ID
// extraction/rehost, full header rebuild, DKIM signing, attachment extraction/storage,
// relay delivery, and EmailLog/EmailRecipientLog/EmailAttachment persistence.
func (s *Session) Data(r io.Reader) error {
raw, err := io.ReadAll(r)
if err != nil {
return internalError("Internal server error")
}
content := string(raw)
messageID := extractMessageID(content, s.backend.HeloHostname)
senderDomain := domainOfAddr(s.mailFrom)
var customHeaders [][2]string
if senderDomain != "" {
customHeaders, _ = s.backend.DKIM.GetActiveCustomHeaders(senderDomain)
}
customHeaders = append(customHeaders,
[2]string{"X-Originating-IP", "[" + s.peerIP + "]"},
[2]string{"X-Mailer", "NetBro Mail Server 1.0"},
[2]string{"X-Priority", "3"},
)
rebuilt := ensureRequiredHeaders(content, messageID, s.rcptTos, s.mailFrom, customHeaders)
signedContent := rebuilt
dkimSigned := false
if senderDomain != "" {
signedContent = s.backend.DKIM.Sign(rebuilt, senderDomain)
dkimSigned = signedContent != rebuilt
}
rebuiltHeaders := existingHeaders(rebuilt)
toHeader := rebuiltHeaders["to"]
ccHeader := rebuiltHeaders["cc"]
subject := rebuiltHeaders["subject"]
// Attachment storage: only if the authenticated sender or whitelisted IP opted in.
storeMessage := false
if sender, _ := s.backend.DB.GetSenderByEmail(s.mailFrom); sender != nil && sender.StoreMessageContent {
storeMessage = true
} else if wl, _ := s.backend.DB.GetWhitelistedIP(s.peerIP, senderDomain); wl != nil && wl.StoreMessageContent {
storeMessage = true
}
parsed, parseErr := parseMessage(raw)
type savedAttachment struct {
Filename, ContentType, FilePath string
Size int64
}
var toSave []savedAttachment
if storeMessage && parseErr == nil && len(parsed.Attachments) > 0 {
usernameOrIP := s.username
if usernameOrIP == "" && s.peerIP != "" {
usernameOrIP = sanitizePathSegment(s.peerIP, ":")
} else {
usernameOrIP = sanitizePathSegment(usernameOrIP, "/\\")
}
storagePath := attachmentStoragePath(s.backend.AttachmentsBasePath, senderDomain, usernameOrIP, time.Now())
if err := os.MkdirAll(storagePath, 0o755); err == nil {
prefix := cleanMessageIDPrefix(messageID)
for _, a := range parsed.Attachments {
filename := prefix + "_" + a.Filename
fullPath := filepath.Join(storagePath, filename)
if err := os.WriteFile(fullPath, a.Data, 0o644); err == nil {
toSave = append(toSave, savedAttachment{Filename: a.Filename, ContentType: a.ContentType, FilePath: fullPath, Size: int64(len(a.Data))})
} else {
s.backend.Logger.Error("Failed to write attachment %s: %v", filename, err)
}
}
}
}
// Classify each envelope recipient as to/cc/bcc by presence in the To/Cc headers —
// anything not literally present in either is inferred BCC.
toList := parseAddressList(toHeader)
ccList := parseAddressList(ccHeader)
recipientTypes := make([]string, len(s.rcptTos))
for i, rcpt := range s.rcptTos {
lower := strings.ToLower(rcpt)
switch {
case containsStr(toList, lower):
recipientTypes[i] = "to"
case containsStr(ccList, lower):
recipientTypes[i] = "cc"
default:
recipientTypes[i] = "bcc"
}
}
results := s.backend.Relay.RelayEmailAsync(s.mailFrom, s.rcptTos, signedContent, recipientTypes)
allSucceeded := len(results) > 0
for _, res := range results {
if res.Status != "success" {
allSucceeded = false
}
}
var emailHeaders, messageBody string
if parseErr == nil {
emailHeaders = strings.Join(parsed.HeaderLines, "\n")
messageBody = parsed.BodyText
}
logID, logErr := s.backend.Relay.LogEmail(s.backend.Cfg, s.peerIP, s.mailFrom, toHeader, ccHeader, "", subject, emailHeaders, messageBody, messageID, s.username, dkimSigned, results)
if logErr != nil {
s.backend.Logger.Error("Failed to log email: %v", logErr)
} else {
for _, a := range toSave {
if err := s.backend.DB.InsertEmailAttachment(db.EmailAttachment{
EmailLogID: logID, Filename: a.Filename, ContentType: a.ContentType, FilePath: a.FilePath, Size: a.Size,
}); err != nil {
s.backend.Logger.Error("Failed to record attachment %s: %v", a.Filename, err)
}
}
}
if allSucceeded {
return &smtp.SMTPError{Code: 250, EnhancedCode: smtp.NoEnhancedCode, Message: "Message accepted for delivery"}
}
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "Message relay failed"}
}
func containsStr(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
+94
View File
@@ -0,0 +1,94 @@
// Package tlsutil generates the self-signed certificate used by the direct-TLS SMTP
// listener and builds its tls.Config, mirroring email_server/tls_utils.py.
package tlsutil
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"time"
)
// GenerateSelfSignedCert mirrors tls_utils.generate_self_signed_cert: skips generation
// if both files already exist; otherwise writes an RSA-2048/SHA-256, 1-year-valid,
// self-signed cert with the same subject fields as the Python version.
func GenerateSelfSignedCert(certFile, keyFile string) error {
if _, err := os.Stat(certFile); err == nil {
if _, err := os.Stat(keyFile); err == nil {
return nil
}
}
if err := os.MkdirAll(filepath.Dir(certFile), 0o755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(keyFile), 0o755); err != nil {
return err
}
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
subject := pkix.Name{
CommonName: "localhost",
Organization: []string{"PyMTA Server"},
Country: []string{"GB"},
}
template := x509.Certificate{
SerialNumber: big.NewInt(1000),
Subject: subject,
Issuer: subject,
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
SignatureAlgorithm: x509.SHA256WithRSA,
BasicConstraintsValid: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return err
}
certOut, err := os.Create(certFile)
if err != nil {
return err
}
defer certOut.Close()
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil {
return err
}
keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
defer keyOut.Close()
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return err
}
return pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
}
// CreateSSLContext mirrors tls_utils.create_ssl_context: loads the cert/key pair and
// pins MinVersion to TLS 1.2 (Python's ssl.create_default_context leaves this to the
// environment's OpenSSL defaults, which is typically TLS 1.2+ on modern systems —
// pinning it explicitly here is the closest deterministic equivalent). Cipher suites
// are left at Go's own secure defaults, matching the Python code's "DEFAULT" relaxation.
func CreateSSLContext(certFile, keyFile string) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, err
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}, nil
}
+134
View File
@@ -0,0 +1,134 @@
// Package toolbox provides small shared helpers, mirroring email_server/tool_box.py:
// logging, the configured-timezone clock, and Message-ID generation.
package toolbox
import (
"crypto/rand"
"fmt"
"log"
"math/big"
"os"
"strings"
"time"
"gopkg.in/ini.v1"
)
// Logger is a tiny leveled logger matching the Python format:
// "%(asctime)s - %(name)s - %(levelname)s - %(message)s".
type Logger struct {
name string
level Level
out *log.Logger
}
type Level int
const (
LevelDebug Level = iota
LevelInfo
LevelWarning
LevelError
LevelCritical
)
func parseLevel(s string) Level {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "DEBUG":
return LevelDebug
case "WARNING", "WARN":
return LevelWarning
case "ERROR":
return LevelError
case "CRITICAL":
return LevelCritical
default:
return LevelInfo
}
}
func (l Level) String() string {
switch l {
case LevelDebug:
return "DEBUG"
case LevelWarning:
return "WARNING"
case LevelError:
return "ERROR"
case LevelCritical:
return "CRITICAL"
default:
return "INFO"
}
}
var globalLevel = LevelInfo
// Configure sets the process-wide log level from settings.ini's [Logging] section,
// mirroring tool_box.setup_logging.
func Configure(cfg *ini.File) {
section := cfg.Section("Logging")
globalLevel = parseLevel(section.Key("LOG_LEVEL").MustString("INFO"))
}
// GetLogger returns a Logger for the given component name, mirroring tool_box.get_logger.
// Python derives the name from the caller's filename when omitted; Go callers pass it
// explicitly instead, since introspecting the caller module isn't idiomatic here.
func GetLogger(name string) *Logger {
return &Logger{name: name, level: globalLevel, out: log.New(os.Stderr, "", 0)}
}
func (l *Logger) log(level Level, format string, args ...any) {
if level < globalLevel {
return
}
msg := fmt.Sprintf(format, args...)
ts := time.Now().Format("2006-01-02 15:04:05,000")
l.out.Printf("%s - %s - %s - %s", ts, l.name, level, msg)
}
func (l *Logger) Debug(format string, args ...any) { l.log(LevelDebug, format, args...) }
func (l *Logger) Info(format string, args ...any) { l.log(LevelInfo, format, args...) }
func (l *Logger) Warning(format string, args ...any) { l.log(LevelWarning, format, args...) }
func (l *Logger) Error(format string, args ...any) { l.log(LevelError, format, args...) }
func (l *Logger) Critical(format string, args ...any) { l.log(LevelCritical, format, args...) }
// EnsureFolderExists creates the parent directory of filepath (a file path, not a
// directory path), mirroring tool_box.ensure_folder_exists including its handling of
// "sqlite:///" prefixed database URLs.
func EnsureFolderExists(path string) error {
path = strings.TrimPrefix(path, "sqlite:///")
dir := path
if idx := strings.LastIndexAny(path, "/\\"); idx >= 0 {
dir = path[:idx]
} else {
return nil
}
if dir == "" {
return nil
}
return os.MkdirAll(dir, 0o755)
}
// GetCurrentTime returns the current time in the configured server timezone, mirroring
// tool_box.get_current_time. Falls back to UTC if the configured zone can't be loaded.
func GetCurrentTime(cfg *ini.File) time.Time {
tzName := cfg.Section("Server").Key("time_zone").MustString("UTC")
loc, err := time.LoadLocation(tzName)
if err != nil {
loc = time.UTC
}
return time.Now().In(loc)
}
// GenerateMessageID builds a Message-ID local-part@hostname string, mirroring
// tool_box.generate_message_id: system wall-clock time (not the configured timezone)
// plus a 6-digit random suffix.
func GenerateMessageID(hostname string) string {
digits := make([]byte, 6)
for i := range digits {
n, _ := rand.Int(rand.Reader, big.NewInt(10))
digits[i] = byte('0' + n.Int64())
}
return fmt.Sprintf("%s.%s@%s", time.Now().Format("20060102150405"), string(digits), hostname)
}
+118
View File
@@ -0,0 +1,118 @@
package webui
import (
"bytes"
"encoding/base64"
"image/png"
"net/http"
"strings"
"github.com/pquerna/otp/totp"
"mailgoserver/internal/db"
)
// accountPage shows the admin their own profile: password change, TOTP MFA
// enable/disable, and registered passkeys (passkey registration itself is wired up
// in webauthn.go).
func (a *App) accountPage(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r)
creds, _ := a.DB.ListWebAuthnCredentials(user.ID)
a.render(w, r, "account.html", M{"active": "account", "user": user, "passkeys": creds})
}
// changePassword mirrors a normal (not forced) password change from account settings.
func (a *App) changePassword(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r)
current := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirm := r.FormValue("new_password_confirm")
if !db.CheckPassword(current, user.PasswordHash) {
setFlash(w, "error", "Current password is incorrect")
http.Redirect(w, r, Prefix+"/account", 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, Prefix+"/account", http.StatusFound)
return
}
if newPassword != confirm {
setFlash(w, "error", "New passwords don't match")
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
return
}
hash, err := db.HashPassword(newPassword)
if err != nil {
setFlash(w, "error", "Something went wrong")
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
return
}
if err := a.DB.UpdateAdminPassword(user.ID, hash); err != nil {
setFlash(w, "error", "Something went wrong")
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
return
}
setFlash(w, "success", "Password updated")
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
}
// totpSetupBegin generates a fresh (not-yet-enabled) TOTP secret and shows it as a
// scannable QR code (rendered inline as a data: URI — simplest way to hand the
// browser an image without a second round-trip route) plus the manual entry key.
func (a *App) totpSetupBegin(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r)
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "mailgoserver",
AccountName: user.Username,
})
if err != nil {
setFlash(w, "error", "Could not generate a TOTP secret")
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
return
}
if err := a.DB.SetAdminTOTPSecret(user.ID, key.Secret(), false); err != nil {
setFlash(w, "error", "Could not save the TOTP secret")
http.Redirect(w, r, Prefix+"/account", 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, "totp_setup.html", M{"secret": key.Secret(), "qr_data_uri": qrDataURI})
}
// totpSetupConfirm verifies a code against the pending secret and, if correct, flips
// TOTP on for the account.
func (a *App) totpSetupConfirm(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r)
code := strings.TrimSpace(r.FormValue("code"))
if user.TOTPSecret == "" || !totp.Validate(code, user.TOTPSecret) {
setFlash(w, "error", "That code didn't match — try scanning the QR code again")
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
return
}
if err := a.DB.SetAdminTOTPSecret(user.ID, user.TOTPSecret, true); err != nil {
setFlash(w, "error", "Something went wrong enabling MFA")
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
return
}
setFlash(w, "success", "Authenticator app MFA enabled")
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
}
func (a *App) totpDisable(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r)
if err := a.DB.DisableAdminTOTP(user.ID); err != nil {
setFlash(w, "error", "Something went wrong")
} else {
setFlash(w, "success", "Authenticator app MFA disabled")
}
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
}
+250
View File
@@ -0,0 +1,250 @@
package webui
import (
"errors"
"net/http"
"strconv"
"strings"
"mailgoserver/internal/db"
)
var errNotOwned = errors.New("domain not in the requesting admin's scope")
// manageableAdmins mirrors the delegation rule: a global admin manages everyone; a
// scoped admin manages any other scoped admin whose entire domain assignment is a
// subset of their own (not just admins they personally created) — see the approved
// design in the conversation this shipped from.
func (a *App) manageableAdmins(r *http.Request) ([]db.AdminUser, error) {
user := userFromContext(r)
if user.IsGlobalAdmin {
return a.DB.ListAllAdminUsers()
}
scope := scopeFromContext(r)
scoped, err := a.DB.ListScopedAdminUsers()
if err != nil {
return nil, err
}
var out []db.AdminUser
for _, other := range scoped {
if other.ID == user.ID {
continue
}
theirDomains, err := a.DB.AccessibleDomainIDs(other.ID)
if err != nil {
return nil, err
}
if isDomainSubset(theirDomains, scope) {
out = append(out, other)
}
}
return out, nil
}
func isDomainSubset(ids []int64, scope accessScope) bool {
for _, id := range ids {
if !scope.Allowed(id) {
return false
}
}
return true
}
// canManageAdmin re-checks a specific target admin against the current admin's
// delegation rights — used by the mutating routes so they don't just trust whatever
// the list page happened to render.
func (a *App) canManageAdmin(r *http.Request, target *db.AdminUser) (bool, error) {
user := userFromContext(r)
if target.ID == user.ID {
return false, nil
}
if user.IsGlobalAdmin {
return true, nil
}
if target.IsGlobalAdmin {
return false, nil
}
theirDomains, err := a.DB.AccessibleDomainIDs(target.ID)
if err != nil {
return false, err
}
return isDomainSubset(theirDomains, scopeFromContext(r)), nil
}
func (a *App) adminsList(w http.ResponseWriter, r *http.Request) {
admins, err := a.manageableAdmins(r)
if err != nil {
setFlash(w, "error", "Error loading admins")
}
var rows []M
for _, u := range admins {
domainIDs, _ := a.DB.AccessibleDomainIDs(u.ID)
var domainNames []string
for _, id := range domainIDs {
if dom, _ := a.DB.GetDomainByID(id); dom != nil {
domainNames = append(domainNames, dom.DomainName)
}
}
rows = append(rows, M{"user": u, "domain_names": domainNames})
}
a.render(w, r, "admins.html", M{"active": "admins", "rows": rows})
}
func (a *App) addAdminForm(w http.ResponseWriter, r *http.Request) {
domains, _ := a.accessibleDomains(r)
a.render(w, r, "add_admin.html", M{"active": "admins", "domains": domains, "can_grant_global": userFromContext(r).IsGlobalAdmin})
}
// addAdmin mirrors the delegation flow: creates a new scoped admin (or, for a global
// admin, optionally a new global admin), forced to change their password on first
// login exactly like the seeded default account.
func (a *App) addAdmin(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r)
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
makeGlobal := user.IsGlobalAdmin && r.FormValue("is_global_admin") == "on"
if username == "" || !isStrongPassword(password) {
setFlash(w, "error", "Username is required and password must be at least 10 characters with a letter, a number, and a symbol")
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
return
}
if existing, _ := a.DB.GetAdminUserByUsername(username); existing != nil {
setFlash(w, "error", "That username is already taken")
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
return
}
hash, err := db.HashPassword(password)
if err != nil {
setFlash(w, "error", "Something went wrong")
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
return
}
if makeGlobal {
if _, err := a.DB.CreateAdminUser(username, hash, true); err != nil {
setFlash(w, "error", "Error creating admin")
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
return
}
setFlash(w, "success", "Global admin created")
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
return
}
domainIDs, err := a.parseOwnedDomainIDs(r)
if err != nil {
setFlash(w, "error", "You can only assign domains you manage yourself")
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
return
}
if _, err := a.DB.CreateScopedAdminUser(username, hash, user.ID, domainIDs); err != nil {
setFlash(w, "error", "Error creating admin")
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
return
}
setFlash(w, "success", "Admin created and given access to the selected domains")
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
}
// parseOwnedDomainIDs reads the "domain_ids" checkbox list from the form and rejects
// the request outright if any of them fall outside the current admin's own scope —
// the actual enforcement point for "can only delegate domains you have yourself".
func (a *App) parseOwnedDomainIDs(r *http.Request) ([]int64, error) {
scope := scopeFromContext(r)
var ids []int64
for _, v := range r.Form["domain_ids"] {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil {
continue
}
if !scope.Allowed(id) {
return nil, errNotOwned
}
ids = append(ids, id)
}
return ids, nil
}
func (a *App) editAdminDomainsForm(w http.ResponseWriter, r *http.Request) {
target, ok := a.adminWithManageAccess(w, r)
if !ok {
return
}
domains, _ := a.accessibleDomains(r)
assigned, _ := a.DB.AccessibleDomainIDs(target.ID)
assignedSet := make(map[int64]bool, len(assigned))
for _, id := range assigned {
assignedSet[id] = true
}
a.render(w, r, "edit_admin.html", M{"active": "admins", "target": target, "domains": domains, "assigned": assignedSet})
}
func (a *App) editAdminDomains(w http.ResponseWriter, r *http.Request) {
target, ok := a.adminWithManageAccess(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
setFlash(w, "error", "Invalid form data")
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
return
}
domainIDs, err := a.parseOwnedDomainIDs(r)
if err != nil {
setFlash(w, "error", "You can only assign domains you manage yourself")
http.Redirect(w, r, Prefix+"/admins/"+strconv.FormatInt(target.ID, 10)+"/edit", http.StatusFound)
return
}
if err := a.DB.SetAdminDomainAccess(target.ID, domainIDs); err != nil {
setFlash(w, "error", "Error updating domain access")
} else {
setFlash(w, "success", "Domain access updated")
}
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
}
// adminWithManageAccess fetches the target admin by path ID and re-validates the
// delegation rule server-side (never trust that the list page's filtering was the
// only gate).
func (a *App) adminWithManageAccess(w http.ResponseWriter, r *http.Request) (*db.AdminUser, bool) {
target, err := a.DB.GetAdminUserByID(pathID(r))
if err != nil || target == nil {
http.NotFound(w, r)
return nil, false
}
allowed, err := a.canManageAdmin(r, target)
if err != nil || !allowed {
http.NotFound(w, r)
return nil, false
}
return target, true
}
func (a *App) removeAdmin(w http.ResponseWriter, r *http.Request) {
target, ok := a.adminWithManageAccess(w, r)
if !ok {
return
}
if target.IsGlobalAdmin {
if all, err := a.DB.ListAllAdminUsers(); err == nil {
remaining := 0
for _, u := range all {
if u.IsGlobalAdmin {
remaining++
}
}
if remaining <= 1 {
setFlash(w, "error", "Can't remove the last global admin")
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
return
}
}
}
if err := a.DB.DeleteAdminUser(target.ID); err != nil {
setFlash(w, "error", "Error removing admin")
} else {
setFlash(w, "success", "Admin removed")
}
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
}
+168
View File
@@ -0,0 +1,168 @@
package webui
import (
"context"
"net/http"
"time"
"mailgoserver/internal/db"
)
const (
sessionCookieName = "mailgoserver_session"
sessionTTL = 7 * 24 * time.Hour
)
type ctxKey int
const (
ctxUserKey ctxKey = iota
ctxScopeKey
)
// accessScope is which domains the current admin can see/manage. A global admin
// bypasses the domain-ID check entirely; a scoped admin is restricted to exactly the
// domains in DomainIDs — computed once per request in requireAuth and reused by every
// handler via scopeFromContext, rather than re-querying esrv_admin_domain_access
// repeatedly within the same request.
type accessScope struct {
Global bool
DomainIDs map[int64]bool
}
func (s accessScope) Allowed(domainID int64) bool {
return s.Global || s.DomainIDs[domainID]
}
// IDs returns the accessible domain IDs as a slice — nil (not empty) for a global
// admin, since "nil" is the signal callers should treat as "no filter" rather than
// "empty set" when building an IN (...) clause or similar.
func (s accessScope) IDs() []int64 {
if s.Global {
return nil
}
ids := make([]int64, 0, len(s.DomainIDs))
for id := range s.DomainIDs {
ids = append(ids, id)
}
return ids
}
func scopeFromContext(r *http.Request) accessScope {
s, _ := r.Context().Value(ctxScopeKey).(accessScope)
return s
}
// requireDomainAccess checks the current admin's scope covers domainID; if not, it
// writes a 404 (not 403 — a scoped admin shouldn't be able to distinguish "doesn't
// exist" from "exists but isn't mine" by probing IDs) and returns false, matching the
// existing "not found" handling every route already does for a missing resource.
func requireDomainAccess(w http.ResponseWriter, r *http.Request, domainID int64) bool {
if scopeFromContext(r).Allowed(domainID) {
return true
}
http.NotFound(w, r)
return false
}
func (a *App) buildAccessScope(user *db.AdminUser) (accessScope, error) {
if user.IsGlobalAdmin {
return accessScope{Global: true}, nil
}
ids, err := a.DB.AccessibleDomainIDs(user.ID)
if err != nil {
return accessScope{}, err
}
m := make(map[int64]bool, len(ids))
for _, id := range ids {
m[id] = true
}
return accessScope{DomainIDs: m}, nil
}
func setSessionCookie(w http.ResponseWriter, token string, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionTTL.Seconds()),
})
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1})
}
// currentSession loads the session + user for the request's cookie, if any and valid
// (exists, not expired). A nil session/user (no error) means "not logged in".
func (a *App) currentSession(r *http.Request) (*db.AdminSession, *db.AdminUser, error) {
c, err := r.Cookie(sessionCookieName)
if err != nil || c.Value == "" {
return nil, nil, nil
}
sess, err := a.DB.GetSession(c.Value)
if err != nil || sess == nil {
return nil, nil, err
}
if time.Now().After(sess.ExpiresAt) {
_ = a.DB.DeleteSession(sess.Token)
return nil, nil, nil
}
user, err := a.DB.GetAdminUserByID(sess.UserID)
if err != nil || user == nil {
return nil, nil, err
}
return sess, user, nil
}
func userFromContext(r *http.Request) *db.AdminUser {
u, _ := r.Context().Value(ctxUserKey).(*db.AdminUser)
return u
}
// requireAuth gates every admin route behind a valid, fully-authenticated session:
// logged in, second factor satisfied if one is enabled, and not stuck in the forced
// first-login credential change. Unauthenticated/incomplete requests are redirected
// to the right step of the login flow rather than shown an error.
func (a *App) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, user, err := a.currentSession(r)
if err != nil {
a.Logger.Error("session lookup: %v", err)
}
if sess == nil || user == nil {
http.Redirect(w, r, Prefix+"/login?next="+r.URL.Path, http.StatusFound)
return
}
needsMFA := user.TOTPEnabled
if !needsMFA {
if n, _ := a.DB.CountWebAuthnCredentials(user.ID); n > 0 {
needsMFA = true
}
}
if needsMFA && !sess.MFAVerified {
http.Redirect(w, r, Prefix+"/login/mfa", http.StatusFound)
return
}
if user.MustChangePassword && r.URL.Path != Prefix+"/first-login" {
http.Redirect(w, r, Prefix+"/first-login", http.StatusFound)
return
}
scope, err := a.buildAccessScope(user)
if err != nil {
a.Logger.Error("build access scope: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
ctx := context.WithValue(r.Context(), ctxUserKey, user)
ctx = context.WithValue(ctx, ctxScopeKey, scope)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
+103
View File
@@ -0,0 +1,103 @@
package webui
import (
"net/http"
"strings"
"mailgoserver/internal/db"
)
func emailDomain(addr string) string {
if i := strings.LastIndex(addr, "@"); i >= 0 {
return strings.ToLower(addr[i+1:])
}
return ""
}
// dashboard mirrors dashboard.py's dashboard(), scoped to the current admin's
// assigned domains unless they're a global admin.
func (a *App) dashboard(w http.ResponseWriter, r *http.Request) {
scope := scopeFromContext(r)
allowedNames, isGlobal, err := a.accessibleDomainNames(r)
if err != nil {
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++
}
}
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++
}
}
}
allEmails, err := a.DB.ListEmailLogsPage(0, 50)
if err != nil {
setFlash(w, "error", "Error loading recent activity")
}
var recentEmails []db.EmailLog
for _, e := range allEmails {
if isGlobal || allowedNames[emailDomain(e.MailFrom)] {
recentEmails = append(recentEmails, e)
}
if len(recentEmails) == 10 {
break
}
}
allAuths, _ := a.DB.ListRecentAuthLogs(50)
var recentAuths []db.AuthLog
for _, au := range allAuths {
if isGlobal || allowedNames[authLogDomain(au.Identifier)] {
recentAuths = append(recentAuths, au)
}
if len(recentAuths) == 10 {
break
}
}
a.render(w, r, "dashboard.html", M{
"active": "dashboard",
"domain_count": domainCount,
"sender_count": senderCount,
"dkim_count": dkimCount,
"recent_emails": recentEmails,
"recent_auths": recentAuths,
})
}
// authLogDomain best-effort extracts a domain name from an AuthLog identifier, whose
// format varies by auth_type: a bare email ("sender"), "ip -> domain" (ip), or
// "sender@x -> target@y" (sender_validation). There's no domain_id column on this
// table (it predates admin scoping), so this is a text heuristic, not a foreign key.
func authLogDomain(identifier string) string {
if idx := strings.LastIndex(identifier, "->"); idx >= 0 {
return emailOrBareDomain(strings.TrimSpace(identifier[idx+2:]))
}
return emailOrBareDomain(identifier)
}
func emailOrBareDomain(s string) string {
if strings.Contains(s, "@") {
return emailDomain(s)
}
return strings.ToLower(s)
}
+314
View File
@@ -0,0 +1,314 @@
package webui
import (
"encoding/json"
"net/http"
"regexp"
"strings"
"time"
"mailgoserver/internal/db"
"mailgoserver/internal/dkim"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func isAjax(r *http.Request) bool {
return r.Header.Get("X-Requested-With") == "XMLHttpRequest" || strings.Contains(r.Header.Get("Content-Type"), "application/json")
}
// dkimList mirrors dkim.py's dkim_list().
func (a *App) dkimList(w http.ResponseWriter, r *http.Request) {
active, err := a.DB.ListActiveDKIMKeysWithDomain()
if err != nil {
setFlash(w, "error", "Error loading DKIM keys")
}
inactive, _ := a.DB.ListInactiveDKIMKeysWithDomain()
publicIP := getPublicIP(a.Cfg)
scope := scopeFromContext(r)
var dkimData []M
for _, k := range active {
if !scope.Allowed(k.DomainID) {
continue
}
rec, _ := a.DKIM.GetDKIMPublicKeyRecord(k.DomainName)
if rec == nil {
rec = &dkim.DNSRecord{}
}
spfCheck := checkDNSRecord(k.DomainName)
existingSPF := ""
for _, txt := range spfCheck.Records {
if strings.Contains(txt, "v=spf1") {
existingSPF = txt
break
}
}
dkimData = append(dkimData, M{
"domain": M{"id": k.DomainID, "domain_name": k.DomainName},
"dkim_key": k.DKIMKey,
"dns_record": M{"name": rec.Name, "value": rec.Value},
"existing_spf": existingSPF,
"recommended_spf": generateSPFRecord(publicIP, existingSPF),
"public_ip": publicIP,
})
}
var oldData []M
for _, k := range inactive {
if !scope.Allowed(k.DomainID) {
continue
}
status := "Disabled"
if k.ReplacedAt != nil {
status = "Replaced"
}
oldData = append(oldData, M{
"domain": M{"id": k.DomainID, "domain_name": k.DomainName},
"dkim_key": k.DKIMKey,
"status_text": status,
})
}
a.render(w, r, "dkim.html", M{"active": "dkim", "dkim_data": dkimData, "old_dkim_data": oldData})
}
// createDKIM mirrors dkim.py's create_dkim().
func (a *App) createDKIM(w http.ResponseWriter, r *http.Request) {
domain := r.FormValue("domain")
selector := r.FormValue("selector")
if domain == "" {
writeJSON(w, http.StatusBadRequest, M{"success": false, "message": "Domain is required"})
return
}
dom, err := a.DB.GetDomainByNameExact(domain)
if err != nil || dom == nil {
writeJSON(w, http.StatusNotFound, M{"success": false, "message": "Domain not found"})
return
}
if !scopeFromContext(r).Allowed(dom.ID) {
writeJSON(w, http.StatusNotFound, M{"success": false, "message": "Domain not found"})
return
}
if err := a.DB.DeactivateActiveDKIMKeysForDomain(dom.ID, time.Now()); err != nil {
writeJSON(w, http.StatusInternalServerError, M{"success": false, "message": "Failed to create DKIM key"})
return
}
ok, err := a.DKIM.GenerateDKIMKeypair(domain, selector, true)
if err != nil || !ok {
writeJSON(w, http.StatusInternalServerError, M{"success": false, "message": "Failed to create DKIM key"})
return
}
writeJSON(w, http.StatusOK, M{"success": true, "message": "DKIM key created successfully"})
}
// regenerateDKIM mirrors dkim.py's regenerate_dkim() — path id is a DOMAIN id.
func (a *App) regenerateDKIM(w http.ResponseWriter, r *http.Request) {
domainID := pathID(r)
dom, err := a.DB.GetDomainByID(domainID)
if err != nil || dom == nil {
http.NotFound(w, r)
return
}
if !requireDomainAccess(w, r, dom.ID) {
return
}
current, _ := a.DB.GetActiveDKIMKeyByDomainID(domainID)
selector := ""
if current != nil {
selector = current.Selector
}
if err := a.DB.DeactivateActiveDKIMKeysForDomain(domainID, time.Now()); err != nil {
a.dkimActionFailed(w, r, "Failed to regenerate DKIM key")
return
}
ok, err := a.DKIM.GenerateDKIMKeypair(dom.DomainName, selector, true)
if err != nil || !ok {
a.dkimActionFailed(w, r, "Failed to regenerate DKIM key")
return
}
if isAjax(r) {
publicIP := getPublicIP(a.Cfg)
rec, _ := a.DKIM.GetDKIMPublicKeyRecord(dom.DomainName)
spfCheck := checkDNSRecord(dom.DomainName)
existingSPF := ""
for _, txt := range spfCheck.Records {
if strings.Contains(txt, "v=spf1") {
existingSPF = txt
break
}
}
newKey, _ := a.DB.GetActiveDKIMKeyByDomainID(domainID)
writeJSON(w, http.StatusOK, M{
"success": true, "message": "DKIM key regenerated successfully",
"new_key": newKey, "dns_record": rec, "existing_spf": existingSPF,
"recommended_spf": generateSPFRecord(publicIP, existingSPF),
"public_ip": publicIP, "domain": dom.DomainName,
})
return
}
setFlash(w, "success", "DKIM key regenerated successfully")
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
}
func (a *App) dkimActionFailed(w http.ResponseWriter, r *http.Request, message string) {
if isAjax(r) {
writeJSON(w, http.StatusInternalServerError, M{"success": false, "message": message})
return
}
setFlash(w, "error", message)
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
}
func (a *App) editDKIMForm(w http.ResponseWriter, r *http.Request) {
key, ok := a.dkimKeyWithAccess(w, r)
if !ok {
return
}
dom, _ := a.DB.GetDomainByID(key.DomainID)
a.render(w, r, "edit_dkim.html", M{"active": "dkim", "dkim_key": key, "domain": dom})
}
var selectorPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
// dkimKeyWithAccess fetches a DKIM key by path ID and confirms it belongs to a domain
// the current admin can manage.
func (a *App) dkimKeyWithAccess(w http.ResponseWriter, r *http.Request) (key *db.DKIMKey, ok bool) {
key, err := a.DB.GetDKIMKeyByID(pathID(r))
if err != nil || key == nil {
http.NotFound(w, r)
return nil, false
}
if !requireDomainAccess(w, r, key.DomainID) {
return nil, false
}
return key, true
}
// editDKIM mirrors dkim.py's edit_dkim() POST branch.
func (a *App) editDKIM(w http.ResponseWriter, r *http.Request) {
key, ok := a.dkimKeyWithAccess(w, r)
if !ok {
return
}
id := key.ID
dom, _ := a.DB.GetDomainByID(key.DomainID)
selector := strings.TrimSpace(r.FormValue("selector"))
if selector == "" || !selectorPattern.MatchString(selector) {
setFlash(w, "error", "Selector must contain only letters, numbers, hyphens, and underscores")
a.render(w, r, "edit_dkim.html", M{"active": "dkim", "dkim_key": key, "domain": dom})
return
}
if exists, _ := a.DB.SelectorExistsForDomain(key.DomainID, selector, id); exists {
setFlash(w, "error", "This selector is already in use for this domain")
a.render(w, r, "edit_dkim.html", M{"active": "dkim", "dkim_key": key, "domain": dom})
return
}
if err := a.DB.UpdateDKIMKeySelector(id, selector); err != nil {
setFlash(w, "error", "Error updating selector")
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
return
}
setFlash(w, "success", "DKIM selector updated successfully")
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
}
// toggleDKIM mirrors dkim.py's toggle_dkim().
func (a *App) toggleDKIM(w http.ResponseWriter, r *http.Request) {
key, ok := a.dkimKeyWithAccess(w, r)
if !ok {
return
}
id := key.ID
newActive := !key.IsActive
if newActive {
if err := a.DB.DeactivateActiveDKIMKeysForDomain(key.DomainID, time.Now()); err != nil {
a.dkimActionFailed(w, r, "Error toggling DKIM status")
return
}
}
if err := a.DB.SetDKIMKeyActive(id, newActive, time.Now()); err != nil {
a.dkimActionFailed(w, r, "Error toggling DKIM status")
return
}
message := "DKIM key disabled"
if newActive {
message = "DKIM key enabled"
}
if isAjax(r) {
writeJSON(w, http.StatusOK, M{"success": true, "message": message, "is_active": newActive})
return
}
setFlash(w, "success", message)
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
}
func (a *App) removeDKIM(w http.ResponseWriter, r *http.Request) {
key, ok := a.dkimKeyWithAccess(w, r)
if !ok {
return
}
if err := a.DB.RemoveDKIMKey(key.ID); err != nil {
setFlash(w, "error", "Error removing DKIM key")
} else {
setFlash(w, "success", "DKIM key permanently removed")
}
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
}
// checkDKIMDNS mirrors dkim.py's check_dkim_dns().
func (a *App) checkDKIMDNS(w http.ResponseWriter, r *http.Request) {
domain := r.FormValue("domain")
selector := r.FormValue("selector")
rec, err := a.DKIM.GetDKIMPublicKeyRecord(domain)
if err != nil || rec == nil {
writeJSON(w, http.StatusOK, M{"success": false, "message": "No active DKIM key for domain"})
return
}
dnsName := selector + "._domainkey." + domain
result := checkDNSRecord(dnsName)
found := false
expected := strings.Trim(rec.Value, `"`)
for _, txt := range result.Records {
if strings.Contains(txt, expected) || strings.Contains(expected, txt) {
found = true
break
}
}
message := "DKIM record not found or does not match"
if found {
message = "DKIM record found and matches"
} else if !result.Success {
message = result.Message
}
writeJSON(w, http.StatusOK, M{"success": found, "message": message, "records": result.Records})
}
// checkSPFDNS mirrors dkim.py's check_spf_dns().
func (a *App) checkSPFDNS(w http.ResponseWriter, r *http.Request) {
domain := r.FormValue("domain")
result := checkDNSRecord(domain)
var spfRecord string
for _, txt := range result.Records {
if strings.Contains(txt, "v=spf1") {
spfRecord = txt
break
}
}
publicIP := getPublicIP(a.Cfg)
validForServer := spfRecord != "" && strings.Contains(spfRecord, "ip4:"+publicIP)
message := "SPF record not found"
if spfRecord != "" {
message = "SPF record found"
}
writeJSON(w, http.StatusOK, M{
"success": spfRecord != "", "message": message, "records": result.Records,
"spf_record": spfRecord, "spf_valid_for_server": validForServer,
"spf_check_message": message, "public_ip": publicIP,
})
}
+262
View File
@@ -0,0 +1,262 @@
package webui
import (
"net/http"
"regexp"
"strconv"
"strings"
"mailgoserver/internal/db"
)
func pathID(r *http.Request) int64 {
return int64(atoi(r.PathValue("id")))
}
// validDomainName requires a real, multi-label FQDN (e.g. "example.com" or
// "mail.example.com") — a bare word like "zczsdc" has no dot and is rejected.
var validDomainName = regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$`)
func isValidDomainName(name string) bool {
return len(name) <= 253 && validDomainName.MatchString(name)
}
// domainsList mirrors domains.py's domains_list(), scoped to the current admin's
// assigned domains unless they're a global admin.
func (a *App) domainsList(w http.ResponseWriter, r *http.Request) {
allDomains, err := a.DB.ListDomains()
if err != nil {
setFlash(w, "error", "Error loading domains")
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
return
}
scope := scopeFromContext(r)
var domains []db.Domain
var rows []M
for _, d := range allDomains {
if !scope.Allowed(d.ID) {
continue
}
domains = append(domains, d)
senderCount, _ := a.DB.CountSendersForDomain(d.ID)
hasActiveDKIM, _ := a.DB.HasActiveDKIMForDomain(d.ID)
hasAnyDKIM, _ := a.DB.HasAnyDKIMForDomain(d.ID)
rows = append(rows, M{
"domain": d, "sender_count": senderCount,
"has_active_dkim": hasActiveDKIM, "has_any_dkim": hasAnyDKIM,
})
}
a.render(w, r, "domains.html", M{"active": "domains", "domains": domains, "rows": rows})
}
func (a *App) addDomainForm(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "add_domain.html", M{"active": "domains"})
}
// addDomain mirrors domains.py's add_domain() POST branch.
func (a *App) addDomain(w http.ResponseWriter, r *http.Request) {
name := strings.ToLower(strings.TrimSpace(r.FormValue("domain_name")))
if !isValidDomainName(name) {
setFlash(w, "error", "Please enter a real domain name, e.g. example.com or mail.example.com")
http.Redirect(w, r, Prefix+"/domains/add", http.StatusFound)
return
}
existing, _ := a.DB.GetDomainByNameExact(name)
if existing != nil {
setFlash(w, "error", "Domain already exists")
http.Redirect(w, r, Prefix+"/domains/add", http.StatusFound)
return
}
newID, err := a.DB.CreateDomain(name)
if err != nil {
setFlash(w, "error", "Error adding domain")
http.Redirect(w, r, Prefix+"/domains/add", http.StatusFound)
return
}
// A scoped admin who creates a domain automatically gets management access to it
// — otherwise they'd create a domain and immediately lose the ability to see it.
if scope := scopeFromContext(r); !scope.Global {
if err := a.DB.GrantDomainAccess(userFromContext(r).ID, newID); err != nil {
a.Logger.Error("grant domain access after create: %v", err)
}
}
if _, err := a.DKIM.GenerateDKIMKeypair(name, "", false); err != nil {
a.Logger.Error("DKIM generation for %s failed: %v", name, err)
}
setFlash(w, "success", "Domain added successfully")
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
}
// toggleDomainOff mirrors domains.py's delete_domain(): unconditional soft-disable.
func (a *App) toggleDomainOff(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
if !requireDomainAccess(w, r, id) {
return
}
if err := a.DB.SetDomainActive(id, false); err != nil {
setFlash(w, "error", "Error disabling domain")
} else {
setFlash(w, "success", "Domain disabled")
}
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
}
func (a *App) editDomainForm(w http.ResponseWriter, r *http.Request) {
dom, err := a.DB.GetDomainByID(pathID(r))
if err != nil || dom == nil {
http.NotFound(w, r)
return
}
if !requireDomainAccess(w, r, dom.ID) {
return
}
a.render(w, r, "edit_domain.html", M{"active": "domains", "domain": dom})
}
// editDomain mirrors domains.py's edit_domain() POST branch.
func (a *App) editDomain(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
dom, err := a.DB.GetDomainByID(id)
if err != nil || dom == nil {
http.NotFound(w, r)
return
}
if !requireDomainAccess(w, r, dom.ID) {
return
}
name := strings.ToLower(strings.TrimSpace(r.FormValue("domain_name")))
requiresAuth := r.FormValue("requires_auth") == "on"
if !isValidDomainName(name) {
setFlash(w, "error", "Please enter a real domain name, e.g. example.com or mail.example.com")
a.render(w, r, "edit_domain.html", M{"active": "domains", "domain": dom})
return
}
if other, _ := a.DB.GetDomainByNameExact(name); other != nil && other.ID != id {
setFlash(w, "error", "Domain already exists")
a.render(w, r, "edit_domain.html", M{"active": "domains", "domain": dom})
return
}
if err := a.DB.UpdateDomain(id, name, requiresAuth); err != nil {
setFlash(w, "error", "Error updating domain")
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
return
}
setFlash(w, "success", "Domain updated successfully")
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
}
// toggleDomain mirrors domains.py's toggle_domain().
func (a *App) toggleDomain(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
dom, err := a.DB.GetDomainByID(id)
if err != nil || dom == nil {
http.NotFound(w, r)
return
}
if !requireDomainAccess(w, r, dom.ID) {
return
}
if err := a.DB.SetDomainActive(id, !dom.IsActive); err != nil {
setFlash(w, "error", "Error updating domain status")
} else if dom.IsActive {
setFlash(w, "success", "Domain disabled")
} else {
setFlash(w, "success", "Domain enabled")
}
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
}
// removeDomain mirrors domains.py's remove_domain(): hard delete + cascade.
func (a *App) removeDomain(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
dom, err := a.DB.GetDomainByID(id)
if err != nil || dom == nil {
http.NotFound(w, r)
return
}
if !requireDomainAccess(w, r, dom.ID) {
return
}
senders, ips, keys, headers, err := a.DB.RemoveDomainCascade(id)
if err != nil {
setFlash(w, "error", "Error removing domain")
} else {
setFlash(w, "success", domainRemovedMessage(dom.DomainName, senders, ips, keys, headers))
}
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
}
// verifyDomainCheck queries the domain's DNS TXT ownership record via 1.1.1.1 and
// 8.8.8.8 and marks it verified if found, mirroring the DKIM/SPF "Check DNS" pattern.
func (a *App) verifyDomainCheck(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
dom, err := a.DB.GetDomainByID(id)
if err != nil || dom == nil {
http.NotFound(w, r)
return
}
if !requireDomainAccess(w, r, dom.ID) {
return
}
verified, records, err := checkDomainOwnership(dom.DomainName, dom.VerificationToken)
if err != nil {
writeJSON(w, http.StatusOK, M{"success": false, "message": "DNS lookup failed: " + err.Error()})
return
}
if verified {
if err := a.DB.SetDomainVerified(id, true); err != nil {
writeJSON(w, http.StatusOK, M{"success": false, "message": "Verified via DNS but failed to save: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, M{"success": true, "message": "Domain ownership verified — it can now send mail.", "records": records})
return
}
writeJSON(w, http.StatusOK, M{"success": false, "message": "TXT record not found or doesn't match yet. DNS changes can take a while to propagate.", "records": records})
}
// accessibleDomains returns the active domains the current admin can pick from in a
// dropdown (add/edit sender, IP, DKIM forms) — all of them for a global admin, only
// their assigned ones for a scoped admin.
func (a *App) accessibleDomains(r *http.Request) ([]db.Domain, error) {
all, err := a.DB.ListActiveDomains()
if err != nil {
return nil, err
}
scope := scopeFromContext(r)
if scope.Global {
return all, nil
}
var out []db.Domain
for _, d := range all {
if scope.Allowed(d.ID) {
out = append(out, d)
}
}
return out, nil
}
// accessibleDomainNames is accessibleDomains's counterpart for tables that store the
// domain as text (mail_from, auth log identifiers) rather than a domain_id — email
// logs and auth logs, which predate per-domain admin scoping. isGlobal=true means
// "don't filter, they can see everything" and names will be nil.
func (a *App) accessibleDomainNames(r *http.Request) (names map[string]bool, isGlobal bool, err error) {
scope := scopeFromContext(r)
if scope.Global {
return nil, true, nil
}
domains, err := a.DB.ListDomains()
if err != nil {
return nil, false, err
}
names = make(map[string]bool)
for _, d := range domains {
if scope.Allowed(d.ID) {
names[strings.ToLower(d.DomainName)] = true
}
}
return names, false, nil
}
func domainRemovedMessage(name string, senders, ips, keys, headers int) string {
return "Domain " + name + " and associated records removed (senders: " + strconv.Itoa(senders) + ", IPs: " + strconv.Itoa(ips) + ", DKIM keys: " + strconv.Itoa(keys) + ", custom headers: " + strconv.Itoa(headers) + ")"
}
+28
View File
@@ -0,0 +1,28 @@
package webui
import "testing"
func TestIsValidDomainName(t *testing.T) {
valid := []string{"example.com", "mail.example.com", "my-domain.org", "company.co.uk"}
invalid := []string{"zczsdc", "example", "", ".example.com", "http://example.com", "example..com"}
for _, d := range valid {
if !isValidDomainName(d) {
t.Errorf("isValidDomainName(%q) = false, want true", d)
}
}
for _, d := range invalid {
if isValidDomainName(d) {
t.Errorf("isValidDomainName(%q) = true, want false", d)
}
}
}
func TestVerificationRecordNameAndValue(t *testing.T) {
if got := verificationRecordName("example.com"); got != "_pymta-verify.example.com" {
t.Errorf("verificationRecordName = %q", got)
}
if got := verificationRecordValue("abc123"); got != "pymta-verify=abc123" {
t.Errorf("verificationRecordValue = %q", got)
}
}
+10
View File
@@ -0,0 +1,10 @@
package webui
import "embed"
// assets embeds templates/ and static/ directly into the compiled binary, so
// mailgoserver is a genuinely standalone executable — it doesn't need to be run
// from a particular working directory or shipped alongside its source tree.
//
//go:embed templates static
var assets embed.FS
+49
View File
@@ -0,0 +1,49 @@
package webui
import (
"encoding/base64"
"encoding/json"
"net/http"
)
// Flash mirrors one Flask flash() message: (message, category).
type Flash struct {
Category string `json:"c"`
Message string `json:"m"`
}
const flashCookieName = "flash"
// setFlash mirrors flask.flash(): appends one message to the flash cookie so it
// survives the redirect that (almost) always follows a form POST in this app.
// No signing: these are cosmetic toast notifications, not a trust boundary.
func setFlash(w http.ResponseWriter, category, message string) {
flashes := []Flash{{Category: category, Message: message}}
encoded, _ := json.Marshal(flashes)
http.SetCookie(w, &http.Cookie{
Name: flashCookieName,
Value: base64.URLEncoding.EncodeToString(encoded),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
// popFlashes mirrors get_flashed_messages(with_categories=true): reads and clears the
// flash cookie so each message is shown exactly once, on the very next render.
func popFlashes(w http.ResponseWriter, r *http.Request) []Flash {
c, err := r.Cookie(flashCookieName)
if err != nil || c.Value == "" {
return nil
}
http.SetCookie(w, &http.Cookie{Name: flashCookieName, Value: "", Path: "/", MaxAge: -1})
raw, err := base64.URLEncoding.DecodeString(c.Value)
if err != nil {
return nil
}
var flashes []Flash
if err := json.Unmarshal(raw, &flashes); err != nil {
return nil
}
return flashes
}
+157
View File
@@ -0,0 +1,157 @@
package webui
import (
"net"
"net/http"
"mailgoserver/internal/db"
)
func (a *App) ipsList(w http.ResponseWriter, r *http.Request) {
ips, err := a.DB.ListWhitelistedIPs()
if err != nil {
setFlash(w, "error", "Error loading IP whitelist")
}
scope := scopeFromContext(r)
var pairs [][2]any
for _, ip := range ips {
if !scope.Allowed(ip.DomainID) {
continue
}
pairs = append(pairs, [2]any{ip.WhitelistedIP, M{"domain_name": ip.DomainName}})
}
a.render(w, r, "ips.html", M{"active": "ips", "ips": pairs})
}
func (a *App) addIPForm(w http.ResponseWriter, r *http.Request) {
domains, _ := a.accessibleDomains(r)
a.render(w, r, "add_ip.html", M{"active": "ips", "domains": domains, "prefill_ip": r.URL.Query().Get("ip")})
}
// addIP mirrors ip_whitelist.py's add_ip() POST branch: IPv4-only validation via
// net.ParseIP + To4(), matching Python's socket.inet_aton behavior (CIDR is rejected
// here despite edit_ip.html's placeholder implying CIDR support — that mismatch is
// preserved from the Python version).
func (a *App) addIP(w http.ResponseWriter, r *http.Request) {
ip := r.FormValue("ip_address")
domainID := int64(atoi(r.FormValue("domain_id")))
storeMessage := r.FormValue("store_message_content") == "on"
if net.ParseIP(ip).To4() == nil || domainID == 0 {
setFlash(w, "error", "A valid IPv4 address and domain are required")
http.Redirect(w, r, Prefix+"/ips/add", http.StatusFound)
return
}
if !requireDomainAccess(w, r, domainID) {
return
}
if exists, _ := a.DB.IPPairExists(ip, domainID, -1); exists {
setFlash(w, "error", "This IP is already whitelisted for this domain")
http.Redirect(w, r, Prefix+"/ips/add", http.StatusFound)
return
}
if _, err := a.DB.CreateWhitelistedIP(ip, domainID, storeMessage); err != nil {
setFlash(w, "error", "Error adding IP")
http.Redirect(w, r, Prefix+"/ips/add", http.StatusFound)
return
}
setFlash(w, "success", "IP address whitelisted successfully")
http.Redirect(w, r, Prefix+"/ips", http.StatusFound)
}
// ipWithAccess fetches a whitelisted-IP row by path ID and confirms it belongs to a
// domain the current admin can manage.
func (a *App) ipWithAccess(w http.ResponseWriter, r *http.Request) (rec *db.WhitelistedIP, ok bool) {
rec, err := a.DB.GetWhitelistedIPByID(pathID(r))
if err != nil || rec == nil {
http.NotFound(w, r)
return nil, false
}
if !requireDomainAccess(w, r, rec.DomainID) {
return nil, false
}
return rec, true
}
func (a *App) disableIP(w http.ResponseWriter, r *http.Request) {
rec, ok := a.ipWithAccess(w, r)
if !ok {
return
}
if err := a.DB.SetWhitelistedIPActive(rec.ID, false); err != nil {
setFlash(w, "error", "Error disabling IP")
} else {
setFlash(w, "success", "IP disabled")
}
http.Redirect(w, r, Prefix+"/ips", http.StatusFound)
}
func (a *App) enableIP(w http.ResponseWriter, r *http.Request) {
rec, ok := a.ipWithAccess(w, r)
if !ok {
return
}
if err := a.DB.SetWhitelistedIPActive(rec.ID, true); err != nil {
setFlash(w, "error", "Error enabling IP")
} else {
setFlash(w, "success", "IP enabled")
}
http.Redirect(w, r, Prefix+"/ips", http.StatusFound)
}
func (a *App) removeIP(w http.ResponseWriter, r *http.Request) {
rec, ok := a.ipWithAccess(w, r)
if !ok {
return
}
if err := a.DB.RemoveWhitelistedIP(rec.ID); err != nil {
setFlash(w, "error", "Error removing IP")
} else {
setFlash(w, "success", "IP permanently removed")
}
http.Redirect(w, r, Prefix+"/ips", http.StatusFound)
}
func (a *App) editIPForm(w http.ResponseWriter, r *http.Request) {
rec, ok := a.ipWithAccess(w, r)
if !ok {
return
}
domains, _ := a.accessibleDomains(r)
a.render(w, r, "edit_ip.html", M{"active": "ips", "ip_record": rec, "domains": domains})
}
// editIP mirrors ip_whitelist.py's edit_ip() POST branch.
func (a *App) editIP(w http.ResponseWriter, r *http.Request) {
rec, ok := a.ipWithAccess(w, r)
if !ok {
return
}
id := rec.ID
domains, _ := a.accessibleDomains(r)
ip := r.FormValue("ip_address")
domainID := int64(atoi(r.FormValue("domain_id")))
storeMessage := r.FormValue("store_message_content") == "on"
if net.ParseIP(ip).To4() == nil || domainID == 0 {
setFlash(w, "error", "A valid IPv4 address and domain are required")
a.render(w, r, "edit_ip.html", M{"active": "ips", "ip_record": rec, "domains": domains})
return
}
if !requireDomainAccess(w, r, domainID) {
return
}
if exists, _ := a.DB.IPPairExists(ip, domainID, id); exists {
setFlash(w, "error", "This IP is already whitelisted for this domain")
a.render(w, r, "edit_ip.html", M{"active": "ips", "ip_record": rec, "domains": domains})
return
}
if err := a.DB.UpdateWhitelistedIP(id, ip, domainID, storeMessage); err != nil {
setFlash(w, "error", "Error updating IP")
http.Redirect(w, r, Prefix+"/ips", http.StatusFound)
return
}
setFlash(w, "success", "IP whitelist entry updated successfully")
http.Redirect(w, r, Prefix+"/ips", http.StatusFound)
}
+221
View File
@@ -0,0 +1,221 @@
package webui
import (
"net/http"
"strconv"
"strings"
"github.com/pquerna/otp/totp"
"mailgoserver/internal/db"
)
// pendingMFACookie holds the user id awaiting a second factor, set right after a
// successful password check and cleared once MFA passes (or the user logs out).
// Kept separate from the real session cookie so an unfinished login never grants
// access to anything.
const pendingMFACookieName = "mailgoserver_pending_mfa"
func setPendingMFACookie(w http.ResponseWriter, userID string) {
http.SetCookie(w, &http.Cookie{
Name: pendingMFACookieName, Value: userID, Path: "/", HttpOnly: true,
SameSite: http.SameSiteLaxMode, MaxAge: 10 * 60,
})
}
func clearPendingMFACookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: pendingMFACookieName, Value: "", Path: "/", MaxAge: -1})
}
func pendingMFAUserID(r *http.Request) int64 {
c, err := r.Cookie(pendingMFACookieName)
if err != nil {
return 0
}
return int64(atoi(c.Value))
}
func (a *App) loginForm(w http.ResponseWriter, r *http.Request) {
if sess, user, _ := a.currentSession(r); sess != nil && user != nil {
http.Redirect(w, r, Prefix+"/", http.StatusFound)
return
}
a.render(w, r, "login.html", M{"next": r.URL.Query().Get("next")})
}
// loginSubmit checks username+password, then either starts a fully-verified session
// (no second factor enabled) or a pending-MFA state that requires /login/mfa next.
func (a *App) loginSubmit(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
next := r.FormValue("next")
fail := func(msg string) {
a.render(w, r, "login.html", M{"error": msg, "username": username, "next": next})
}
user, err := a.DB.GetAdminUserByUsername(username)
if err != nil {
a.Logger.Error("login lookup: %v", err)
fail("Something went wrong. Try again.")
return
}
if user == nil || !db.CheckPassword(password, user.PasswordHash) {
fail("Incorrect username or password.")
return
}
needsMFA := user.TOTPEnabled
if !needsMFA {
if n, _ := a.DB.CountWebAuthnCredentials(user.ID); n > 0 {
needsMFA = true
}
}
if !needsMFA {
token, err := a.DB.CreateSession(user.ID, true, sessionTTL)
if err != nil {
fail("Something went wrong. Try again.")
return
}
setSessionCookie(w, token, r.TLS != nil)
http.Redirect(w, r, redirectTarget(next), http.StatusFound)
return
}
setPendingMFACookie(w, strconv.FormatInt(user.ID, 10))
http.Redirect(w, r, Prefix+"/login/mfa?next="+next, http.StatusFound)
}
func redirectTarget(next string) string {
if next == "" || !strings.HasPrefix(next, Prefix) {
return Prefix + "/"
}
return next
}
func (a *App) mfaForm(w http.ResponseWriter, r *http.Request) {
userID := pendingMFAUserID(r)
if userID == 0 {
http.Redirect(w, r, Prefix+"/login", http.StatusFound)
return
}
user, _ := a.DB.GetAdminUserByID(userID)
if user == nil {
clearPendingMFACookie(w)
http.Redirect(w, r, Prefix+"/login", http.StatusFound)
return
}
hasPasskeys, _ := a.DB.CountWebAuthnCredentials(userID)
a.render(w, r, "login_mfa.html", M{
"next": r.URL.Query().Get("next"), "totp_enabled": user.TOTPEnabled, "has_passkeys": hasPasskeys > 0,
})
}
// mfaSubmit verifies the TOTP code for the pending login and, on success, promotes
// the pending state into a real, fully-verified session.
func (a *App) mfaSubmit(w http.ResponseWriter, r *http.Request) {
userID := pendingMFAUserID(r)
next := r.FormValue("next")
if userID == 0 {
http.Redirect(w, r, Prefix+"/login", http.StatusFound)
return
}
user, err := a.DB.GetAdminUserByID(userID)
if err != nil || user == nil {
clearPendingMFACookie(w)
http.Redirect(w, r, Prefix+"/login", http.StatusFound)
return
}
code := strings.TrimSpace(r.FormValue("code"))
if !user.TOTPEnabled || !totp.Validate(code, user.TOTPSecret) {
hasPasskeys, _ := a.DB.CountWebAuthnCredentials(userID)
a.render(w, r, "login_mfa.html", M{
"next": next, "totp_enabled": user.TOTPEnabled, "has_passkeys": hasPasskeys > 0, "error": "Invalid code.",
})
return
}
token, err := a.DB.CreateSession(user.ID, true, sessionTTL)
if err != nil {
a.Logger.Error("create session: %v", err)
http.Redirect(w, r, Prefix+"/login", http.StatusFound)
return
}
clearPendingMFACookie(w)
setSessionCookie(w, token, r.TLS != nil)
http.Redirect(w, r, redirectTarget(next), http.StatusFound)
}
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookieName); err == nil {
_ = a.DB.DeleteSession(c.Value)
}
clearSessionCookie(w)
http.Redirect(w, r, Prefix+"/login", http.StatusFound)
}
func (a *App) firstLoginForm(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r)
a.render(w, r, "first_login.html", M{"username": user.Username})
}
// firstLoginSubmit mirrors the forced "you can't keep the default credentials" flow:
// require a new username and password before must_change_password clears.
func (a *App) firstLoginSubmit(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r)
newUsername := strings.TrimSpace(r.FormValue("username"))
newPassword := r.FormValue("password")
confirm := r.FormValue("password_confirm")
fail := func(msg string) {
a.render(w, r, "first_login.html", M{"username": newUsername, "error": msg})
}
if newUsername == "" {
fail("Choose a username.")
return
}
if !isStrongPassword(newPassword) {
fail("Password must be at least 10 characters and include a letter, a number, and a symbol.")
return
}
if newPassword != confirm {
fail("Passwords don't match.")
return
}
if existing, _ := a.DB.GetAdminUserByUsername(newUsername); existing != nil && existing.ID != user.ID {
fail("That username is already taken.")
return
}
hash, err := db.HashPassword(newPassword)
if err != nil {
fail("Something went wrong. Try again.")
return
}
if err := a.DB.UpdateAdminCredentials(user.ID, newUsername, hash); err != nil {
fail("Something went wrong. Try again.")
return
}
setFlash(w, "success", "Credentials updated. Welcome to your dashboard.")
http.Redirect(w, r, Prefix+"/", http.StatusFound)
}
// isStrongPassword requires the same practical minimum most providers enforce: not
// the literal default, long enough, and not just letters.
func isStrongPassword(pw string) bool {
if len(pw) < 10 {
return false
}
var hasLetter, hasDigit, hasSymbol bool
for _, c := range pw {
switch {
case c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z':
hasLetter = true
case c >= '0' && c <= '9':
hasDigit = true
default:
hasSymbol = true
}
}
return hasLetter && hasDigit && hasSymbol
}
+162
View File
@@ -0,0 +1,162 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/pquerna/otp/totp"
"mailgoserver/internal/db"
)
func TestLoginSuccessWithoutMFA(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
hash, err := db.HashPassword("correct-horse-battery-staple1!")
if err != nil {
t.Fatal(err)
}
if _, err := app.DB.CreateAdminUser("alice", hash, false); err != nil {
t.Fatal(err)
}
form := url.Values{"username": {"alice"}, "password": {"correct-horse-battery-staple1!"}}
req := httptest.NewRequest(http.MethodPost, Prefix+"/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 == sessionCookieName {
sessionCookie = c
}
}
if sessionCookie == nil || sessionCookie.Value == "" {
t.Fatal("expected a session cookie to be set on successful login")
}
// That cookie must actually grant access to a protected page.
req2 := httptest.NewRequest(http.MethodGet, Prefix+"/", nil)
req2.AddCookie(sessionCookie)
rec2 := httptest.NewRecorder()
mux.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("expected dashboard to load with the new session, got %d", rec2.Code)
}
}
func TestLoginWrongPasswordRejected(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
hash, _ := db.HashPassword("the-real-password-123!")
if _, err := app.DB.CreateAdminUser("bob", hash, false); err != nil {
t.Fatal(err)
}
form := url.Values{"username": {"bob"}, "password": {"wrong-password"}}
req := httptest.NewRequest(http.MethodPost, Prefix+"/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 {
t.Fatalf("expected the login form re-rendered with an error, got status %d", rec.Code)
}
for _, c := range rec.Result().Cookies() {
if c.Name == sessionCookieName && c.Value != "" {
t.Fatal("must not set a session cookie on failed login")
}
}
}
func TestLoginRequiresTOTPWhenEnabled(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
hash, _ := db.HashPassword("carol-password-123!")
userID, err := app.DB.CreateAdminUser("carol", hash, false)
if err != nil {
t.Fatal(err)
}
secret := "JBSWY3DPEHPK3PXP" // fixed test secret, valid base32
if err := app.DB.SetAdminTOTPSecret(userID, secret, true); err != nil {
t.Fatal(err)
}
form := url.Values{"username": {"carol"}, "password": {"carol-password-123!"}}
req := httptest.NewRequest(http.MethodPost, Prefix+"/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 || rec.Header().Get("Location") != Prefix+"/login/mfa?next=" {
t.Fatalf("expected redirect to MFA step, got %d location %q", rec.Code, rec.Header().Get("Location"))
}
var pendingCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == pendingMFACookieName {
pendingCookie = c
}
}
if pendingCookie == nil {
t.Fatal("expected a pending-MFA cookie after correct password with TOTP enabled")
}
// Wrong code must not grant a session.
code, err := totp.GenerateCode(secret, time.Now())
if err != nil {
t.Fatal(err)
}
wrongCode := "000000"
if wrongCode == code {
wrongCode = "111111"
}
badForm := url.Values{"code": {wrongCode}}
badReq := httptest.NewRequest(http.MethodPost, Prefix+"/login/mfa", strings.NewReader(badForm.Encode()))
badReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
badReq.AddCookie(pendingCookie)
badRec := httptest.NewRecorder()
mux.ServeHTTP(badRec, badReq)
for _, c := range badRec.Result().Cookies() {
if c.Name == sessionCookieName && c.Value != "" {
t.Fatal("must not grant a session for a wrong TOTP code")
}
}
// Correct code must grant a fully-verified session.
goodForm := url.Values{"code": {code}}
goodReq := httptest.NewRequest(http.MethodPost, Prefix+"/login/mfa", strings.NewReader(goodForm.Encode()))
goodReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
goodReq.AddCookie(pendingCookie)
goodRec := httptest.NewRecorder()
mux.ServeHTTP(goodRec, goodReq)
if goodRec.Code != http.StatusFound {
t.Fatalf("expected redirect after correct TOTP code, got %d", goodRec.Code)
}
var sessionCookie *http.Cookie
for _, c := range goodRec.Result().Cookies() {
if c.Name == sessionCookieName {
sessionCookie = c
}
}
if sessionCookie == nil || sessionCookie.Value == "" {
t.Fatal("expected a session cookie after correct TOTP code")
}
dashReq := httptest.NewRequest(http.MethodGet, Prefix+"/", nil)
dashReq.AddCookie(sessionCookie)
dashRec := httptest.NewRecorder()
mux.ServeHTTP(dashRec, dashReq)
if dashRec.Code != http.StatusOK {
t.Fatalf("expected dashboard to load after full MFA login, got %d", dashRec.Code)
}
}
+118
View File
@@ -0,0 +1,118 @@
package webui
import (
"net/http"
"sort"
"time"
"mailgoserver/internal/db"
)
const perPage = 50
// logs mirrors logs.py's logs(): type=all|emails|auth, page (mostly cosmetic in "all"
// mode, matching the Python version's own quirk where "all" mode's pagination isn't
// real pagination — see the route inventory). Scoped admins get the fetched page
// filtered down to their domains in-memory (these tables have no domain_id column to
// filter in SQL — see accessibleDomainNames) — pagination counts stay based on the
// unfiltered page, same imprecision the "all" mode already had before scoping existed.
func (a *App) logs(w http.ResponseWriter, r *http.Request) {
allowedNames, isGlobal, err := a.accessibleDomainNames(r)
if err != nil {
setFlash(w, "error", "Error loading logs")
}
emailAllowed := func(e db.EmailLog) bool { return isGlobal || allowedNames[emailDomain(e.MailFrom)] }
authAllowed := func(au db.AuthLog) bool { return isGlobal || allowedNames[authLogDomain(au.Identifier)] }
filterType := r.URL.Query().Get("type")
if filterType == "" {
filterType = "all"
}
page := atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
offset := (page - 1) * perPage
switch filterType {
case "emails":
fetched, err := a.DB.ListEmailLogsPage(offset, perPage)
if err != nil {
setFlash(w, "error", "Error loading logs")
}
var emails []db.EmailLog
for _, e := range fetched {
if emailAllowed(e) {
emails = append(emails, e)
}
}
recipientMap, attachMap := a.buildLogMaps(emails)
a.render(w, r, "logs.html", M{
"active": "logs", "logs": emails, "filter_type": filterType, "page": page,
"has_next": len(fetched) == perPage, "has_prev": page > 1,
"recipient_logs_map": recipientMap, "attachments_map": attachMap,
})
case "auth":
fetched, err := a.DB.ListAuthLogsPage(offset, perPage)
if err != nil {
setFlash(w, "error", "Error loading logs")
}
var auths []db.AuthLog
for _, au := range fetched {
if authAllowed(au) {
auths = append(auths, au)
}
}
a.render(w, r, "logs.html", M{
"active": "logs", "logs": auths, "filter_type": filterType, "page": page,
"has_next": len(fetched) == perPage, "has_prev": page > 1,
})
default:
half := perPage / 2
fetchedEmails, _ := a.DB.ListEmailLogsPage(0, half)
fetchedAuths, _ := a.DB.ListAuthLogsPage(0, half)
recipientMap, _ := a.buildLogMaps(fetchedEmails)
type combinedEntry struct {
M M
At time.Time
}
var combined []combinedEntry
for _, e := range fetchedEmails {
if !emailAllowed(e) {
continue
}
combined = append(combined, combinedEntry{M: M{"type": "email", "data": e, "recipients": recipientMap[e.ID]}, At: e.Timestamp})
}
for _, au := range fetchedAuths {
if !authAllowed(au) {
continue
}
combined = append(combined, combinedEntry{M: M{"type": "auth", "data": au}, At: au.CreatedAt})
}
sort.SliceStable(combined, func(i, j int) bool { return combined[i].At.After(combined[j].At) })
if len(combined) > perPage {
combined = combined[:perPage]
}
var logs []M
for _, c := range combined {
logs = append(logs, c.M)
}
a.render(w, r, "logs.html", M{
"active": "logs", "logs": logs, "filter_type": filterType, "page": page,
"has_next": len(logs) > perPage, "has_prev": page > 1,
})
}
}
func (a *App) buildLogMaps(emails []db.EmailLog) (map[int64][]db.EmailRecipientLog, map[int64][]db.EmailAttachment) {
recipientMap := map[int64][]db.EmailRecipientLog{}
attachMap := map[int64][]db.EmailAttachment{}
for _, e := range emails {
recs, _ := a.DB.ListRecipientLogsForEmail(e.ID)
recipientMap[e.ID] = recs
atts, _ := a.DB.ListAttachmentsForEmail(e.ID)
attachMap[e.ID] = atts
}
return recipientMap, attachMap
}
+269
View File
@@ -0,0 +1,269 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"mailgoserver/internal/db"
)
// scopedLogin creates a domain-scoped (non-global) admin with access to exactly
// domainIDs and returns its session cookie. CreateScopedAdminUser always sets
// must_change_password (matching the real "you can't keep an admin-picked initial
// password" flow), so this clears it the same way completing /first-login would —
// otherwise every protected route redirects to /first-login before the scoping logic
// these tests exercise ever runs.
func scopedLogin(t *testing.T, app *App, username string, domainIDs []int64) *http.Cookie {
t.Helper()
hash, err := db.HashPassword("scoped-password-123!")
if err != nil {
t.Fatal(err)
}
userID, err := app.DB.CreateScopedAdminUser(username, hash, 0, domainIDs)
if err != nil {
t.Fatal(err)
}
if err := app.DB.UpdateAdminCredentials(userID, username, hash); err != nil {
t.Fatal(err)
}
token, err := app.DB.CreateSession(userID, true, sessionTTL)
if err != nil {
t.Fatal(err)
}
return &http.Cookie{Name: sessionCookieName, Value: token}
}
// setupTwoTenants seeds two separate domains, each with its own sender, whitelisted
// IP, and DKIM key, and returns everything needed to test cross-tenant isolation.
func setupTwoTenants(t *testing.T, app *App) (domainA, domainB db.Domain, senderA, senderB *db.Sender) {
t.Helper()
aID, err := app.DB.CreateDomain("tenant-a.example")
if err != nil {
t.Fatal(err)
}
bID, err := app.DB.CreateDomain("tenant-b.example")
if err != nil {
t.Fatal(err)
}
da, _ := app.DB.GetDomainByID(aID)
db_, _ := app.DB.GetDomainByID(bID)
hash, _ := db.HashPassword("password123")
saID, err := app.DB.CreateSender("alice@tenant-a.example", hash, aID, false, false)
if err != nil {
t.Fatal(err)
}
sbID, err := app.DB.CreateSender("bob@tenant-b.example", hash, bID, false, false)
if err != nil {
t.Fatal(err)
}
sa, _ := app.DB.GetSenderByID(saID)
sb, _ := app.DB.GetSenderByID(sbID)
if _, err := app.DKIM.GenerateDKIMKeypair("tenant-a.example", "", false); err != nil {
t.Fatal(err)
}
if _, err := app.DKIM.GenerateDKIMKeypair("tenant-b.example", "", false); err != nil {
t.Fatal(err)
}
return *da, *db_, sa, sb
}
func TestScopedAdminOnlySeesOwnDomainInList(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domainA, domainB, _, _ := setupTwoTenants(t, app)
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
req := httptest.NewRequest(http.MethodGet, Prefix+"/domains", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
body := rec.Body.String()
if !strings.Contains(body, domainA.DomainName) {
t.Error("scoped admin's own domain should appear in the domains list")
}
if strings.Contains(body, domainB.DomainName) {
t.Error("scoped admin must NOT see a domain outside their assignment")
}
}
func TestScopedAdminCannotAccessOtherTenantSenderByID(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domainA, _, _, senderB := setupTwoTenants(t, app)
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
// Direct URL access to a sender belonging to a domain they don't manage.
req := httptest.NewRequest(http.MethodGet, Prefix+"/senders/"+strconv.FormatInt(senderB.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 sender outside scope, got %d", rec.Code)
}
// Same for the mutating route — must not be able to disable it either.
form := url.Values{}
req2 := httptest.NewRequest(http.MethodPost, Prefix+"/senders/"+strconv.FormatInt(senderB.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 sender outside scope, got %d", rec2.Code)
}
stillActive, err := app.DB.GetSenderByID(senderB.ID)
if err != nil || stillActive == nil || !stillActive.IsActive {
t.Fatal("sender outside scope must not have been modified")
}
}
func TestScopedAdminCannotCreateSenderOnUnownedDomain(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+"/senders/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 sender on an unowned domain, got %d", rec.Code)
}
if s, _ := app.DB.GetSenderByEmail("mallory@" + domainB.DomainName); s != nil {
t.Fatal("sender must not have been created on a domain outside the admin's scope")
}
}
func TestNewDomainAutoGrantedToScopedCreator(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domainA, _, _, _ := setupTwoTenants(t, app)
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
userID, err := app.DB.GetAdminUserByUsername("tenant-a-admin")
if err != nil || userID == nil {
t.Fatal("expected the scoped admin to exist")
}
form := url.Values{"domain_name": {"brand-new.example"}}
req := httptest.NewRequest(http.MethodPost, Prefix+"/domains/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 domain, got %d: %s", rec.Code, rec.Body.String())
}
newDomain, err := app.DB.GetDomainByNameExact("brand-new.example")
if err != nil || newDomain == nil {
t.Fatalf("expected domain to be created: %v", err)
}
ids, err := app.DB.AccessibleDomainIDs(userID.ID)
if err != nil {
t.Fatal(err)
}
found := false
for _, id := range ids {
if id == newDomain.ID {
found = true
}
}
if !found {
t.Fatal("a scoped admin who creates a domain must automatically get access to it")
}
}
func TestScopedAdminCannotDelegateUnownedDomain(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{
"username": {"sub-admin"},
"password": {"sub-admin-password-1!"},
"domain_ids": {strconv.FormatInt(domainA.ID, 10), strconv.FormatInt(domainB.ID, 10)}, // B isn't theirs
}
req := httptest.NewRequest(http.MethodPost, Prefix+"/admins/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 (with an error flash), got %d", rec.Code)
}
if u, _ := app.DB.GetAdminUserByUsername("sub-admin"); u != nil {
t.Fatal("admin creation must be rejected outright when it tries to delegate a domain outside the creator's own scope")
}
}
func TestScopedAdminCanDelegateOwnedDomainAndManageResultingAdmin(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domainA, _, _, _ := setupTwoTenants(t, app)
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
form := url.Values{
"username": {"sub-admin"},
"password": {"sub-admin-password-1!"},
"domain_ids": {strconv.FormatInt(domainA.ID, 10)},
}
req := httptest.NewRequest(http.MethodPost, Prefix+"/admins/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 delegating an owned domain, got %d: %s", rec.Code, rec.Body.String())
}
subAdmin, err := app.DB.GetAdminUserByUsername("sub-admin")
if err != nil || subAdmin == nil {
t.Fatalf("expected sub-admin to be created: %v", err)
}
// The delegating admin must be able to see and manage the new sub-admin, per the
// "any admin within scope, not just ones I personally created" rule.
listReq := httptest.NewRequest(http.MethodGet, Prefix+"/admins", nil)
listReq.AddCookie(cookie)
listRec := httptest.NewRecorder()
mux.ServeHTTP(listRec, listReq)
if !strings.Contains(listRec.Body.String(), "sub-admin") {
t.Fatal("delegating admin should see the newly created sub-admin in their admin list")
}
}
func TestGlobalAdminSeesEverything(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domainA, domainB, _, _ := setupTwoTenants(t, app)
cookie := loginSession(t, app) // global admin
req := httptest.NewRequest(http.MethodGet, Prefix+"/domains", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
body := rec.Body.String()
if !strings.Contains(body, domainA.DomainName) || !strings.Contains(body, domainB.DomainName) {
t.Fatal("global admin must see every domain regardless of scoped assignments")
}
}
+185
View File
@@ -0,0 +1,185 @@
package webui
import (
"fmt"
"html/template"
"net/http"
"strconv"
"strings"
"time"
"gopkg.in/ini.v1"
"mailgoserver/internal/db"
)
// M is the per-page template data map — mirrors the kwargs Flask's render_template(...)
// is called with. Using a map (not per-page structs) keeps 18 template contexts from
// needing 18 Go struct types.
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 {
if t.IsZero() {
return ""
}
return t.Format(pyToGoLayout(layout))
},
"title": strings.Title,
"upper": strings.ToUpper,
"lower": strings.ToLower,
"safe": func(s string) template.HTML { return template.HTML(s) },
"filesize": humanFileSize,
"dotToDash": func(s string) string { return strings.ReplaceAll(s, ".", "-") },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"eq2": func(a, b any) bool { return fmt.Sprint(a) == fmt.Sprint(b) },
// dget looks up an optional map key, returning "" if absent — mirrors Jinja's
// `x if x is defined else ''` pattern used for context vars only some pages set
// (e.g. sidebar badge counts, which only dashboard passes).
"dget": func(m M, key string) any {
if v, ok := m[key]; ok {
return v
}
return ""
},
"list": func(items ...string) []string { return items },
// emailOverallStatus mirrors the delivered/failed selectattr computation
// dashboard.html and logs.html both do in the Python templates.
"emailOverallStatus": func(recipients []db.EmailRecipientLog) string {
delivered, failed := 0, 0
for _, r := range recipients {
if r.Status == "success" {
delivered++
} else {
failed++
}
}
switch {
case delivered > 0 && failed > 0:
return "partial"
case delivered > 0:
return "relayed"
default:
return "failed"
}
},
}
}
// pyToGoLayout converts the handful of Python strftime directives this app actually
// uses into Go's reference-time layout.
func pyToGoLayout(py string) string {
r := strings.NewReplacer(
"%Y", "2006", "%m", "01", "%d", "02",
"%H", "15", "%M", "04", "%S", "05",
)
return r.Replace(py)
}
func formatDatetimeInZone(t time.Time, cfg *ini.File) string {
if t.IsZero() {
return ""
}
tzName := cfg.Section("Server").Key("time_zone").MustString("UTC")
loc, err := time.LoadLocation(tzName)
if err != nil {
loc = time.UTC
}
return t.In(loc).Format("2006-01-02 15:04:05")
}
func humanFileSize(size int64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
}
// pages lists every template that extends base.html — each gets its own isolated
// template set (base.html + sidebar_email.html + itself) so their same-named
// {{define "content"}} blocks don't collide with each other (see loadTemplates).
var pages = []string{
"dashboard.html", "domains.html", "add_domain.html", "edit_domain.html",
"senders.html", "add_sender.html", "edit_sender.html",
"ips.html", "add_ip.html", "edit_ip.html",
"dkim.html", "edit_dkim.html",
"settings.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"}
// 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
// directory without needing the source tree alongside it.
func (a *App) loadTemplates() error {
a.templates = map[string]*template.Template{}
for _, page := range pages {
t := template.New("base.html").Funcs(a.funcMap())
t, err := t.ParseFS(assets, "templates/base.html", "templates/sidebar_email.html", "templates/"+page)
if err != nil {
return fmt.Errorf("parse %s: %w", page, err)
}
a.templates[page] = t
}
for _, page := range standalonePages {
t := template.New(page).Funcs(a.funcMap())
t, err := t.ParseFS(assets, "templates/"+page)
if err != nil {
return fmt.Errorf("parse %s: %w", page, err)
}
a.templates[page] = t
}
return nil
}
func isStandalonePage(page string) bool {
for _, p := range standalonePages {
if p == page {
return true
}
}
return false
}
// render executes the named page template — as itself for standalone (pre-login)
// pages, or as "base.html" for everything else — mirroring flask.render_template.
func (a *App) render(w http.ResponseWriter, r *http.Request, page string, data M) {
t, ok := a.templates[page]
if !ok {
http.Error(w, "template not found: "+page, http.StatusInternalServerError)
return
}
if data == nil {
data = M{}
}
if isStandalonePage(page) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, page, data); err != nil {
a.Logger.Error("template render error (%s): %v", page, err)
}
return
}
data["flashes"] = popFlashes(w, r)
data["health"] = a.checkHealth()
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)
}
}
func atoi(s string) int {
n, _ := strconv.Atoi(s)
return n
}
+222
View File
@@ -0,0 +1,222 @@
package webui
import (
"net/http"
"regexp"
"strings"
"mailgoserver/internal/db"
)
// validLocalPart mirrors the RFC-5321-ish practical subset most mail systems accept
// for the part of an address before "@" — letters, digits, and . _ % + -.
var validLocalPart = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+$`)
// buildSenderEmail resolves the domain by ID (so the client can't just type an
// arbitrary domain string) and joins it with the local part — this is what actually
// fixes "any text accepted, then assigned to an unrelated domain": the address is
// always <local_part>@<selected domain's real name>, never free text.
func (a *App) buildSenderEmail(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 localPartOf(email string) string {
if i := strings.Index(email, "@"); i >= 0 {
return email[:i]
}
return email
}
func (a *App) sendersList(w http.ResponseWriter, r *http.Request) {
senders, err := a.DB.ListSenders()
if err != nil {
setFlash(w, "error", "Error loading senders")
}
scope := scopeFromContext(r)
// Templates use {% for sender, domain in senders %} — pair each row up as [sender, domain].
var pairs [][2]any
for _, s := range senders {
if !scope.Allowed(s.DomainID) {
continue
}
pairs = append(pairs, [2]any{s.Sender, M{"domain_name": s.DomainName}})
}
a.render(w, r, "senders.html", M{"active": "senders", "senders": pairs, "users": pairs})
}
func (a *App) addSenderForm(w http.ResponseWriter, r *http.Request) {
domains, _ := a.accessibleDomains(r)
a.render(w, r, "add_sender.html", M{"active": "senders", "domains": domains})
}
// addSender mirrors senders.py's add_sender() POST branch. The email is always
// <local_part>@<selected domain>, built server-side from the resolved domain name —
// see buildSenderEmail — so a sender can never end up assigned to a domain its own
// address doesn't belong to.
func (a *App) addSender(w http.ResponseWriter, r *http.Request) {
localPart := strings.TrimSpace(r.FormValue("local_part"))
password := r.FormValue("password")
domainID := int64(atoi(r.FormValue("domain_id")))
canSendAsDomain := r.FormValue("can_send_as_domain") == "on"
storeMessage := r.FormValue("store_message_content") == "on"
if !requireDomainAccess(w, r, domainID) {
return
}
email, err := a.buildSenderEmail(localPart, domainID)
if err != nil {
setFlash(w, "error", "Error creating sender")
http.Redirect(w, r, Prefix+"/senders/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+"/senders/add", http.StatusFound)
return
}
if exists, _ := a.DB.EmailExists(email, -1); exists {
setFlash(w, "error", "A sender with this email already exists")
http.Redirect(w, r, Prefix+"/senders/add", http.StatusFound)
return
}
hash, err := db.HashPassword(password)
if err != nil {
setFlash(w, "error", "Error creating sender")
http.Redirect(w, r, Prefix+"/senders/add", http.StatusFound)
return
}
if _, err := a.DB.CreateSender(email, hash, domainID, canSendAsDomain, storeMessage); err != nil {
setFlash(w, "error", "Error creating sender")
http.Redirect(w, r, Prefix+"/senders/add", http.StatusFound)
return
}
setFlash(w, "success", "Sender added successfully")
http.Redirect(w, r, Prefix+"/senders", http.StatusFound)
}
// senderWithAccess fetches a sender by path ID and confirms it belongs to a domain the
// current admin can manage, writing 404 and returning ok=false otherwise.
func (a *App) senderWithAccess(w http.ResponseWriter, r *http.Request) (sender *db.Sender, ok bool) {
sender, err := a.DB.GetSenderByID(pathID(r))
if err != nil || sender == nil {
http.NotFound(w, r)
return nil, false
}
if !requireDomainAccess(w, r, sender.DomainID) {
return nil, false
}
return sender, true
}
func (a *App) disableSender(w http.ResponseWriter, r *http.Request) {
sender, ok := a.senderWithAccess(w, r)
if !ok {
return
}
if err := a.DB.SetSenderActive(sender.ID, false); err != nil {
setFlash(w, "error", "Error disabling sender")
} else {
setFlash(w, "success", "Sender disabled")
}
http.Redirect(w, r, Prefix+"/senders", http.StatusFound)
}
func (a *App) enableSender(w http.ResponseWriter, r *http.Request) {
sender, ok := a.senderWithAccess(w, r)
if !ok {
return
}
if err := a.DB.SetSenderActive(sender.ID, true); err != nil {
setFlash(w, "error", "Error enabling sender")
} else {
setFlash(w, "success", "Sender enabled")
}
http.Redirect(w, r, Prefix+"/senders", http.StatusFound)
}
func (a *App) removeSender(w http.ResponseWriter, r *http.Request) {
sender, ok := a.senderWithAccess(w, r)
if !ok {
return
}
if err := a.DB.RemoveSender(sender.ID); err != nil {
setFlash(w, "error", "Error removing sender")
} else {
setFlash(w, "success", "Sender permanently removed")
}
http.Redirect(w, r, Prefix+"/senders", http.StatusFound)
}
func (a *App) editSenderForm(w http.ResponseWriter, r *http.Request) {
sender, ok := a.senderWithAccess(w, r)
if !ok {
return
}
domains, _ := a.accessibleDomains(r)
a.render(w, r, "edit_sender.html", M{"active": "senders", "sender": sender, "domains": domains, "local_part": localPartOf(sender.Email)})
}
// editSender mirrors senders.py's edit_sender() POST branch. As in addSender, the
// email is rebuilt server-side from local_part + the resolved domain, never trusted
// as free text — see buildSenderEmail. Both the sender's current domain and the
// (possibly different) target domain from the form must be within the admin's scope.
func (a *App) editSender(w http.ResponseWriter, r *http.Request) {
sender, ok := a.senderWithAccess(w, r)
if !ok {
return
}
id := sender.ID
domains, _ := a.accessibleDomains(r)
localPart := strings.TrimSpace(r.FormValue("local_part"))
password := r.FormValue("password")
domainID := int64(atoi(r.FormValue("domain_id")))
canSendAsDomain := r.FormValue("can_send_as_domain") == "on"
storeMessage := r.FormValue("store_message_content") == "on"
renderErr := func(msg string) {
setFlash(w, "error", msg)
a.render(w, r, "edit_sender.html", M{"active": "senders", "sender": sender, "domains": domains, "local_part": localPart})
}
if !requireDomainAccess(w, r, domainID) {
return
}
email, err := a.buildSenderEmail(localPart, domainID)
if err != nil {
renderErr("Error updating sender")
return
}
if email == "" {
renderErr("Please provide a valid local part (letters, numbers, and . _ % + - only) and domain")
return
}
if exists, _ := a.DB.EmailExists(email, id); exists {
renderErr("A sender with this email already exists")
return
}
var hash string
if password != "" {
hash, err = db.HashPassword(password)
if err != nil {
setFlash(w, "error", "Error updating sender")
http.Redirect(w, r, Prefix+"/senders", http.StatusFound)
return
}
}
if err := a.DB.UpdateSender(id, email, hash, domainID, canSendAsDomain, storeMessage); err != nil {
setFlash(w, "error", "Error updating sender")
http.Redirect(w, r, Prefix+"/senders", http.StatusFound)
return
}
setFlash(w, "success", "Sender updated successfully")
http.Redirect(w, r, Prefix+"/senders", http.StatusFound)
}
+66
View File
@@ -0,0 +1,66 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
// TestAddSenderEmailAlwaysMatchesSelectedDomain guards against the bug where the
// sender's email was a free-text field independent of the selected domain, so a
// sender like bob@gogl.as could be filed under an unrelated domain (e.g.
// freebede.com). The email must always be <local_part>@<the domain actually
// selected>, never whatever the client sends in an email-shaped field.
func TestAddSenderEmailAlwaysMatchesSelectedDomain(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
cookie := loginSession(t, app)
domains, err := app.DB.ListDomains()
if err != nil || len(domains) == 0 {
t.Fatalf("expected seeded domain: %v", err)
}
realDomain := domains[0]
form := url.Values{
"local_part": {"bob"},
"domain_id": {strconv.FormatInt(realDomain.ID, 10)},
"password": {"password123"},
}
req := httptest.NewRequest(http.MethodPost, Prefix+"/senders/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 adding sender, got %d: %s", rec.Code, rec.Body.String())
}
sender, err := app.DB.GetSenderByEmail("bob@" + realDomain.DomainName)
if err != nil || sender == nil {
t.Fatalf("expected sender bob@%s to exist: %v", realDomain.DomainName, err)
}
if sender.DomainID != realDomain.ID {
t.Fatalf("sender domain_id = %d, want %d (the domain actually selected)", sender.DomainID, realDomain.ID)
}
// A local part that isn't a bare identifier (e.g. tries to smuggle a different
// domain) must be rejected rather than silently accepted.
badForm := url.Values{
"local_part": {"mallory@evil.example"},
"domain_id": {strconv.FormatInt(realDomain.ID, 10)},
"password": {"password123"},
}
badReq := httptest.NewRequest(http.MethodPost, Prefix+"/senders/add", strings.NewReader(badForm.Encode()))
badReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
badReq.AddCookie(cookie)
badRec := httptest.NewRecorder()
mux.ServeHTTP(badRec, badReq)
if s, _ := app.DB.GetSenderByEmail("mallory@evil.example"); s != nil {
t.Fatal("a local part containing '@' must never produce a sender at an arbitrary domain")
}
}
+184
View File
@@ -0,0 +1,184 @@
package webui
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite"
)
var timezoneNames = []string{
"UTC", "Europe/London", "Europe/Berlin", "Europe/Paris", "Europe/Madrid", "Europe/Rome",
"America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
"Asia/Tokyo", "Asia/Shanghai", "Asia/Kolkata", "Asia/Dubai", "Australia/Sydney",
}
// settingsPage mirrors settings.py's settings().
func (a *App) settingsPage(w http.ResponseWriter, r *http.Request) {
sections := M{}
for _, name := range a.Cfg.SectionStrings() {
sec := a.Cfg.Section(name)
kv := M{}
for _, k := range sec.Keys() {
kv[strings.ToLower(k.Name())] = k.Value()
}
sections[name] = kv
}
a.render(w, r, "settings.html", M{"active": "settings", "settings": sections, "timezones": timezoneNames})
}
// settingsUpdate mirrors settings.py's settings_update(): iterate every existing
// Section.key, update from the matching form field if present and different, write
// settings.ini back out. Preserves the '""'-means-empty convention for server_banner.
func (a *App) settingsUpdate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
setFlash(w, "error", "Invalid form data")
http.Redirect(w, r, Prefix+"/settings", http.StatusFound)
return
}
changed := false
for _, name := range a.Cfg.SectionStrings() {
sec := a.Cfg.Section(name)
for _, k := range sec.Keys() {
field := name + "." + k.Name()
if !r.Form.Has(field) {
continue
}
val := r.FormValue(field)
if name == "Server" && strings.EqualFold(k.Name(), "server_banner") && strings.TrimSpace(val) == "" {
val = `""`
}
if val != k.Value() {
k.SetValue(val)
changed = true
}
}
}
if !changed {
setFlash(w, "info", "No changes were made")
http.Redirect(w, r, Prefix+"/settings", http.StatusFound)
return
}
if err := a.Cfg.SaveTo(a.ConfigPath); err != nil {
setFlash(w, "error", "Error saving settings: "+err.Error())
http.Redirect(w, r, Prefix+"/settings", http.StatusFound)
return
}
setFlash(w, "success", "Settings saved. Restart the server for changes to take effect.")
http.Redirect(w, r, Prefix+"/settings", http.StatusFound)
}
// testDatabaseConnection mirrors settings.py's test_database_connection_endpoint,
// backed by a Go equivalent of database.py's test_database_connection. Unlike the
// Python version (which pip-installs a driver at request time for mysql/postgresql/
// mssql), this build only compiles in the sqlite driver — other schemes report clearly
// as unsupported rather than attempting a live package install from a web request.
func (a *App) testDatabaseConnection(w http.ResponseWriter, r *http.Request) {
var body struct {
URL string `json:"url"`
}
if err := decodeJSONBody(r, &body); err != nil || body.URL == "" {
writeJSON(w, http.StatusOK, M{"status": "error", "message": "No database URL provided"})
return
}
switch {
case strings.HasPrefix(body.URL, "sqlite:///"):
path := strings.TrimPrefix(body.URL, "sqlite:///")
db, err := sql.Open("sqlite", path)
if err != nil {
writeJSON(w, http.StatusOK, M{"status": "error", "message": err.Error()})
return
}
defer db.Close()
if err := db.Ping(); err != nil {
writeJSON(w, http.StatusOK, M{"status": "error", "message": err.Error()})
return
}
writeJSON(w, http.StatusOK, M{"status": "success", "message": "SQLite connection successful"})
case strings.HasPrefix(body.URL, "mysql://"), strings.HasPrefix(body.URL, "postgresql://"), strings.HasPrefix(body.URL, "mssql"):
writeJSON(w, http.StatusOK, M{"status": "error", "message": "This database type isn't compiled into this build"})
default:
writeJSON(w, http.StatusOK, M{"status": "error", "message": "Unrecognized database URL scheme"})
}
}
func decodeJSONBody(r *http.Request, v any) error {
defer r.Body.Close()
return json.NewDecoder(r.Body).Decode(v)
}
// uploadCert mirrors settings.py's upload_cert().
func (a *App) uploadCert(w http.ResponseWriter, r *http.Request) {
a.uploadTLSFile(w, r, "cert_file", "crt")
}
// uploadKey mirrors settings.py's upload_key().
func (a *App) uploadKey(w http.ResponseWriter, r *http.Request) {
a.uploadTLSFile(w, r, "key_file", "key")
}
func (a *App) uploadTLSFile(w http.ResponseWriter, r *http.Request, field, forcedExt string) {
if err := r.ParseMultipartForm(10 << 20); err != nil {
writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "Invalid upload"})
return
}
file, header, err := r.FormFile(field)
if err != nil {
writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "No file provided"})
return
}
defer file.Close()
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(header.Filename), "."))
if ext != "crt" && ext != "key" && ext != "pem" {
writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "Invalid file extension"})
return
}
sslDir := filepath.Join(filepath.Dir(a.ConfigPath), "ssl_certs")
os.MkdirAll(sslDir, 0o755)
filePath := filepath.Join(sslDir, fmt.Sprintf("server%d.%s", time.Now().Unix(), forcedExt))
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})
}
// getServerIP mirrors settings.py's get_server_ip().
func (a *App) getServerIP(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, M{"status": "success", "ip": getPublicIP(a.Cfg)})
}
// testAttachmentsPath mirrors settings.py's test_attachments_path().
func (a *App) testAttachmentsPath(w http.ResponseWriter, r *http.Request) {
path := r.FormValue("path")
if path == "" {
writeJSON(w, http.StatusOK, M{"success": false, "message": "No path provided"})
return
}
if !filepath.IsAbs(path) {
path = filepath.Join(filepath.Dir(a.ConfigPath), path)
}
if err := os.MkdirAll(path, 0o755); err != nil {
writeJSON(w, http.StatusOK, M{"success": false, "message": err.Error()})
return
}
testFile := filepath.Join(path, ".write_test")
if err := os.WriteFile(testFile, []byte("test"), 0o644); err != nil {
writeJSON(w, http.StatusOK, M{"success": false, "message": "Path is not writable: " + err.Error()})
return
}
os.Remove(testFile)
writeJSON(w, http.StatusOK, M{"success": true, "message": "Path is valid and writable", "absolute_path": path})
}
@@ -0,0 +1,260 @@
/* Custom CSS for SMTP Management Frontend */
/* Enhanced dark theme tweaks */
.card {
border: 1px solid #404040;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.card-header {
border-bottom: 1px solid #404040;
}
.table-dark {
--bs-table-bg: #2d3748;
--bs-table-striped-bg: #374151;
}
/* Status badges */
.status-active {
background-color: #10b981 !important;
}
.status-inactive {
background-color: #ef4444 !important;
}
.status-pending {
background-color: #f59e0b !important;
}
/* Custom form styling */
.form-control:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
}
.form-select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
}
/* Copy button styling */
.copy-btn {
position: relative;
overflow: hidden;
}
.copy-btn::after {
content: "Copied!";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #10b981;
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
opacity: 0;
transition: opacity 0.3s ease;
}
.copy-btn.copied::after {
opacity: 1;
}
/* DNS record styling */
.dns-record {
background-color: #1f2937;
border: 1px solid #374151;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 14px;
padding: 12px;
margin: 8px 0;
}
.dns-record-header {
color: #9ca3af;
font-weight: bold;
margin-bottom: 4px;
}
.dns-record-value {
color: #e5e7eb;
word-break: break-all;
}
/* Log entry styling */
.log-entry {
border-left: 4px solid #374151;
padding-left: 12px;
margin-bottom: 8px;
}
.log-entry.log-error {
border-left-color: #ef4444;
}
.log-entry.log-warning {
border-left-color: #f59e0b;
}
.log-entry.log-info {
border-left-color: #3b82f6;
}
.log-entry.log-success {
border-left-color: #10b981;
}
/* Statistics cards */
.stat-card {
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
border: 1px solid #4b5563;
border-radius: 8px;
padding: 20px;
text-align: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
}
.stat-number {
font-size: 2.5rem;
font-weight: bold;
color: #3b82f6;
}
.stat-label {
color: #9ca3af;
font-size: 0.9rem;
margin-top: 8px;
}
/* Loading states */
.loading {
opacity: 0.6;
pointer-events: none;
}
.spinner-border-sm {
width: 1rem;
height: 1rem;
}
/* Responsive table wrapper */
.table-responsive {
border-radius: 8px;
border: 1px solid #404040;
}
/* Alert styling */
.alert {
border: none;
border-radius: 8px;
}
.alert-success {
background-color: rgba(16, 185, 129, 0.1);
color: #10b981;
border-left: 4px solid #10b981;
}
.alert-danger {
background-color: rgba(239, 68, 68, 0.1);
color: #ef4444;
border-left: 4px solid #ef4444;
}
.alert-warning {
background-color: rgba(245, 158, 11, 0.1);
color: #f59e0b;
border-left: 4px solid #f59e0b;
}
.alert-info {
background-color: rgba(59, 130, 246, 0.1);
color: #3b82f6;
border-left: 4px solid #3b82f6;
}
/* Custom scrollbar */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #4b5563 #1f2937;
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #1f2937;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #4b5563;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: #6b7280;
}
/* Animation classes */
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.slide-in {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from { transform: translateX(-20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* Tooltip styling */
.tooltip {
font-size: 12px;
}
.tooltip-inner {
background-color: #1f2937;
border: 1px solid #374151;
}
.tooltip.bs-tooltip-top .tooltip-arrow::before {
border-top-color: #374151;
}
.tooltip.bs-tooltip-bottom .tooltip-arrow::before {
border-bottom-color: #374151;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.stat-number {
font-size: 1.8rem;
}
.dns-record {
font-size: 12px;
padding: 8px;
}
.table-responsive {
font-size: 14px;
}
}
+266
View File
@@ -0,0 +1,266 @@
// DKIM Management functionality
const DKIMManagement = {
// Check DNS records for a domain
checkDomainDNS: async function(domain, selector, checkDkimUrl, checkSpfUrl) {
const dkimStatus = document.getElementById(`dkim-status-${domain.replace('.', '-')}`);
const spfStatus = document.getElementById(`spf-status-${domain.replace('.', '-')}`);
// Show loading state
dkimStatus.innerHTML = '<span class="status-indicator status-warning"></span><small class="text-muted">Checking...</small>';
spfStatus.innerHTML = '<span class="status-indicator status-warning"></span><small class="text-muted">Checking...</small>';
try {
// Check DKIM DNS
const dkimResponse = await fetch(checkDkimUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
domain: domain,
selector: selector
})
});
const dkimResult = await dkimResponse.json();
// Check SPF DNS
const spfResponse = await fetch(checkSpfUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
domain: domain
})
});
const spfResult = await spfResponse.json();
// Get DKIM key status from the card class
const domainCard = document.getElementById(`domain-${domain.replace('.', '-')}`);
const isActive = domainCard && domainCard.classList.contains('dkim-active');
// Update DKIM status based on active state and DNS visibility
if (isActive) {
if (dkimResult.success) {
dkimStatus.innerHTML = '<span class="status-indicator status-success"></span><small class="text-success">✓ Active & Configured</small>';
} else {
dkimStatus.innerHTML = '<span class="status-indicator" style="background-color: #fd7e14;"></span><small class="text-warning">Active but DNS not found</small>';
}
} else {
dkimStatus.innerHTML = '<span class="status-indicator" style="background-color: #6c757d;"></span><small class="text-muted">Disabled</small>';
}
// Update SPF status
if (spfResult.success) {
spfStatus.innerHTML = '<span class="status-indicator status-success"></span><small class="text-success">✓ Found</small>';
} else {
spfStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">✗ Not found</small>';
}
// Show detailed results in modal
this.showDNSResults(domain, dkimResult, spfResult);
} catch (error) {
console.error('DNS check error:', error);
dkimStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">Error</small>';
spfStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">Error</small>';
}
},
// Show DNS check results in modal
showDNSResults: function(domain, dkimResult, spfResult) {
// Clean up record strings by removing extra quotes and normalizing whitespace
function cleanRecordDisplay(record) {
if (!record) return '';
return record
.replace(/^["']|["']$/g, '') // Remove outer quotes
.replace(/\\n/g, '') // Remove newlines
.replace(/\s+/g, ' ') // Normalize whitespace
.trim(); // Remove leading/trailing space
}
const dkimRecordsHtml = dkimResult.records ?
dkimResult.records.map(record =>
`<div class="record-value" style="word-break: break-all; font-family: monospace; background: #f8f9fa; padding: 8px; border-radius: 4px;">
${cleanRecordDisplay(record)}
</div>`
).join('') : '';
const spfRecordHtml = spfResult.spf_record ?
`<div class="record-value mt-2" style="word-break: break-all; font-family: monospace; background: #f8f9fa; padding: 8px; border-radius: 4px;">
${cleanRecordDisplay(spfResult.spf_record)}
</div>` : '';
const resultsHtml = `
<h6>DNS Check Results for ${domain}</h6>
<div class="mb-3">
<h6 class="text-primary">DKIM Record</h6>
<div class="alert ${dkimResult.success ? 'alert-success' : 'alert-danger'}">
<strong>Status:</strong> ${dkimResult.success ? 'Found' : 'Not Found'}<br>
<strong>Message:</strong> ${dkimResult.message}
${dkimResult.records ? `
<br><strong>Records:</strong>
<div class="records-container mt-2">
${dkimRecordsHtml}
</div>
` : ''}
</div>
</div>
<div class="mb-3">
<h6 class="text-primary">SPF Record</h6>
<div class="alert ${spfResult.success ? 'alert-success' : 'alert-danger'}">
<strong>Status:</strong> ${spfResult.success ? 'Found' : 'Not Found'}<br>
<strong>Message:</strong> ${spfResult.message}
${spfResult.spf_record ? `
<br><strong>Current SPF:</strong>
${spfRecordHtml}
` : ''}
</div>
</div>
`;
document.getElementById('dnsResults').innerHTML = resultsHtml;
new bootstrap.Modal(document.getElementById('dnsResultModal')).show();
},
// Check all domains' DNS records
checkAllDNS: async function(checkDkimUrl, checkSpfUrl) {
const domains = document.querySelectorAll('[id^="domain-"]');
const results = [];
// Show a progress indicator
showToast('Checking DNS records for all domains...', 'info');
for (const domainCard of domains) {
try {
const domainId = domainCard.id.split('-')[1];
// Extract domain name from the card header
const domainHeaderText = domainCard.querySelector('h5').textContent.trim();
const domainName = domainHeaderText.split('\n')[0].trim().replace(/^\s*\S+\s+/, ''); // Remove icon
const selectorElement = domainCard.querySelector('code');
if (selectorElement) {
const selector = selectorElement.textContent;
// Check DKIM DNS
const dkimResponse = await fetch(checkDkimUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `domain=${encodeURIComponent(domainName)}&selector=${encodeURIComponent(selector)}`
});
const dkimResult = await dkimResponse.json();
// Check SPF DNS
const spfResponse = await fetch(checkSpfUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `domain=${encodeURIComponent(domainName)}`
});
const spfResult = await spfResponse.json();
results.push({
domain: domainName,
dkim: dkimResult,
spf: spfResult
});
// Update individual status indicators
const dkimStatus = document.getElementById(`dkim-status-${domainName.replace('.', '-')}`);
const spfStatus = document.getElementById(`spf-status-${domainName.replace('.', '-')}`);
if (dkimStatus) {
if (dkimResult.success) {
dkimStatus.innerHTML = '<span class="status-indicator status-success"></span><small class="text-success">✓ Configured</small>';
} else {
dkimStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">✗ Not found</small>';
}
}
if (spfStatus) {
if (spfResult.success) {
spfStatus.innerHTML = '<span class="status-indicator status-success"></span><small class="text-success">✓ Found</small>';
} else {
spfStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">✗ Not found</small>';
}
}
// Small delay between checks to avoid overwhelming the DNS server
await new Promise(resolve => setTimeout(resolve, 300));
}
} catch (error) {
console.error('Error checking DNS for domain:', error);
}
}
// Show combined results in modal
this.showAllDNSResults(results);
},
// Show combined DNS check results
showAllDNSResults: function(results) {
let tableRows = '';
results.forEach(result => {
const dkimIcon = result.dkim.success ? '<i class="bi bi-check-circle-fill text-success"></i>' : '<i class="bi bi-x-circle-fill text-danger"></i>';
const spfIcon = result.spf.success ? '<i class="bi bi-check-circle-fill text-success"></i>' : '<i class="bi bi-x-circle-fill text-danger"></i>';
tableRows += `
<tr>
<td><strong>${result.domain}</strong></td>
<td class="text-center">
${dkimIcon}
<small class="d-block">${result.dkim.success ? 'Configured' : 'Not Found'}</small>
</td>
<td class="text-center">
${spfIcon}
<small class="d-block">${result.spf.success ? 'Found' : 'Not Found'}</small>
</td>
<td>
<small class="text-muted">
DKIM: ${result.dkim.message}<br>
SPF: ${result.spf.message}
</small>
</td>
</tr>
`;
});
const resultsHtml = `
<h6>DNS Check Results for All Domains</h6>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Domain</th>
<th class="text-center">DKIM Status</th>
<th class="text-center">SPF Status</th>
<th>Details</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
</div>
<div class="mt-3">
<div class="alert alert-info">
<small>
<i class="bi bi-info-circle me-1"></i>
<strong>DKIM:</strong> Verifies email signatures for authenticity<br>
<i class="bi bi-info-circle me-1"></i>
<strong>SPF:</strong> Authorizes servers that can send email for your domain
</small>
</div>
</div>
`;
document.getElementById('dnsResults').innerHTML = resultsHtml;
new bootstrap.Modal(document.getElementById('dnsResultModal')).show();
}
};
+285
View File
@@ -0,0 +1,285 @@
/* Custom JavaScript for SMTP Management Frontend */
// Global utilities
const SMTPManagement = {
// Copy text to clipboard
copyToClipboard: function(text, button) {
navigator.clipboard.writeText(text).then(() => {
this.showCopySuccess(button);
}).catch(err => {
console.error('Failed to copy: ', err);
this.showCopyError(button);
});
},
// Show copy success feedback
showCopySuccess: function(button) {
const originalText = button.innerHTML;
button.innerHTML = '<i class="fas fa-check me-1"></i>Copied!';
button.classList.remove('btn-outline-light');
button.classList.add('btn-success');
setTimeout(() => {
button.innerHTML = originalText;
button.classList.remove('btn-success');
button.classList.add('btn-outline-light');
}, 2000);
},
// Show copy error feedback
showCopyError: function(button) {
const originalText = button.innerHTML;
button.innerHTML = '<i class="fas fa-times me-1"></i>Failed!';
button.classList.remove('btn-outline-light');
button.classList.add('btn-danger');
setTimeout(() => {
button.innerHTML = originalText;
button.classList.remove('btn-danger');
button.classList.add('btn-outline-light');
}, 2000);
},
// Format timestamps
formatTimestamp: function(timestamp) {
const date = new Date(timestamp);
return date.toLocaleString();
},
// Validate email address
validateEmail: function(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
},
// Validate IP address
validateIP: function(ip) {
const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
},
// Show loading state
showLoading: function(element) {
element.classList.add('loading');
const spinner = element.querySelector('.spinner-border');
if (spinner) {
spinner.style.display = 'inline-block';
}
},
// Hide loading state
hideLoading: function(element) {
element.classList.remove('loading');
const spinner = element.querySelector('.spinner-border');
if (spinner) {
spinner.style.display = 'none';
}
},
// Show toast notification
showToast: function(message, type = 'info') {
const toastContainer = document.getElementById('toast-container') || this.createToastContainer();
const toast = this.createToast(message, type);
toastContainer.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
toast.remove();
}, 5000);
},
// Create toast container
createToastContainer: function() {
const container = document.createElement('div');
container.id = 'toast-container';
container.className = 'position-fixed top-0 end-0 p-3';
container.style.zIndex = '1056';
document.body.appendChild(container);
return container;
},
// Create toast element
createToast: function(message, type) {
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${type} border-0`;
toast.setAttribute('role', 'alert');
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
// Initialize Bootstrap toast
const bsToast = new bootstrap.Toast(toast);
bsToast.show();
return toast;
},
// Auto-refresh functionality
autoRefresh: function(url, interval = 30000) {
setInterval(() => {
fetch(url, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newContent = doc.querySelector('#refresh-content');
const currentContent = document.querySelector('#refresh-content');
if (newContent && currentContent) {
currentContent.innerHTML = newContent.innerHTML;
}
})
.catch(error => {
console.error('Auto-refresh failed:', error);
});
}, interval);
}
};
// DNS verification functionality
const DNSVerification = {
// Check DNS record
checkDNSRecord: function(domain, recordType, expectedValue) {
return fetch('/email/check-dns', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
domain: domain,
record_type: recordType,
expected_value: expectedValue
})
})
.then(response => response.json());
},
// Update DNS status indicator
updateDNSStatus: function(element, status, message) {
const statusIcon = element.querySelector('.dns-status-icon');
const statusText = element.querySelector('.dns-status-text');
if (statusIcon && statusText) {
statusIcon.className = `dns-status-icon fas ${status === 'valid' ? 'fa-check-circle text-success' : 'fa-times-circle text-danger'}`;
statusText.textContent = message;
}
}
};
// Form validation
const FormValidation = {
// Real-time email validation
validateEmailField: function(input) {
const isValid = SMTPManagement.validateEmail(input.value);
this.updateFieldStatus(input, isValid, 'Please enter a valid email address');
return isValid;
},
// Real-time IP validation
validateIPField: function(input) {
const isValid = SMTPManagement.validateIP(input.value);
this.updateFieldStatus(input, isValid, 'Please enter a valid IP address');
return isValid;
},
// Update field validation status
updateFieldStatus: function(input, isValid, errorMessage) {
const feedback = input.parentNode.querySelector('.invalid-feedback');
if (isValid) {
input.classList.remove('is-invalid');
input.classList.add('is-valid');
if (feedback) feedback.textContent = '';
} else {
input.classList.remove('is-valid');
input.classList.add('is-invalid');
if (feedback) feedback.textContent = errorMessage;
}
}
};
// Initialize on DOM load
document.addEventListener('DOMContentLoaded', function() {
// Initialize tooltips
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.map(function(tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
// Initialize form validation
const emailInputs = document.querySelectorAll('input[type="email"]');
emailInputs.forEach(input => {
input.addEventListener('blur', () => FormValidation.validateEmailField(input));
});
const ipInputs = document.querySelectorAll('input[data-validate="ip"]');
ipInputs.forEach(input => {
input.addEventListener('blur', () => FormValidation.validateIPField(input));
});
// Initialize auto-refresh for logs page
if (document.querySelector('#logs-page')) {
SMTPManagement.autoRefresh(window.location.href, 30000);
}
// Initialize current IP detection
const currentIPSpan = document.querySelector('#current-ip');
if (currentIPSpan) {
fetch('https://api.ipify.org?format=json')
.then(response => response.json())
.then(data => {
currentIPSpan.textContent = data.ip;
})
.catch(() => {
currentIPSpan.textContent = 'Unable to detect';
});
}
// Initialize copy buttons
const copyButtons = document.querySelectorAll('.copy-btn');
copyButtons.forEach(button => {
button.addEventListener('click', function() {
const textToCopy = this.getAttribute('data-copy') || this.nextElementSibling.textContent;
SMTPManagement.copyToClipboard(textToCopy, this);
});
});
// Initialize DNS check buttons
const dnsCheckButtons = document.querySelectorAll('.dns-check-btn');
dnsCheckButtons.forEach(button => {
button.addEventListener('click', function() {
const domain = this.getAttribute('data-domain');
const recordType = this.getAttribute('data-record-type');
const expectedValue = this.getAttribute('data-expected-value');
const statusElement = this.closest('.dns-record').querySelector('.dns-status');
SMTPManagement.showLoading(this);
DNSVerification.checkDNSRecord(domain, recordType, expectedValue)
.then(result => {
DNSVerification.updateDNSStatus(statusElement, result.status, result.message);
SMTPManagement.hideLoading(this);
})
.catch(error => {
console.error('DNS check failed:', error);
DNSVerification.updateDNSStatus(statusElement, 'error', 'DNS check failed');
SMTPManagement.hideLoading(this);
});
});
});
});
// Export for use in other scripts
window.SMTPManagement = SMTPManagement;
window.DNSVerification = DNSVerification;
window.FormValidation = FormValidation;
+135
View File
@@ -0,0 +1,135 @@
{{define "title"}}Account Settings{{end}}
{{define "page_title"}}Account Settings{{end}}
{{define "content"}}
<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-person-circle me-2"></i>Profile</h5></div>
<div class="card-body">
<p><strong>Username:</strong> {{.user.Username}}</p>
<hr>
<h6>Change password</h6>
<form method="POST" action="/pymta-manager/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 minlength="10">
</div>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Update password</button>
</form>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-phone me-2"></i>Authenticator App (TOTP)</h5></div>
<div class="card-body">
{{if .user.TOTPEnabled}}
<p class="text-success"><i class="bi bi-check-circle me-1"></i>Enabled</p>
<form method="POST" action="/pymta-manager/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. Add an authenticator app (Google Authenticator, 1Password, etc.) as an optional second factor.</p>
<form method="POST" action="/pymta-manager/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}}
</div>
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-fingerprint me-2"></i>Passkeys / Security Keys</h5></div>
<div class="card-body">
{{if .passkeys}}
<ul class="list-group mb-3">
{{range .passkeys}}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span><i class="bi bi-key me-2"></i>{{.Name}} <small class="text-muted">added {{strftime "%Y-%m-%d" .CreatedAt}}</small></span>
<form method="POST" action="/pymta-manager/account/passkey/{{.ID}}/remove">
<button type="submit" class="btn btn-sm btn-outline-danger" data-confirm="Remove this passkey?">Remove</button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="text-muted">No passkeys registered yet.</p>
{{end}}
<div id="passkey-error" class="alert alert-danger d-none"></div>
<button type="button" class="btn btn-outline-primary btn-sm" id="add-passkey-btn"><i class="bi bi-plus-circle me-1"></i>Add a passkey</button>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<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(/=+$/, '');
}
document.getElementById('add-passkey-btn').addEventListener('click', async function() {
const errEl = document.getElementById('passkey-error');
errEl.classList.add('d-none');
try {
const name = prompt('Name this passkey (e.g. "YubiKey", "MacBook Touch ID"):', 'Passkey') || 'Passkey';
const beginResp = await fetch('/pymta-manager/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 credential = await navigator.credentials.create({ publicKey });
const body = {
id: credential.id,
rawId: bufToB64url(credential.rawId),
type: credential.type,
response: {
attestationObject: bufToB64url(credential.response.attestationObject),
clientDataJSON: bufToB64url(credential.response.clientDataJSON),
},
};
const finishResp = await fetch('/pymta-manager/account/passkey/finish?name=' + encodeURIComponent(name), {
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');
showToast('Passkey added', 'success');
setTimeout(() => location.reload(), 800);
} catch (e) {
errEl.textContent = e.message || 'Adding the passkey failed';
errEl.classList.remove('d-none');
}
});
</script>
{{end}}
+66
View File
@@ -0,0 +1,66 @@
{{define "title"}}Add Admin{{end}}
{{define "page_title"}}Add Admin{{end}}
{{define "content"}}
<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-person-plus me-2"></i>Add a new admin</h5></div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Initial password</label>
<input type="password" class="form-control" id="password" name="password" required minlength="10">
<div class="form-text">At least 10 characters, with a letter, a number, and a symbol. They'll be asked to change it on first login.</div>
</div>
{{if .can_grant_global}}
<div class="mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="is_global_admin" name="is_global_admin">
<label class="form-check-label" for="is_global_admin"><strong>Global admin</strong></label>
<div class="form-text">Full access to every domain, sender, and setting — same as your own account. Leave unchecked to scope this admin to specific domains below.</div>
</div>
</div>
{{end}}
<div id="domain-picker" class="mb-4">
<label class="form-label">Domains this admin can manage</label>
{{if .domains}}
<div class="border rounded p-3" style="max-height: 240px; overflow-y: auto;">
{{range .domains}}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="domain_ids" value="{{.ID}}" id="dom-{{.ID}}">
<label class="form-check-label" for="dom-{{.ID}}">{{.DomainName}}</label>
</div>
{{end}}
</div>
{{else}}
<p class="text-muted">You don't manage any domains yet to delegate.</p>
{{end}}
</div>
<div class="d-flex justify-content-between">
<a href="/pymta-manager/admins" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back</a>
<button type="submit" class="btn btn-primary"><i class="bi bi-person-plus me-2"></i>Create Admin</button>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
{{if .can_grant_global}}
<script>
document.getElementById('is_global_admin').addEventListener('change', function(e) {
document.getElementById('domain-picker').style.display = e.target.checked ? 'none' : '';
});
</script>
{{end}}
{{end}}
+46
View File
@@ -0,0 +1,46 @@
{{define "title"}}Add Domain - Email Server Management{{end}}
{{define "page_title"}}Add New Domain{{end}}
{{define "content"}}
<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-plus-circle me-2"></i>Add New Domain</h5></div>
<div class="card-body">
<form method="post">
<div class="mb-3">
<label for="domain_name" class="form-label"><i class="bi bi-globe me-1"></i>Domain Name</label>
<input type="text" class="form-control" id="domain_name" name="domain_name" placeholder="example.com" required
pattern="^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.?[a-zA-Z]{2,}$">
<div class="form-text">Enter the domain name that will be used for sending emails (e.g., example.com)</div>
</div>
<div class="alert alert-info">
<h6 class="alert-heading"><i class="bi bi-info-circle me-2"></i>What happens next?</h6>
<ul class="mb-0">
<li>Domain will be added to the system</li>
<li>DKIM key pair will be automatically generated</li>
<li>You'll need to configure DNS records</li>
<li>Add users or whitelist IPs for authentication</li>
</ul>
</div>
<div class="d-flex justify-content-between">
<a href="/pymta-manager/domains" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Domains</a>
<button type="submit" class="btn btn-primary"><i class="bi bi-plus-circle me-2"></i>Add Domain</button>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
document.getElementById('domain_name').addEventListener('input', function(e) {
let value = e.target.value.toLowerCase();
value = value.replace(/^https?:\/\//, '');
value = value.replace(/\/$/, '');
e.target.value = value;
});
</script>
{{end}}
+99
View File
@@ -0,0 +1,99 @@
{{define "title"}}Add IP Address - 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"><h6 class="mb-0"><i class="bi bi-geo-alt me-2"></i>Your Current IP</h6></div>
<div class="card-body text-center">
<div class="fw-bold font-monospace fs-5 mb-2" id="current-ip"><span class="spinner-border spinner-border-sm me-2"></span>Detecting...</div>
<button type="button" class="btn btn-outline-primary btn-sm" onclick="useCurrentIP()"><i class="bi bi-arrow-up me-1"></i>Use This IP</button>
</div>
</div>
</div>
</div>
<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-shield-plus me-2"></i>Add IP Address to Whitelist</h4></div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="ip_address" class="form-label">IP Address</label>
<input type="text" class="form-control font-monospace" id="ip_address" name="ip_address" required
pattern="^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
placeholder="192.168.1.100" value="{{.prefill_ip}}">
<div class="form-text">IPv4 address that will be allowed to send emails without authentication</div>
</div>
<div class="mb-4">
<label for="domain_id" class="form-label">Authorized Domain</label>
<select class="form-select" id="domain_id" name="domain_id" required>
<option value="">Select a domain...</option>
{{range .domains}}<option value="{{.ID}}">{{.DomainName}}</option>{{end}}
</select>
<div class="form-text">This IP will only be able to send emails for the selected domain</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="store_message_content" name="store_message_content">
<label class="form-check-label" for="store_message_content"><strong>Store Full Message Content</strong></label>
<div class="form-text">If enabled, the full message body and attachments will be stored and viewable in logs.</div>
</div>
</div>
<div class="alert alert-warning">
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle me-2"></i>Security Note</h6>
<ul class="mb-0">
<li>Only whitelist trusted IP addresses</li>
<li>This IP can send emails without username/password authentication</li>
<li>The IP is restricted to the selected domain only</li>
<li>Use static IP addresses for reliable access</li>
</ul>
</div>
<div class="d-flex justify-content-between">
<a href="/pymta-manager/ips" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to IP List</a>
<button type="submit" class="btn btn-success"><i class="bi bi-shield-plus me-2"></i>Add to Whitelist</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
async function detectCurrentIP() {
try {
const response = await fetch('https://ifconfig.me/all.json');
const data = await response.json();
document.getElementById('current-ip').innerHTML = `<span class="text-primary">${data.ip_addr}</span>`;
} catch (er) {
try {
const response = await fetch('https://httpbin.org/ip');
const data = await response.json();
document.getElementById('current-ip').innerHTML = `<span class="text-primary">${data.origin}</span>`;
} catch (error) {
document.getElementById('current-ip').innerHTML = '<span class="text-muted">Unable to detect</span>';
}
}
}
function useCurrentIP() {
const currentIPElement = document.getElementById('current-ip');
const ip = currentIPElement.textContent.trim();
if (ip && ip !== 'Detecting...' && ip !== 'Unable to detect') {
document.getElementById('ip_address').value = ip;
document.getElementById('domain_id').focus();
} else {
showToast('Unable to detect current IP address', 'danger');
}
}
document.getElementById('ip_address').addEventListener('input', function(e) {
const ip = e.target.value;
const ipPattern = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ip && !ipPattern.test(ip)) { e.target.setCustomValidity('Please enter a valid IPv4 address'); } else { e.target.setCustomValidity(''); }
});
detectCurrentIP();
</script>
{{end}}
+80
View File
@@ -0,0 +1,80 @@
{{define "title"}}Add Sender - 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-person-plus me-2"></i>Add New Sender</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">The sender always belongs to the domain selected here — this can't be typed as free text, so it can't drift from the domain it's assigned to.</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="6">
<div class="form-text">Minimum 6 characters</div>
</div>
<div class="mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="can_send_as_domain" name="can_send_as_domain">
<label class="form-check-label" for="can_send_as_domain"><strong>Domain Sender</strong></label>
<div class="form-text">If checked, sender can send emails as any address in their domain. Otherwise, sender can only send as their own email address.</div>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="store_message_content" name="store_message_content">
<label class="form-check-label" for="store_message_content"><strong>Store Full Message Content</strong></label>
<div class="form-text">If enabled, the full message body and attachments will be stored and viewable in logs. Otherwise, only headers and subject are stored.</div>
</div>
</div>
<div class="alert alert-info">
<h6 class="alert-heading"><i class="bi bi-info-circle me-2"></i>Permission Levels</h6>
<ul class="mb-0">
<li><strong>Regular Sender:</strong> Can only send emails from their own email address</li>
<li><strong>Domain Sender:</strong> Can send emails from any address in their domain</li>
</ul>
</div>
<div class="d-flex justify-content-between">
<a href="/pymta-manager/senders" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Senders</a>
<button type="submit" class="btn btn-success"><i class="bi bi-person-plus me-2"></i>Add Sender</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
document.getElementById('can_send_as_domain').addEventListener('change', function(e) {
const isChecked = e.target.checked;
const domainSelect = document.getElementById('domain_id');
const selectedDomain = domainSelect.options[domainSelect.selectedIndex]?.text || 'domain.com';
const helpText = e.target.closest('.form-check').querySelector('.form-text');
if (isChecked) {
helpText.innerHTML = `User can send as any address in ${selectedDomain}`;
} else {
helpText.innerHTML = 'User can only send as their own email address.';
}
});
document.getElementById('domain_id').addEventListener('change', function(e) {
const checkbox = document.getElementById('can_send_as_domain');
if (checkbox.checked) { checkbox.dispatchEvent(new Event('change')); }
});
</script>
{{end}}
+62
View File
@@ -0,0 +1,62 @@
{{define "title"}}Manage Admins{{end}}
{{define "page_title"}}Manage Admins{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-people-fill me-2"></i>Admins</h2>
<a href="/pymta-manager/admins/add" class="btn btn-primary"><i class="bi bi-person-plus me-2"></i>Add Admin</a>
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
Delegated admins can only see and manage the domains you assign them. They can also delegate further, but only for domains within their own assignment.
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>All Admins You Manage</h5></div>
<div class="card-body p-0">
{{if .rows}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Username</th><th>Role</th><th>Domains</th><th>Created</th><th>Actions</th></tr></thead>
<tbody>
{{range .rows}}
{{$u := .user}}
<tr>
<td class="fw-bold">{{$u.Username}}</td>
<td>
{{if $u.IsGlobalAdmin}}<span class="badge bg-danger"><i class="bi bi-shield-fill-check me-1"></i>Global Admin</span>
{{else}}<span class="badge bg-secondary"><i class="bi bi-shield me-1"></i>Scoped Admin</span>{{end}}
</td>
<td>
{{if $u.IsGlobalAdmin}}<span class="text-muted">All domains</span>
{{else if .domain_names}}{{range .domain_names}}<span class="badge bg-info text-dark me-1">{{.}}</span>{{end}}
{{else}}<span class="text-muted">None assigned</span>{{end}}
</td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" $u.CreatedAt}}</small></td>
<td>
<div class="btn-group btn-group-sm" role="group">
{{if not $u.IsGlobalAdmin}}
<a href="/pymta-manager/admins/{{$u.ID}}/edit" class="btn btn-outline-primary" title="Edit Domain Access"><i class="bi bi-pencil"></i></a>
{{end}}
<form method="post" action="/pymta-manager/admins/{{$u.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger" data-confirm="Permanently remove admin {{$u.Username}}? This cannot be undone." title="Remove Admin"><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-people text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No delegated admins yet</h4>
<p class="text-muted">Add an admin and assign them the domains they should manage</p>
<a href="/pymta-manager/admins/add" class="btn btn-primary"><i class="bi bi-person-plus me-2"></i>Add Your First Admin</a>
</div>
{{end}}
</div>
</div>
{{end}}
+217
View File
@@ -0,0 +1,217 @@
<!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>{{block "title" .}}Email Server Management{{end}}</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>
:root { --sidebar-width: 280px; }
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; }
.navbar-brand { color: #fff !important; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
.btn-outline-light:hover { background-color: #495057; }
.alert-success { background-color: #0f5132; border-color: #146c43; color: #75b798; }
.alert-danger { background-color: #58151c; border-color: #842029; color: #ea868f; }
.alert-warning { background-color: #664d03; border-color: #997404; color: #ffda6a; }
.alert-info { background-color: #055160; border-color: #087990; color: #6edff6; }
.form-control:focus { border-color: #0d6efd; box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); }
.form-select:focus { border-color: #0d6efd; box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); }
.text-muted { color: #adb5bd !important; }
.border-success { border-color: #198754 !important; }
.border-danger { border-color: #dc3545 !important; }
.text-success { color: #75b798 !important; }
.text-danger { color: #ea868f !important; }
.text-warning { color: #ffda6a !important; }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #2d2d2d; }
::-webkit-scrollbar-thumb { background: #495057; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #6c757d; }
</style>
<link href="/pymta-manager/static/css/smtp-management.css" rel="stylesheet">
<style>
.tooltip-inner { color: #fff !important; background-color: #222 !important; font-size: 1rem; text-align: left; }
.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,
.bs-tooltip-top .tooltip-arrow::before { border-top-color: #222 !important; }
</style>
{{block "extra_css" .}}{{end}}
</head>
<body>
<div class="main-container">
{{template "sidebar_email.html" .}}
<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">
<i class="bi bi-envelope-fill me-2"></i>
{{block "page_title" .}}Email Server Management{{end}}
</span>
<div class="navbar-nav ms-auto">
<span class="navbar-text">
<i class="bi bi-clock-fill me-1"></i>
<span id="current-time"></span>
</span>
</div>
</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>
<main>
{{block "content" .}}{{end}}
</main>
</div>
</div>
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-labelledby="confirmationModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="confirmationModalLabel">
<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-primary" 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>
function updateTime() {
const now = new Date();
const timeString = now.toLocaleTimeString();
const dateString = now.toLocaleDateString();
document.getElementById('current-time').textContent = `${dateString} ${timeString}`;
}
setInterval(updateTime, 1000);
updateTime();
// 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.
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() {
const toastElements = document.querySelectorAll('.toast');
toastElements.forEach(function(toastElement) {
const toast = new bootstrap.Toast(toastElement);
toast.show();
armToastAutoDismiss(toastElement, toast);
});
});
function showToast(message, type = 'info') {
const toastContainer = document.querySelector('.toast-container');
const toastId = 'toast-' + Date.now();
const iconMap = { 'danger': 'exclamation-triangle', 'success': 'check-circle', 'warning': 'exclamation-triangle', 'info': 'info-circle' };
const toastHtml = `
<div id="${toastId}" class="toast align-items-center text-bg-${type} 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-${iconMap[type] || 'info-circle'} 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>
`;
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
const toastEl = document.getElementById(toastId);
const newToast = new bootstrap.Toast(toastEl);
newToast.show();
armToastAutoDismiss(toastEl, newToast);
toastEl.addEventListener('hidden.bs.toast', function() { this.remove(); });
}
function showConfirmation(message, title = 'Confirm Action', confirmButtonText = 'Confirm', confirmButtonClass = 'btn-primary') {
return new Promise((resolve) => {
const modal = document.getElementById('confirmationModal');
const modalTitle = document.getElementById('confirmationModalLabel');
const modalBody = document.getElementById('confirmationModalBody');
const confirmButton = document.getElementById('confirmationModalConfirm');
modalTitle.innerHTML = `<i class="bi bi-question-circle me-2"></i>${title}`;
modalBody.textContent = message;
confirmButton.textContent = confirmButtonText;
confirmButton.className = `btn ${confirmButtonClass}`;
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() {
const deleteButtons = document.querySelectorAll('[data-confirm]');
deleteButtons.forEach(function(button) {
button.addEventListener('click', async function(e) {
e.preventDefault();
const confirmMessage = this.getAttribute('data-confirm');
const confirmed = await showConfirmation(confirmMessage, 'Confirm Action', 'Confirm', 'btn-danger');
if (confirmed) {
const form = this.closest('form');
if (form) { form.submit(); } else if (this.href) { window.location.href = this.href; }
}
});
});
});
</script>
<script src="/pymta-manager/static/js/smtp-management.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
new bootstrap.Tooltip(tooltipTriggerEl);
});
});
</script>
{{block "extra_js" .}}{{end}}
</body>
</html>
+184
View File
@@ -0,0 +1,184 @@
{{define "title"}}Dashboard - Email Server Management{{end}}
{{define "page_title"}}Dashboard{{end}}
{{define "content"}}
<div class="row">
<div class="col-lg-3 col-md-6 mb-4">
<a href="/pymta-manager/domains" class="text-decoration-none">
<div class="card border-primary">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<h5 class="card-title text-primary mb-1"><i class="bi bi-globe me-2"></i>Domains</h5>
<h3 class="mb-0">{{.domain_count}}</h3>
<small class="text-muted">Active domains</small>
</div>
<div class="fs-2 text-primary opacity-50"><i class="bi bi-globe"></i></div>
</div>
</div>
</div>
</a>
</div>
<div class="col-lg-3 col-md-6 mb-4">
<a href="/pymta-manager/senders" class="text-decoration-none">
<div class="card border-success">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<h5 class="card-title text-success mb-1"><i class="bi bi-people me-2"></i>Senders</h5>
<h3 class="mb-0">{{.sender_count}}</h3>
<small class="text-muted">Authenticated senders</small>
</div>
<div class="fs-2 text-success opacity-50"><i class="bi bi-people"></i></div>
</div>
</div>
</div>
</a>
</div>
<div class="col-lg-3 col-md-6 mb-4">
<a href="/pymta-manager/dkim" class="text-decoration-none">
<div class="card border-warning">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<h5 class="card-title text-warning mb-1"><i class="bi bi-shield-check me-2"></i>DKIM Keys</h5>
<h3 class="mb-0">{{.dkim_count}}</h3>
<small class="text-muted">Active DKIM keys</small>
</div>
<div class="fs-2 text-warning opacity-50"><i class="bi bi-shield-check"></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">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<h5 class="card-title text-info mb-1"><i class="bi bi-activity me-2"></i>Status</h5>
<h6 class="{{if eq .health.Status "healthy"}}text-success{{else}}text-warning{{end}} mb-0">
<i class="bi bi-circle-fill me-1" style="font-size: 0.5rem;"></i>
{{title .health.Status}}
</h6>
<small class="text-muted">
{{if and (eq .health.Services.smtp_server "running") (eq .health.Services.database "ok")}}
All services running
{{else}}
{{if eq .health.Services.smtp_server "stopped"}}SMTP Server stopped{{end}}
{{if eq .health.Services.database "error"}}Database error{{end}}
{{end}}
</small>
</div>
<div class="fs-2 text-info opacity-50"><i class="bi bi-activity"></i></div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-envelope me-2"></i>Recent Email Activity</h5>
<a href="/pymta-manager/logs?type=emails" class="btn btn-outline-light btn-sm">View All</a>
</div>
<div class="card-body p-0">
{{if .recent_emails}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Time</th><th>From</th><th>Recipients</th><th>Status</th><th>DKIM</th></tr></thead>
<tbody>
{{range .recent_emails}}
<tr>
<td><small class="text-muted">{{formatDatetime .CreatedAt}}</small></td>
<td><span class="text-truncate d-inline-block" style="max-width: 150px;" title="{{.MailFrom}}">{{.MailFrom}}</span></td>
<td>
<div style="max-width: 200px; font-size: 0.85rem;">
{{if .ToAddress}}<div class="text-truncate"><span class="text-info fw-bold" style="font-size: 0.75rem;">To:</span> {{.ToAddress}}</div>{{end}}
{{if .CcAddresses}}<div class="text-truncate"><span class="text-warning fw-bold" style="font-size: 0.75rem;">CC:</span> {{.CcAddresses}}</div>{{end}}
{{if .BccAddresses}}<div class="text-truncate"><span class="text-secondary fw-bold" style="font-size: 0.75rem;">BCC:</span> {{.BccAddresses}}</div>{{end}}
</div>
</td>
<td>
{{if eq .Status "relayed"}}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Sent</span>
{{else if eq .Status "partial"}}
<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-triangle me-1"></i>Partial Fail</span>
{{else}}
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Failed</span>
{{end}}
</td>
<td>
{{if .DKIMSigned}}<span class="text-success"><i class="bi bi-shield-check" title="DKIM Signed"></i></span>
{{else}}<span class="text-muted"><i class="bi bi-shield-x" title="Not DKIM Signed"></i></span>{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="text-center py-4"><i class="bi bi-envelope text-muted fs-1"></i><p class="text-muted mt-2">No email activity yet</p></div>
{{end}}
</div>
</div>
</div>
<div class="col-lg-4 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>Recent Auth Activity</h5>
<a href="/pymta-manager/logs?type=auth" class="btn btn-outline-light btn-sm">View All</a>
</div>
<div class="card-body p-0">
{{if .recent_auths}}
<div class="list-group list-group-flush">
{{range .recent_auths}}
<div class="list-group-item list-group-item-dark d-flex justify-content-between align-items-start">
<div class="ms-2 me-auto">
<div class="fw-bold">
{{if .Success}}<i class="bi bi-check-circle text-success me-1"></i>{{else}}<i class="bi bi-x-circle text-danger me-1"></i>{{end}}
{{title .AuthType}}
</div>
<small class="text-muted">{{.Identifier}}</small><br>
<small class="text-muted">{{formatDatetime .CreatedAt}}</small>
</div>
<small class="text-muted">{{.IPAddress}}</small>
</div>
{{end}}
</div>
{{else}}
<div class="text-center py-4"><i class="bi bi-shield-lock text-muted fs-1"></i><p class="text-muted mt-2">No authentication activity yet</p></div>
{{end}}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-lightning me-2"></i>Quick Actions</h5></div>
<div class="card-body">
<div class="row">
<div class="col-md-3 mb-3"><div class="d-grid"><a href="/pymta-manager/domains/add" class="btn btn-outline-primary"><i class="bi bi-plus-circle me-2"></i>Add Domain</a></div></div>
<div class="col-md-3 mb-3"><div class="d-grid"><a href="/pymta-manager/senders/add" class="btn btn-outline-success"><i class="bi bi-person-plus me-2"></i>Add Sender</a></div></div>
<div class="col-md-3 mb-3"><div class="d-grid"><a href="/pymta-manager/ips/add" class="btn btn-outline-warning"><i class="bi bi-shield-plus me-2"></i>Whitelist IP</a></div></div>
<div class="col-md-3 mb-3"><div class="d-grid"><a href="/pymta-manager/settings" class="btn btn-outline-info"><i class="bi bi-gear me-2"></i>Settings</a></div></div>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
setTimeout(function() { location.reload(); }, 30000);
</script>
{{end}}
+290
View File
@@ -0,0 +1,290 @@
{{define "title"}}DKIM Keys - Email Server{{end}}
{{define "extra_css"}}
<style>
.dns-record { font-family: 'Courier New', monospace; color: black; background-color: var(--bs-gray-100); border-radius: 0.375rem; padding: 0.75rem; border: 1px solid var(--bs-border-color); word-break: break-all; }
.status-indicator { width: 12px; height: 12px; border-radius: 50%; display: inline-block; margin-right: 0.5rem; }
.status-success { background-color: #28a745; }
.status-warning { background-color: #ffc107; }
.status-danger { background-color: #dc3545; }
</style>
{{end}}
{{define "content"}}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-shield-check me-2"></i>DKIM Key Management</h2>
<div class="btn-group">
<button class="btn btn-outline-primary me-2" data-bs-toggle="modal" data-bs-target="#createDKIMModal"><i class="bi bi-plus-circle me-2"></i>Create DKIM</button>
<button class="btn btn-outline-info" data-action="check-all-dns"><i class="bi bi-arrow-clockwise me-2"></i>Check All DNS</button>
</div>
</div>
<div class="modal fade" id="createDKIMModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="createDKIMForm" method="post" action="/pymta-manager/dkim/create">
<div class="modal-header">
<h5 class="modal-title">Create New DKIM Key</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="dkimDomain" class="form-label">Domain</label>
<select class="form-select" id="dkimDomain" name="domain" required>
<option value="" disabled selected>Select domain</option>
{{range .dkim_data}}<option value="{{.domain.domain_name}}">{{.domain.domain_name}}</option>{{end}}
</select>
</div>
<div class="mb-3">
<label for="dkimSelector" class="form-label">Selector (optional)</label>
<input type="text" class="form-control" id="dkimSelector" name="selector" maxlength="32" placeholder="Leave blank for random selector">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
</div>
{{range .dkim_data}}
{{$domain := .domain}}{{$key := .dkim_key}}{{$slug := dotToDash $domain.domain_name}}
<div class="card mb-4" id="domain-{{$slug}}" data-is-active="{{$key.IsActive}}">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<div class="flex-grow-1 card-header-clickable" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapse-{{$slug}}">
<h5 class="mb-0">
<i class="bi bi-server me-2"></i>
{{$domain.domain_name}}
{{if $key.IsActive}}<span class="badge bg-success ms-2">Active</span>{{else}}<span class="badge bg-secondary ms-2">Inactive</span>{{end}}
</h5>
</div>
<div class="btn-group btn-group-sm me-2">
<button class="btn btn-outline-primary" data-action="check-dns" data-domain="{{$domain.domain_name}}" data-selector="{{$key.Selector}}" onclick="event.stopPropagation();">
<i class="bi bi-search me-1"></i>Check DNS
</button>
<div class="btn-group btn-group-sm" role="group">
<a href="/pymta-manager/dkim/{{$key.ID}}/edit" class="btn btn-outline-info" onclick="event.stopPropagation();"><i class="bi bi-pencil me-1"></i>Edit</a>
<form method="post" action="/pymta-manager/dkim/{{$key.ID}}/toggle" class="d-inline">
{{if $key.IsActive}}
<button type="submit" class="btn btn-outline-warning" onclick="event.stopPropagation();" title="Disable DKIM"><i class="bi bi-pause-circle me-1"></i>Disable</button>
{{else}}
<button type="submit" class="btn btn-outline-success" onclick="event.stopPropagation();" title="Enable DKIM"><i class="bi bi-play-circle me-1"></i>Enable</button>
{{end}}
</form>
<form method="post" action="/pymta-manager/dkim/{{$key.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger" onclick="event.stopPropagation();" data-confirm="Permanently remove the DKIM key for {{$domain.domain_name}}? You will lose the ability to sign emails until you regenerate a new key.">
<i class="bi bi-trash me-1"></i>Remove
</button>
</form>
</div>
<form method="post" action="/pymta-manager/dkim/{{$domain.id}}/regenerate" class="d-inline" onsubmit="event.stopPropagation();">
<button type="submit" class="btn btn-outline-warning" onclick="event.stopPropagation();"><i class="bi bi-arrow-clockwise me-1"></i>Regenerate</button>
</form>
</div>
<div class="card-header-clickable" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapse-{{$slug}}">
<i class="bi bi-chevron-down" id="chevron-{{$slug}}"></i>
</div>
</div>
</div>
<div class="collapse" id="collapse-{{$slug}}">
<div class="card-body">
<div class="row">
<div class="col-lg-6 mb-3">
<h6>
<i class="bi bi-key me-2"></i>DKIM DNS Record
<span class="dns-status" id="dkim-status-{{$slug}}"><span class="status-indicator status-warning"></span><small class="text-muted">Active (DNS not checked)</small></span>
</h6>
<div class="mb-2"><strong>Name:</strong><div class="dns-record">{{.dns_record.name}}</div></div>
<div class="mb-2"><strong>Type:</strong> TXT</div>
<div class="mb-2"><strong>Value:</strong><div class="dns-record">{{.dns_record.value}}</div></div>
<button class="btn btn-outline-secondary btn-sm" onclick="copyToClipboard('{{.dns_record.value}}')"><i class="bi bi-clipboard me-1"></i>Copy Value</button>
</div>
<div class="col-lg-6 mb-3">
<h6>
<i class="bi bi-shield-lock me-2"></i>SPF DNS Record
<span class="dns-status" id="spf-status-{{$slug}}"><span class="status-indicator status-warning"></span><small class="text-muted">Not checked</small></span>
</h6>
<div class="mb-2"><strong>Name:</strong><div class="dns-record">{{$domain.domain_name}}</div></div>
<div class="mb-2"><strong>Type:</strong> TXT</div>
{{if .existing_spf}}<div class="mb-2"><strong>Current SPF:</strong><div class="dns-record">{{.existing_spf}}</div></div>{{end}}
<div class="mb-2"><strong>Recommended SPF:</strong><div class="dns-record">{{.recommended_spf}}</div></div>
<button class="btn btn-outline-secondary btn-sm" onclick="copyToClipboard('{{.recommended_spf}}')"><i class="bi bi-clipboard me-1"></i>Copy SPF</button>
</div>
</div>
<div class="row">
<div class="col-12">
<h6><i class="bi bi-info-circle me-2"></i>Key Information</h6>
<div class="row">
<div class="col-md-3"><strong>Selector:</strong><br><code>{{$key.Selector}}</code></div>
<div class="col-md-3"><strong>Created:</strong><br>{{strftime "%Y-%m-%d %H:%M" $key.CreatedAt}}</div>
<div class="col-md-3"><strong>Server IP:</strong><br><code>{{.public_ip}}</code></div>
<div class="col-md-3"><strong>Status:</strong><br>{{if $key.IsActive}}<span class="text-success">Active</span>{{else}}<span class="text-secondary">Inactive</span>{{end}}</div>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{if .old_dkim_data}}
<div class="card mb-4">
<div class="card-header"><h4 class="mb-0"><i class="bi bi-archive me-2"></i>Old DKIM Keys <span class="badge bg-secondary ms-2">{{len .old_dkim_data}}</span></h4></div>
<div class="card-body">
<p class="text-muted mb-3">These keys have been replaced or disabled. They are kept for reference and can be permanently removed.</p>
{{range .old_dkim_data}}
{{$domain := .domain}}{{$key := .dkim_key}}
<div class="card mb-3 border-secondary">
<div class="card-header bg-dark">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-0"><i class="bi bi-server me-2"></i>{{$domain.domain_name}}<span class="badge bg-secondary ms-2">{{.status_text}}</span></h6>
<small class="text-muted">Selector: <code>{{$key.Selector}}</code> | Created: {{strftime "%Y-%m-%d %H:%M" $key.CreatedAt}}</small>
</div>
<div class="btn-group btn-group-sm">
<form method="post" action="/pymta-manager/dkim/{{$key.ID}}/toggle" class="d-inline">
<button type="submit" class="btn btn-outline-success btn-sm"><i class="bi bi-play-circle me-1"></i>Reactivate</button>
</form>
<form method="post" action="/pymta-manager/dkim/{{$key.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Permanently remove this old DKIM key? This action cannot be undone."><i class="bi bi-trash me-1"></i>Remove</button>
</form>
</div>
</div>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{if not .dkim_data}}
<div class="card">
<div class="card-body text-center py-5">
<i class="bi bi-shield-x text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No DKIM Keys Found</h4>
<p class="text-muted">Add domains first to automatically generate DKIM keys</p>
<a href="/pymta-manager/domains/add" class="btn btn-primary"><i class="bi bi-plus-circle me-2"></i>Add Domain</a>
</div>
</div>
{{end}}
</div>
<div class="modal fade" id="dnsResultModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">DNS Check Results</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body" id="dnsResults"></div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button></div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() { showToast('Copied to clipboard!', 'success'); }, function(err) { showToast('Failed to copy: ' + err, 'danger'); });
}
async function checkDomainDNS(domain, selector) {
const dkimStatus = document.getElementById(`dkim-status-${domain.replace('.', '-')}`);
const spfStatus = document.getElementById(`spf-status-${domain.replace('.', '-')}`);
dkimStatus.innerHTML = '<span class="status-indicator status-warning"></span><small class="text-muted">Checking...</small>';
spfStatus.innerHTML = '<span class="status-indicator status-warning"></span><small class="text-muted">Checking...</small>';
try {
const dkimResponse = await fetch('/pymta-manager/dkim/check_dns', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams({domain, selector}) });
const dkimResult = await dkimResponse.json();
const spfResponse = await fetch('/pymta-manager/dkim/check_spf', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams({domain}) });
const spfResult = await spfResponse.json();
const domainCard = document.getElementById(`domain-${domain.replace('.', '-')}`);
const isActive = domainCard && domainCard.dataset.isActive === 'true';
if (isActive) {
dkimStatus.innerHTML = dkimResult.success
? '<span class="status-indicator status-success"></span><small class="text-success">Active & Configured</small>'
: '<span class="status-indicator" style="background-color: #fd7e14;"></span><small class="text-warning">Active but DNS not found</small>';
} else {
dkimStatus.innerHTML = '<span class="status-indicator" style="background-color: #6c757d;"></span><small class="text-muted">Disabled</small>';
}
spfStatus.innerHTML = spfResult.success
? '<span class="status-indicator status-success"></span><small class="text-success">Found</small>'
: '<span class="status-indicator status-danger"></span><small class="text-danger">Not found</small>';
showDNSResults(domain, dkimResult, spfResult);
} catch (error) {
dkimStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">Error</small>';
spfStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">Error</small>';
}
}
function showDNSResults(domain, dkimResult, spfResult) {
const resultsHtml = `
<h6>DNS Check Results for ${domain}</h6>
<div class="mb-3"><h6 class="text-primary">DKIM Record</h6>
<div class="alert ${dkimResult.success ? 'alert-success' : 'alert-danger'}">
<strong>Status:</strong> ${dkimResult.success ? 'Found' : 'Not Found'}<br>
<strong>Message:</strong> ${dkimResult.message}
</div>
</div>
<div class="mb-3"><h6 class="text-primary">SPF Record</h6>
<div class="alert ${spfResult.success ? 'alert-success' : 'alert-danger'}">
<strong>Status:</strong> ${spfResult.success ? 'Found' : 'Not Found'}<br>
<strong>Message:</strong> ${spfResult.message}
</div>
</div>`;
document.getElementById('dnsResults').innerHTML = resultsHtml;
new bootstrap.Modal(document.getElementById('dnsResultModal')).show();
}
async function checkAllDNS() {
const cards = document.querySelectorAll('[data-action="check-dns"]');
for (const btn of cards) {
await checkDomainDNS(btn.dataset.domain, btn.dataset.selector);
}
}
document.addEventListener('DOMContentLoaded', function() {
const checkAllBtn = document.querySelector('[data-action="check-all-dns"]');
if (checkAllBtn) { checkAllBtn.addEventListener('click', checkAllDNS); }
document.querySelectorAll('[data-action="check-dns"]').forEach(button => {
button.addEventListener('click', function(event) {
event.stopPropagation();
checkDomainDNS(this.dataset.domain, this.dataset.selector);
});
});
document.querySelectorAll('.card-header-clickable[data-bs-toggle="collapse"]').forEach(function(element) {
element.addEventListener('click', function() {
const targetId = this.getAttribute('data-bs-target');
const chevron = document.querySelector(targetId.replace('#collapse-', '#chevron-'));
if (chevron) {
setTimeout(() => {
const collapseElement = document.querySelector(targetId);
chevron.className = (collapseElement && collapseElement.classList.contains('show')) ? 'bi bi-chevron-up' : 'bi bi-chevron-down';
}, 100);
}
});
});
document.querySelectorAll('form[action*="toggle"]').forEach(form => {
if (!form.action.includes('/dkim/')) return;
form.addEventListener('submit', async function(event) {
event.preventDefault();
const response = await fetch(this.action, { method: 'POST', body: new FormData(this), headers: {'X-Requested-With': 'XMLHttpRequest'} });
if (response.ok) {
const result = await response.json();
if (result.success) { showToast(result.message, 'success'); setTimeout(() => location.reload(), 600); }
else { showToast(result.message, 'danger'); }
}
});
});
document.getElementById('createDKIMForm').addEventListener('submit', async function(event) {
event.preventDefault();
const response = await fetch(this.action, { method: 'POST', body: new FormData(this) });
const result = await response.json();
if (result.success) { showToast(result.message, 'success'); setTimeout(() => location.reload(), 600); }
else { showToast(result.message, 'danger'); }
});
});
</script>
{{end}}
+155
View File
@@ -0,0 +1,155 @@
{{define "title"}}Domains - Email Server Management{{end}}
{{define "page_title"}}Domain Management{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-globe me-2"></i>Domains</h2>
<a href="/pymta-manager/domains/add" class="btn btn-primary"><i class="bi bi-plus-circle me-2"></i>Add Domain</a>
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>All Domains</h5></div>
<div class="card-body p-0">
{{if .rows}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Domain Name</th><th>Status</th><th>Ownership</th><th>Created</th><th>Senders</th><th>DKIM</th><th>Actions</th></tr></thead>
<tbody>
{{range .rows}}
{{$domain := .domain}}
<tr>
<td><div class="fw-bold">{{$domain.DomainName}}</div></td>
<td>
{{if $domain.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>
{{if $domain.IsVerified}}
<span class="badge bg-success" data-bs-toggle="tooltip" title="DNS ownership verified — this domain can send mail"><i class="bi bi-patch-check-fill me-1"></i>Verified</span>
{{else}}
<span class="badge bg-warning text-dark" data-bs-toggle="tooltip" title="Not yet verified — sending is blocked until the DNS TXT record is confirmed"><i class="bi bi-exclamation-triangle me-1"></i>Unverified</span>
<button type="button" class="btn btn-outline-warning btn-sm ms-1"
data-action="show-verify"
data-domain-id="{{$domain.ID}}"
data-domain-name="{{$domain.DomainName}}"
data-record-name="_pymta-verify.{{$domain.DomainName}}"
data-record-value="pymta-verify={{$domain.VerificationToken}}">
Verify
</button>
{{end}}
</td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" $domain.CreatedAt}}</small></td>
<td><span class="badge bg-info">{{.sender_count}} senders</span></td>
<td>
{{if .has_active_dkim}}
<span class="status-indicator status-warning"></span>
<i class="bi bi-shield-check" title="DKIM Active (DNS not checked)"></i>
{{else if .has_any_dkim}}
<span class="text-secondary"><i class="bi bi-shield" title="DKIM Disabled"></i></span>
{{else}}
<span class="text-danger"><i class="bi bi-shield-exclamation" title="No DKIM Key"></i></span>
{{end}}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="/pymta-manager/domains/{{$domain.ID}}/edit" class="btn btn-outline-primary" title="Edit Domain"><i class="bi bi-pencil"></i></a>
<form method="post" action="/pymta-manager/domains/{{$domain.ID}}/toggle" class="d-inline">
{{if $domain.IsActive}}
<button type="submit" class="btn btn-outline-warning" data-confirm="Are you sure you want to disable domain {{$domain.DomainName}}?" title="Disable Domain"><i class="bi bi-pause-circle"></i></button>
{{else}}
<button type="submit" class="btn btn-outline-success" data-confirm="Are you sure you want to enable domain {{$domain.DomainName}}?" title="Enable Domain"><i class="bi bi-play-circle"></i></button>
{{end}}
</form>
<form method="post" action="/pymta-manager/domains/{{$domain.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger" data-confirm="WARNING: This will permanently delete domain {{$domain.DomainName}} and ALL associated data. This action cannot be undone. Continue?" title="Permanently Remove Domain"><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-globe text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No domains configured</h4>
<p class="text-muted">Get started by adding your first domain</p>
<a href="/pymta-manager/domains/add" class="btn btn-primary"><i class="bi bi-plus-circle me-2"></i>Add Your First Domain</a>
</div>
{{end}}
</div>
</div>
<div class="modal fade" id="verifyDomainModal" 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-patch-check me-2"></i>Verify domain ownership</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Add this DNS TXT record for <strong id="verifyDomainName"></strong>, then check:</p>
<div class="mb-2"><strong>Name:</strong><div class="dns-record" id="verifyRecordName" style="font-family: monospace; background: var(--bs-gray-100); color: #111; border-radius: 0.375rem; padding: 0.6rem; word-break: break-all;"></div></div>
<div class="mb-3"><strong>Value:</strong><div class="dns-record" id="verifyRecordValue" style="font-family: monospace; background: var(--bs-gray-100); color: #111; border-radius: 0.375rem; padding: 0.6rem; word-break: break-all;"></div></div>
<div id="verifyResult"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="verifyCheckNowBtn"><i class="bi bi-arrow-clockwise me-1"></i>Check Now</button>
</div>
</div>
</div>
</div>
<style>
.status-indicator { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 0.5rem; }
.status-success { background-color: #28a745; }
.status-warning { background-color: #ffc107; }
.status-danger { background-color: #dc3545; }
</style>
{{end}}
{{define "extra_js"}}
<script>
document.addEventListener('DOMContentLoaded', function() {
let currentDomainID = null;
const modalEl = document.getElementById('verifyDomainModal');
const modal = new bootstrap.Modal(modalEl);
document.querySelectorAll('[data-action="show-verify"]').forEach(btn => {
btn.addEventListener('click', function() {
currentDomainID = this.dataset.domainId;
document.getElementById('verifyDomainName').textContent = this.dataset.domainName;
document.getElementById('verifyRecordName').textContent = this.dataset.recordName;
document.getElementById('verifyRecordValue').textContent = this.dataset.recordValue;
document.getElementById('verifyResult').innerHTML = '';
modal.show();
});
});
document.getElementById('verifyCheckNowBtn').addEventListener('click', async function() {
if (!currentDomainID) return;
const btn = this;
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Checking...';
try {
const response = await fetch(`/pymta-manager/domains/${currentDomainID}/verify_check`, { method: 'POST' });
const result = await response.json();
const resultEl = document.getElementById('verifyResult');
resultEl.innerHTML = `<div class="alert ${result.success ? 'alert-success' : 'alert-warning'} mb-0 mt-2">${result.message}</div>`;
if (result.success) {
showToast(result.message, 'success');
setTimeout(() => location.reload(), 1200);
}
} catch (e) {
showToast('DNS check failed', 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = original;
}
});
});
</script>
{{end}}
+32
View File
@@ -0,0 +1,32 @@
{{define "title"}}Edit Admin Access{{end}}
{{define "page_title"}}Edit Admin Access{{end}}
{{define "content"}}
<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-pencil-square me-2"></i>{{.target.Username}}'s domain access</h5></div>
<div class="card-body">
<form method="POST">
{{if .domains}}
<div class="border rounded p-3 mb-4" style="max-height: 300px; overflow-y: auto;">
{{range .domains}}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="domain_ids" value="{{.ID}}" id="dom-{{.ID}}" {{if index $.assigned .ID}}checked{{end}}>
<label class="form-check-label" for="dom-{{.ID}}">{{.DomainName}}</label>
</div>
{{end}}
</div>
{{else}}
<p class="text-muted">You don't manage any domains to assign.</p>
{{end}}
<div class="d-flex justify-content-between">
<a href="/pymta-manager/admins" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back</a>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-2"></i>Save</button>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}
+82
View File
@@ -0,0 +1,82 @@
{{define "title"}}Edit DKIM Selector{{end}}
{{define "content"}}
<div class="container-fluid">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h4 class="mb-0"><i class="bi bi-pencil me-2"></i>Edit DKIM Selector</h4>
<a href="/pymta-manager/dkim" class="btn btn-light btn-sm"><i class="bi bi-arrow-left me-1"></i>Back to DKIM Keys</a>
</div>
<div class="card-body">
<form method="POST" class="needs-validation" novalidate>
<div class="mb-3">
<label for="selector" class="form-label"><i class="bi bi-key me-1"></i>DKIM Selector</label>
<input type="text" class="form-control" id="selector" name="selector" value="{{.dkim_key.Selector}}" placeholder="default" pattern="^[a-zA-Z0-9_-]+$" required>
<div class="invalid-feedback">Please provide a valid selector (letters, numbers, hyphens, and underscores only).</div>
<div class="form-text"><i class="bi bi-info-circle me-1"></i>The selector is used in DNS records to identify this DKIM key (e.g., "selector._domainkey.{{.domain.DomainName}}")</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="alert alert-info">
<h6><i class="bi bi-info-circle me-1"></i>Current Information</h6>
<p class="mb-1"><strong>Domain:</strong> {{.domain.DomainName}}</p>
<p class="mb-1"><strong>Current Selector:</strong> {{.dkim_key.Selector}}</p>
<p class="mb-1"><strong>Status:</strong> {{if .dkim_key.IsActive}}<span class="badge bg-success">Active</span>{{else}}<span class="badge bg-danger">Inactive</span>{{end}}</p>
<p class="mb-0"><strong>Created:</strong> {{strftime "%Y-%m-%d %H:%M:%S" .dkim_key.CreatedAt}}</p>
</div>
</div>
<div class="col-md-6">
<div class="alert alert-warning">
<h6><i class="bi bi-exclamation-triangle me-1"></i>Important Note</h6>
<p class="mb-0">Changing the selector will require updating your DNS records to match the new selector name.</p>
</div>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="/pymta-manager/dkim" class="btn btn-secondary"><i class="bi bi-x me-1"></i>Cancel</a>
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i>Update Selector</button>
</div>
</form>
</div>
</div>
<div class="card mt-4">
<div class="card-header bg-info text-white"><h5 class="mb-0"><i class="bi bi-dns me-2"></i>DNS Record Information</h5></div>
<div class="card-body">
<div class="alert alert-light">
<h6>Current DNS Record</h6>
<p class="mb-2"><strong>Name:</strong> <code>{{.dkim_key.Selector}}._domainkey.{{.domain.DomainName}}</code></p>
<p class="mb-0"><strong>Type:</strong> TXT</p>
</div>
<p class="text-muted"><i class="bi bi-lightbulb me-1"></i><strong>Tip:</strong> After changing the selector, update your DNS provider to use the new record name. The value stays the same.</p>
</div>
</div>
</div>
</div>
</div>
<script>
(function() {
'use strict';
window.addEventListener('load', function() {
var forms = document.getElementsByClassName('needs-validation');
Array.prototype.filter.call(forms, function(form) {
form.addEventListener('submit', function(event) {
if (form.checkValidity() === false) { event.preventDefault(); event.stopPropagation(); }
form.classList.add('was-validated');
}, false);
});
}, false);
})();
document.getElementById('selector').addEventListener('input', function(e) {
const selectorRegex = /^[a-zA-Z0-9_-]+$/;
if (e.target.value && !selectorRegex.test(e.target.value)) {
e.target.setCustomValidity('Selector must contain only letters, numbers, hyphens, and underscores');
} else {
e.target.setCustomValidity('');
}
});
</script>
{{end}}
+71
View File
@@ -0,0 +1,71 @@
{{define "title"}}Edit Domain{{end}}
{{define "content"}}
<div class="container-fluid">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h4 class="mb-0"><i class="bi bi-pencil me-2"></i>Edit Domain</h4>
<a href="/pymta-manager/domains" class="btn btn-light btn-sm"><i class="bi bi-arrow-left me-1"></i>Back to Domains</a>
</div>
<div class="card-body">
<form method="POST" class="needs-validation" novalidate>
<div class="mb-3">
<label for="domain_name" class="form-label"><i class="bi bi-globe me-1"></i>Domain Name</label>
<input type="text" class="form-control" id="domain_name" name="domain_name" value="{{.domain.DomainName}}" placeholder="example.com"
pattern="^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$" required>
<div class="invalid-feedback">Please provide a valid domain name.</div>
<div class="form-text"><i class="bi bi-info-circle me-1"></i>Enter a fully qualified domain name (e.g., example.com)</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="alert alert-info">
<h6><i class="bi bi-info-circle me-1"></i>Current Status</h6>
<p class="mb-1"><strong>Status:</strong>
{{if .domain.IsActive}}<span class="badge bg-success">Active</span>{{else}}<span class="badge bg-danger">Inactive</span>{{end}}
</p>
<p class="mb-0"><strong>Created:</strong> {{strftime "%Y-%m-%d %H:%M:%S" .domain.CreatedAt}}</p>
</div>
</div>
<div class="col-md-6">
<div class="alert alert-warning">
<h6><i class="bi bi-exclamation-triangle me-1"></i>Note</h6>
<p class="mb-0">Changing the domain name will affect all associated users, IP addresses, and DKIM keys. Make sure to update your DNS records accordingly.</p>
</div>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="/pymta-manager/domains" class="btn btn-secondary"><i class="bi bi-x me-1"></i>Cancel</a>
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i>Update Domain</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
(function() {
'use strict';
window.addEventListener('load', function() {
var forms = document.getElementsByClassName('needs-validation');
Array.prototype.filter.call(forms, function(form) {
form.addEventListener('submit', function(event) {
if (form.checkValidity() === false) { event.preventDefault(); event.stopPropagation(); }
form.classList.add('was-validated');
}, false);
});
}, false);
})();
document.getElementById('domain_name').addEventListener('input', function(e) {
const value = e.target.value.toLowerCase();
e.target.value = value;
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/;
if (value && !domainRegex.test(value)) { e.target.setCustomValidity('Invalid domain format'); } else { e.target.setCustomValidity(''); }
});
</script>
{{end}}
+63
View File
@@ -0,0 +1,63 @@
{{define "title"}}Edit IP Whitelist - 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-pencil-square me-2"></i>Edit IP Whitelist Entry</h5></div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="ip_address" class="form-label">IP Address</label>
<input type="text" class="form-control" id="ip_address" name="ip_address" value="{{.ip_record.IPAddress}}" placeholder="e.g., 192.168.1.1" required>
<div class="form-text">Enter a single IPv4 address</div>
</div>
<div class="mb-3">
<label for="domain_id" class="form-label">Domain</label>
<select class="form-select" id="domain_id" name="domain_id" required>
<option value="">Select a domain</option>
{{range .domains}}<option value="{{.ID}}" {{if eq .ID $.ip_record.DomainID}}selected{{end}}>{{.DomainName}}</option>{{end}}
</select>
<div class="form-text">This IP will be able to send emails for the selected domain</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="store_message_content" name="store_message_content" {{if .ip_record.StoreMessageContent}}checked{{end}}>
<label class="form-check-label" for="store_message_content"><strong>Store Full Message Content</strong></label>
<div class="form-text">If enabled, the full message body and attachments will be stored and viewable in logs.</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Update IP Whitelist</button>
<a href="/pymta-manager/ips" 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 Configuration</h6></div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Current IP:</dt><dd class="col-sm-8"><code>{{.ip_record.IPAddress}}</code></dd>
<dt class="col-sm-4">Domain:</dt>
<dd class="col-sm-8">{{range .domains}}{{if eq .ID $.ip_record.DomainID}}<span class="badge bg-secondary">{{.DomainName}}</span>{{end}}{{end}}</dd>
<dt class="col-sm-4">Status:</dt>
<dd class="col-sm-8">{{if .ip_record.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-4">Store Message:</dt>
<dd class="col-sm-8">{{if .ip_record.StoreMessageContent}}<span class="badge bg-info text-dark"><i class="bi bi-file-earmark-text me-1"></i>Full Message</span>{{else}}<span class="badge bg-secondary"><i class="bi bi-file-earmark me-1"></i>Headers Only</span>{{end}}</dd>
<dt class="col-sm-4">Created:</dt><dd class="col-sm-8"><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .ip_record.CreatedAt}}</small></dd>
</dl>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
document.addEventListener('DOMContentLoaded', function() { document.getElementById('ip_address').focus(); });
</script>
{{end}}
+79
View File
@@ -0,0 +1,79 @@
{{define "title"}}Edit Sender - 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-person-fill-gear me-2"></i>Edit Sender</h5></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" value="{{.local_part}}" required
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}}" {{if eq .ID $.sender.DomainID}}selected{{end}}>{{.DomainName}}</option>{{end}}
</select>
</div>
<div class="form-text">The sender always belongs to the domain selected here.</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</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="can_send_as_domain" name="can_send_as_domain" {{if .sender.CanSendAsDomain}}checked{{end}}>
<label class="form-check-label" for="can_send_as_domain"><strong>Can send as any email from domain</strong></label>
<div class="form-text">Allow this sender to send emails using any address within their domain</div>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="store_message_content" name="store_message_content" {{if .sender.StoreMessageContent}}checked{{end}}>
<label class="form-check-label" for="store_message_content"><strong>Store Full Message Content</strong></label>
<div class="form-text">If enabled, the full message body and attachments will be stored and viewable in logs.</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Update Sender</button>
<a href="/pymta-manager/senders" 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 Sender Details</h6></div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Email:</dt><dd class="col-sm-8"><code>{{.sender.Email}}</code></dd>
<dt class="col-sm-4">Domain:</dt>
<dd class="col-sm-8">{{range .domains}}{{if eq .ID $.sender.DomainID}}<span class="badge bg-secondary">{{.DomainName}}</span>{{end}}{{end}}</dd>
<dt class="col-sm-4">Status:</dt>
<dd class="col-sm-8">{{if .sender.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-4">Domain Sender:</dt>
<dd class="col-sm-8">{{if .sender.CanSendAsDomain}}<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Yes</span>{{else}}<span class="badge bg-secondary"><i class="bi bi-x-circle me-1"></i>No</span>{{end}}</dd>
<dt class="col-sm-4">Store Message:</dt>
<dd class="col-sm-8">{{if .sender.StoreMessageContent}}<span class="badge bg-info text-dark"><i class="bi bi-file-earmark-text me-1"></i>Full Message</span>{{else}}<span class="badge bg-secondary"><i class="bi bi-file-earmark me-1"></i>Headers Only</span>{{end}}</dd>
<dt class="col-sm-4">Created:</dt><dd class="col-sm-8"><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .sender.CreatedAt}}</small></dd>
</dl>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('local_part').focus();
});
</script>
{{end}}
+66
View File
@@ -0,0 +1,66 @@
{{define "title"}}Error - SMTP Management{{end}}
{{define "content"}}
<div class="row">
<div class="col-12">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<div class="d-flex align-items-center">
<i class="fas fa-exclamation-triangle me-2"></i>
<h5 class="mb-0">Error Occurred</h5>
</div>
</div>
<div class="card-body">
{{if .error_code}}
<div class="row mb-3">
<div class="col-sm-3"><strong>Error Code:</strong></div>
<div class="col-sm-9"><span class="badge bg-danger fs-6">{{.error_code}}</span></div>
</div>
{{end}}
{{if .error_message}}
<div class="row mb-3">
<div class="col-sm-3"><strong>Message:</strong></div>
<div class="col-sm-9"><div class="alert alert-danger mb-0">{{.error_message}}</div></div>
</div>
{{end}}
{{if .error_details}}
<div class="row mb-3">
<div class="col-sm-3"><strong>Details:</strong></div>
<div class="col-sm-9">
<div class="bg-dark text-light p-3 rounded">
<pre class="mb-0"><code>{{.error_details}}</code></pre>
</div>
</div>
</div>
{{end}}
<div class="row mb-3">
<div class="col-sm-3"><strong>Timestamp:</strong></div>
<div class="col-sm-9"><span class="text-muted">{{if .current_time}}{{strftime "%Y-%m-%d %H:%M:%S" .current_time}}{{else}}Unknown{{end}}</span></div>
</div>
<div class="row">
<div class="col-sm-3"><strong>Request URL:</strong></div>
<div class="col-sm-9"><code>{{dget . "request_url"}}</code></div>
</div>
</div>
<div class="card-footer">
<div class="d-flex justify-content-between align-items-center">
<div>
<a href="/pymta-manager/" class="btn btn-primary">
<i class="fas fa-home me-1"></i>
Return to Dashboard
</a>
<button onclick="history.back()" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i>
Go Back
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
+37
View File
@@ -0,0 +1,37 @@
{{define "title"}}Set up your account{{end}}
{{define "page_title"}}Set up your account{{end}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
You're signed in with the default admin account. Choose a new username and password before continuing.
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-person-gear me-2"></i>Choose your credentials</h5></div>
<div class="card-body">
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
<form method="POST" action="/pymta-manager/first-login">
<div class="mb-3">
<label for="username" class="form-label">New username</label>
<input type="text" class="form-control" id="username" name="username" value="{{.username}}" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">New password</label>
<input type="password" class="form-control" id="password" name="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-4">
<label for="password_confirm" class="form-label">Confirm new password</label>
<input type="password" class="form-control" id="password_confirm" name="password_confirm" required minlength="10">
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Save and continue</button>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}
+116
View File
@@ -0,0 +1,116 @@
{{define "title"}}Whitelisted IPs - Email Server{{end}}
{{define "content"}}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-router me-2"></i>Whitelisted IP Addresses</h2>
<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="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>
<tbody>
{{range .ips}}
{{$ip := index . 0}}{{$domain := index . 1}}
<tr>
<td><div class="fw-bold font-monospace">{{$ip.IPAddress}}</div></td>
<td><span class="badge bg-secondary">{{$domain.domain_name}}</span></td>
<td>{{if $ip.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>{{if $ip.StoreMessageContent}}<span class="badge bg-info text-dark"><i class="bi bi-file-earmark-text me-1"></i>Stores Full Message</span>{{else}}<span class="badge bg-secondary"><i class="bi bi-file-earmark me-1"></i>Headers Only</span>{{end}}</td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" $ip.CreatedAt}}</small></td>
<td>
<div class="btn-group" role="group">
<a href="/pymta-manager/ips/{{$ip.ID}}/edit" class="btn btn-outline-primary btn-sm" title="Edit IP"><i class="bi bi-pencil"></i></a>
{{if $ip.IsActive}}
<form method="post" action="/pymta-manager/ips/{{$ip.ID}}/delete" class="d-inline">
<button type="submit" class="btn btn-outline-warning btn-sm" title="Disable IP" data-confirm="Disable {{$ip.IPAddress}}?"><i class="bi bi-pause-circle"></i></button>
</form>
{{else}}
<form method="post" action="/pymta-manager/ips/{{$ip.ID}}/enable" class="d-inline">
<button type="submit" class="btn btn-outline-success btn-sm" title="Enable IP" data-confirm="Enable {{$ip.IPAddress}}?"><i class="bi bi-play-circle"></i></button>
</form>
{{end}}
<form method="post" action="/pymta-manager/ips/{{$ip.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Permanently Remove IP" data-confirm="Permanently remove {{$ip.IPAddress}}? 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-router text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No IP Addresses Whitelisted</h4>
<p class="text-muted">Add IP addresses to allow authentication without username/password</p>
<a href="/pymta-manager/ips/add" class="btn btn-primary"><i class="bi bi-plus-circle me-2"></i>Add First IP Address</a>
</div>
{{end}}
</div>
</div>
</div>
<div class="col-lg-4">
<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">
<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>
<li>Each IP is associated with a specific domain</li>
<li>IP can only send emails for its authorized domain</li>
<li>Useful for server-to-server email sending</li>
</ul>
</div>
</div>
</div>
<div class="card mt-3">
<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">
<div class="fw-bold font-monospace fs-5" id="current-ip"><span class="spinner-border spinner-border-sm me-2"></span>Detecting...</div>
<button class="btn btn-outline-primary btn-sm mt-2" onclick="addCurrentIP()"><i class="bi bi-plus-circle me-1"></i>Add This IP</button>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
async function detectCurrentIP() {
try {
const response = await fetch('https://ifconfig.me/all.json');
const data = await response.json();
document.getElementById('current-ip').innerHTML = `<span class="text-primary">${data.ip_addr}</span>`;
} catch (error) {
document.getElementById('current-ip').innerHTML = '<span class="text-muted">Unable to detect</span>';
}
}
function addCurrentIP() {
const currentIPElement = document.getElementById('current-ip');
const ip = currentIPElement.textContent.trim();
if (ip && ip !== 'Detecting...' && ip !== 'Unable to detect') {
const url = new URL('/pymta-manager/ips/add', window.location.origin);
url.searchParams.set('ip', ip);
window.location.href = url.toString();
} else {
showToast('Unable to detect current IP address', 'danger');
}
}
detectCurrentIP();
</script>
{{end}}
+45
View File
@@ -0,0 +1,45 @@
{{define "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 - mailgoserver</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-envelope-fill" style="font-size: 2.5rem;"></i>
<h4 class="mt-2">mailgoserver</h4>
<p class="text-muted">Admin Dashboard</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="/pymta-manager/login">
<input type="hidden" name="next" value="{{.next}}">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" value="{{.username}}" 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>
<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}}
+114
View File
@@ -0,0 +1,114 @@
{{define "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 - mailgoserver</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="/pymta-manager/login/mfa">
<input type="hidden" name="next" value="{{.next}}">
<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('/pymta-manager/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('/pymta-manager/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 = {{if .next}}'{{.next}}'{{else}}'/pymta-manager/'{{end}};
} catch (e) {
errEl.textContent = e.message || 'Passkey login failed';
errEl.classList.remove('d-none');
}
});
}
</script>
</body>
</html>
{{end}}
+157
View File
@@ -0,0 +1,157 @@
{{define "title"}}Logs - Email Server{{end}}
{{define "extra_css"}}
<style>
.log-entry { border-left: 4px solid var(--bs-border-color); padding: 0.75rem; margin-bottom: 0.5rem; background-color: var(--bs-body-bg); border-radius: 0.375rem; }
.log-email { border-left-color: #0d6efd; }
.log-auth { border-left-color: #198754; }
.log-success { border-left-color: #198754; }
.log-failed { border-left-color: #dc3545; }
.log-partial { border-left-color: #fd7e14; }
</style>
{{end}}
{{define "content"}}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-journal-text me-2"></i>Emails Log</h2>
<div class="btn-group">
<a href="/pymta-manager/logs?type=all" class="btn {{if eq .filter_type "all"}}btn-primary{{else}}btn-outline-primary{{end}}"><i class="bi bi-list-ul me-1"></i>All Logs</a>
<a href="/pymta-manager/logs?type=emails" class="btn {{if eq .filter_type "emails"}}btn-primary{{else}}btn-outline-primary{{end}}"><i class="bi bi-envelope me-1"></i>Email Logs</a>
<a href="/pymta-manager/logs?type=auth" class="btn {{if eq .filter_type "auth"}}btn-primary{{else}}btn-outline-primary{{end}}"><i class="bi bi-shield-lock me-1"></i>Auth Logs</a>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Recent Activity</h5>
<button class="btn btn-outline-secondary btn-sm" onclick="location.reload()"><i class="bi bi-arrow-clockwise me-1"></i>Refresh</button>
</div>
<div class="card-body">
{{if .logs}}
{{if eq .filter_type "all"}}
{{range .logs}}
{{if eq .type "email"}}
{{$log := .data}}
{{$overall := emailOverallStatus .recipients}}
<div class="log-entry log-email log-{{if eq $overall "relayed"}}success{{else if eq $overall "partial"}}partial{{else}}failed{{end}}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<span class="badge bg-primary me-2">EMAIL</span>
<strong>{{$log.MailFrom}}</strong>
{{if $log.ToAddress}} &rarr; <span class="text-primary">To:</span> {{$log.ToAddress}}{{end}}
{{if $log.DKIMSigned}}<span class="badge bg-success ms-2"><i class="bi bi-shield-check me-1"></i>DKIM</span>{{end}}
</div>
<small class="text-muted">{{strftime "%Y-%m-%d %H:%M:%S" $log.Timestamp}}</small>
</div>
<div class="row">
<div class="col-md-6"><strong>Status:</strong> {{if eq $overall "relayed"}}<span class="text-success">Sent Successfully</span>{{else if eq $overall "partial"}}<span class="text-warning">Partial Fail</span>{{else}}<span class="text-danger">Failed</span>{{end}}</div>
<div class="col-md-6"><strong>Message ID:</strong> <code>{{$log.MessageID}}</code></div>
</div>
{{if $log.Subject}}<div class="mt-2"><strong>Subject:</strong> {{$log.Subject}}</div>{{end}}
<div class="mt-2"><a href="/pymta-manager/msg/content/{{$log.ID}}" class="btn btn-sm btn-primary"><i class="bi bi-envelope-open-text"></i> View Message Details</a></div>
</div>
{{else}}
{{$log := .data}}
<div class="log-entry log-auth log-{{if $log.Success}}success{{else}}failed{{end}}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<span class="badge bg-success me-2">AUTH</span>
<strong>{{$log.Identifier}}</strong>
<span class="badge {{if $log.Success}}bg-success{{else}}bg-danger{{end}} ms-2">{{if $log.Success}}Success{{else}}Failed{{end}}</span>
</div>
<small class="text-muted">{{formatDatetime $log.CreatedAt}}</small>
</div>
<div class="row">
<div class="col-md-6"><strong>Type:</strong> {{upper $log.AuthType}}</div>
<div class="col-md-6"><strong>IP:</strong> <code>{{if $log.IPAddress}}{{$log.IPAddress}}{{else}}N/A{{end}}</code></div>
</div>
{{if $log.Message}}<div class="mt-2"><strong>Message:</strong> {{$log.Message}}</div>{{end}}
</div>
{{end}}
{{end}}
{{else if eq .filter_type "emails"}}
{{$recMap := .recipient_logs_map}}
{{range .logs}}
{{$log := .}}
{{$recs := index $recMap .ID}}
{{$overall := emailOverallStatus $recs}}
<div class="log-entry log-email log-{{$overall}}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<strong>{{.MailFrom}}</strong>
{{if .ToAddress}} &rarr; <span class="text-primary">To:</span> {{.ToAddress}}{{end}}
{{if .CcAddresses}}<br><span class="ms-4 text-info">CC:</span> {{.CcAddresses}}{{end}}
{{if .BccAddresses}}<br><span class="ms-4 text-warning">BCC:</span> {{.BccAddresses}}{{end}}
{{if .DKIMSigned}}<span class="badge bg-success ms-2"><i class="bi bi-shield-check me-1"></i>DKIM</span>{{end}}
</div>
<small class="text-muted">{{strftime "%Y-%m-%d %H:%M:%S" .Timestamp}}</small>
</div>
<div class="row">
<div class="col-md-3"><strong>Status:</strong> {{if eq $overall "relayed"}}<span class="text-success">Sent</span>{{else if eq $overall "partial"}}<span class="text-warning">Partial Fail</span>{{else}}<span class="text-danger">Failed</span>{{end}}</div>
<div class="col-md-3"><strong>Peer:</strong> <code>{{.PeerIP}}</code></div>
<div class="col-md-6"><strong>Message ID:</strong> <code>{{.MessageID}}</code></div>
</div>
{{if $recs}}
<div class="mt-2">
<strong>Recipient Delivery Results:</strong>
<ul class="list-group">
{{range $recs}}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span><strong>{{upper .RecipientType}}:</strong> {{.Recipient}} {{if eq .Status "success"}}<span class="badge bg-success ms-2">Delivered</span>{{else}}<span class="badge bg-danger ms-2">Failed</span>{{end}}</span>
{{if or .ErrorCode .ErrorMessage}}<span class="text-danger ms-2">{{.ErrorCode}} {{.ErrorMessage}}</span>{{end}}
</li>
{{end}}
</ul>
</div>
{{end}}
{{if .Subject}}<div class="mt-2"><strong>Subject:</strong> {{.Subject}}</div>{{end}}
<div class="mt-2"><a href="/pymta-manager/msg/content/{{.ID}}" class="btn btn-outline-info btn-sm"><i class="bi bi-file-earmark-text me-1"></i> View Full Message</a></div>
</div>
{{end}}
{{else}}
{{range .logs}}
<div class="log-entry log-auth log-{{if .Success}}success{{else}}failed{{end}}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<strong>{{.Identifier}}</strong>
<span class="badge {{if .Success}}bg-success{{else}}bg-danger{{end}} ms-2">{{if .Success}}Success{{else}}Failed{{end}}</span>
</div>
<small class="text-muted">{{formatDatetime .CreatedAt}}</small>
</div>
<div class="row">
<div class="col-md-4"><strong>Type:</strong> {{upper .AuthType}}</div>
<div class="col-md-4"><strong>IP:</strong> <code>{{if .IPAddress}}{{.IPAddress}}{{else}}N/A{{end}}</code></div>
<div class="col-md-4"><strong>Result:</strong> {{if .Success}}<span class="text-success">Authenticated</span>{{else}}<span class="text-danger">Rejected</span>{{end}}</div>
</div>
{{if .Message}}<div class="mt-2"><strong>Details:</strong> {{.Message}}</div>{{end}}
</div>
{{end}}
{{end}}
{{if or .has_prev .has_next}}
<nav aria-label="Log pagination" class="mt-4">
<ul class="pagination justify-content-center">
{{if .has_prev}}<li class="page-item"><a class="page-link" href="/pymta-manager/logs?type={{.filter_type}}&page={{sub .page 1}}"><i class="bi bi-chevron-left"></i> Previous</a></li>{{end}}
<li class="page-item active"><span class="page-link">Page {{.page}}</span></li>
{{if .has_next}}<li class="page-item"><a class="page-link" href="/pymta-manager/logs?type={{.filter_type}}&page={{add .page 1}}">Next <i class="bi bi-chevron-right"></i></a></li>{{end}}
</ul>
</nav>
{{end}}
{{else}}
<div class="text-center py-5"><i class="bi bi-journal-text text-muted" style="font-size: 4rem;"></i><h4 class="text-muted mt-3">No Logs Found</h4></div>
{{end}}
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
setInterval(function() { if (document.visibilityState === 'visible') { location.reload(); } }, 30000);
</script>
{{end}}
+73
View File
@@ -0,0 +1,73 @@
{{define "title"}}Senders - Email Server Management{{end}}
{{define "page_title"}}Sender Management{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-people me-2"></i>Senders</h2>
<a href="/pymta-manager/senders/add" class="btn btn-primary"><i class="bi bi-person-plus me-2"></i>Add Sender</a>
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>All Senders</h5></div>
<div class="card-body p-0">
{{if .senders}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Email</th><th>Domain</th><th>Permissions</th><th>Status</th><th>Storage</th><th>Created</th><th>Actions</th></tr></thead>
<tbody>
{{range .senders}}
{{$sender := index . 0}}{{$domain := index . 1}}
<tr>
<td><div class="fw-bold">{{$sender.Email}}</div></td>
<td><span class="badge bg-secondary">{{$domain.domain_name}}</span></td>
<td>
{{if $sender.CanSendAsDomain}}
<span class="badge bg-warning" style="color: black;"><i class="bi bi-star me-1"></i>Domain Sender</span><br>
<small class="text-muted">Can send as *@{{$domain.domain_name}}</small>
{{else}}
<span class="badge bg-info" style="color: black;"><i class="bi bi-person me-1"></i>Regular Sender</span><br>
<small class="text-muted">Can only send as {{$sender.Email}}</small>
{{end}}
</td>
<td>
{{if $sender.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>
{{if $sender.StoreMessageContent}}<span class="badge bg-info text-dark"><i class="bi bi-file-earmark-text me-1"></i>Stores Full Message</span>
{{else}}<span class="badge bg-secondary"><i class="bi bi-file-earmark me-1"></i>Headers Only</span>{{end}}
</td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" $sender.CreatedAt}}</small></td>
<td>
<div class="btn-group" role="group">
<a href="/pymta-manager/senders/{{$sender.ID}}/edit" class="btn btn-outline-primary btn-sm" title="Edit Sender"><i class="bi bi-pencil"></i></a>
{{if $sender.IsActive}}
<form method="post" action="/pymta-manager/senders/{{$sender.ID}}/delete" class="d-inline">
<button type="submit" class="btn btn-outline-warning btn-sm" title="Disable Sender" data-confirm="Disable user {{$sender.Email}}?"><i class="bi bi-pause-circle"></i></button>
</form>
{{else}}
<form method="post" action="/pymta-manager/senders/{{$sender.ID}}/enable" class="d-inline">
<button type="submit" class="btn btn-outline-success btn-sm" title="Enable Sender" data-confirm="Enable user {{$sender.Email}}?"><i class="bi bi-play-circle"></i></button>
</form>
{{end}}
<form method="post" action="/pymta-manager/senders/{{$sender.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Permanently Remove Sender" data-confirm="Permanently remove user {{$sender.Email}}? 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-people text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No senders configured</h4>
<p class="text-muted">Add sender to enable username/password authentication</p>
<a href="/pymta-manager/senders/add" class="btn btn-primary"><i class="bi bi-person-plus me-2"></i>Add Your First Sender</a>
</div>
{{end}}
</div>
</div>
{{end}}
+277
View File
@@ -0,0 +1,277 @@
{{define "title"}}Server Settings - Email Server{{end}}
{{define "extra_css"}}
<style>
.setting-section { border-left: 4px solid var(--bs-primary); padding-left: 1rem; margin-bottom: 2rem; }
.setting-description { font-size: 0.875rem; color: var(--bs-secondary); margin-bottom: 0.5rem; }
</style>
{{end}}
{{define "content"}}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-sliders me-2"></i>Server Settings</h2>
<div class="btn-group">
<button type="button" class="btn btn-outline-info" onclick="exportSettings()"><i class="bi bi-download me-2"></i>Export Config</button>
</div>
</div>
<form method="POST" action="/pymta-manager/settings_update" id="settingsForm">
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-server me-2"></i>Server Configuration</h5></div>
<div class="card-body">
<div class="setting-section">
<div class="row">
<div class="col-md-6"><div class="mb-3"><label class="form-label">SMTP Port</label>
<div class="setting-description">Port for plain/IP-whitelisted SMTP connections</div>
<input type="number" class="form-control" name="Server.smtp_port" value="{{.settings.Server.smtp_port}}" min="1" max="65535">
</div></div>
<div class="col-md-6"><div class="mb-3"><label class="form-label">SMTP TLS Port</label>
<div class="setting-description">Port for direct-TLS authenticated SMTP connections</div>
<input type="number" class="form-control" name="Server.smtp_tls_port" value="{{.settings.Server.smtp_tls_port}}" min="1" max="65535">
</div></div>
</div>
<div class="row">
<div class="col-md-6"><div class="mb-3"><label class="form-label">Bind IP Address</label>
<input type="text" class="form-control" name="Server.bind_ip" value="{{.settings.Server.bind_ip}}">
</div></div>
<div class="col-md-6"><div class="mb-3"><label class="form-label">Server Timezone</label>
<select class="form-select" name="Server.time_zone">
{{$currentTZ := .settings.Server.time_zone}}
{{range .timezones}}<option value="{{.}}" {{if eq . $currentTZ}}selected{{end}}>{{.}}</option>{{end}}
</select>
</div></div>
</div>
<div class="row">
<div class="col-md-6"><div class="mb-3"><label class="form-label">Hostname</label>
<input type="text" class="form-control" name="Server.hostname" value="{{.settings.Server.hostname}}">
</div></div>
<div class="col-md-6"><div class="mb-3"><label class="form-label">HELO Hostname</label>
<input type="text" class="form-control" name="Server.helo_hostname" value="{{.settings.Server.helo_hostname}}">
</div></div>
</div>
<div class="row">
<div class="col-md-6"><div class="mb-3"><label class="form-label">Server Banner</label>
<div class="setting-description">Custom SMTP banner (empty by default)</div>
<input type="text" class="form-control" name="Server.server_banner" value="{{.settings.Server.server_banner}}">
</div></div>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-database me-2"></i>Database Configuration</h5></div>
<div class="card-body">
<div class="setting-section">
<div class="mb-3">
<label class="form-label">Database URL</label>
<div class="input-group mb-2">
<input type="text" class="form-control font-monospace" name="Database.database_url" id="databaseUrl" value="{{.settings.Database.database_url}}">
<button class="btn btn-primary" type="button" onclick="testDatabaseConnection()"><i class="bi bi-check-circle me-1"></i>Test Connection</button>
</div>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-journal-text me-2"></i>Logging Configuration</h5></div>
<div class="card-body">
<div class="setting-section">
<div class="row">
<div class="col-md-6"><div class="mb-3"><label class="form-label">Log Level</label>
<select class="form-select" name="Logging.log_level">
{{$lvl := .settings.Logging.log_level}}
{{range (list "DEBUG" "INFO" "WARNING" "ERROR" "CRITICAL")}}<option value="{{.}}" {{if eq . $lvl}}selected{{end}}>{{.}}</option>{{end}}
</select>
</div></div>
<div class="col-md-6"><div class="mb-3"><label class="form-label">Hide aiosmtpd-equivalent INFO Messages</label>
<select class="form-select" name="Logging.hide_info_aiosmtpd">
<option value="true" {{if eq .settings.Logging.hide_info_aiosmtpd "true"}}selected{{end}}>Yes</option>
<option value="false" {{if eq .settings.Logging.hide_info_aiosmtpd "false"}}selected{{end}}>No</option>
</select>
</div></div>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-arrow-repeat me-2"></i>Email Relay Configuration</h5></div>
<div class="card-body">
<div class="setting-section">
<div class="mb-3"><label class="form-label">Relay Timeout (seconds)</label>
<input type="number" class="form-control" name="Relay.relay_timeout" value="{{.settings.Relay.relay_timeout}}" min="5" max="300">
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>TLS/SSL Configuration</h5></div>
<div class="card-body">
<div class="setting-section">
<div class="row">
<div class="col-md-6"><div class="mb-3"><label class="form-label">TLS Certificate File</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" name="TLS.tls_cert_file" value="{{.settings.TLS.tls_cert_file}}">
<input type="file" class="d-none" id="certFileUpload" accept=".crt,.pem">
<button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('certFileUpload').click()"><i class="bi bi-upload"></i></button>
</div>
</div></div>
<div class="col-md-6"><div class="mb-3"><label class="form-label">TLS Private Key File</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" name="TLS.tls_key_file" value="{{.settings.TLS.tls_key_file}}">
<input type="file" class="d-none" id="keyFileUpload" accept=".key,.pem">
<button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('keyFileUpload').click()"><i class="bi bi-upload"></i></button>
</div>
</div></div>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-key me-2"></i>DKIM Configuration</h5></div>
<div class="card-body">
<div class="setting-section">
<div class="mb-3"><label class="form-label">DKIM Key Size</label>
<select class="form-select" name="DKIM.dkim_key_size">
{{$ks := .settings.DKIM.dkim_key_size}}
<option value="1024" {{if eq $ks "1024"}}selected{{end}}>1024 bits</option>
<option value="2048" {{if eq $ks "2048"}}selected{{end}}>2048 bits (Recommended)</option>
<option value="4096" {{if eq $ks "4096"}}selected{{end}}>4096 bits</option>
</select>
</div>
<div class="mb-3"><label class="form-label">SPF Server IP</label>
<div class="input-group">
<input type="text" class="form-control" name="DKIM.spf_server_ip" value="{{.settings.DKIM.spf_server_ip}}">
<button class="btn btn-danger" type="button" onclick="getPublicIP()"><i class="bi bi-cloud-download me-1"></i>Get Public IP</button>
</div>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-paperclip me-2"></i>Attachments Configuration</h5></div>
<div class="card-body">
<div class="setting-section">
<div class="mb-3"><label class="form-label">Attachments Storage Path</label>
<input type="text" class="form-control" name="Attachments.attachments_path" value="{{.settings.Attachments.attachments_path}}" placeholder="server_data/attachments">
<div class="setting-description text-warning"><i class="bi bi-exclamation-triangle me-1"></i>Make sure the path exists and is writable by the server process</div>
<div id="attachments-path-feedback" class="mt-2"></div>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="alert alert-warning d-flex align-items-center mb-0"><i class="bi bi-exclamation-triangle me-2"></i><small>Server restart required after changing settings</small></div>
<button type="submit" class="btn btn-primary btn-lg"><i class="bi bi-save me-2"></i>Save Settings</button>
</div>
</form>
</div>
{{end}}
{{define "extra_js"}}
<script>
function exportSettings() {
const settings = {};
const formData = new FormData(document.querySelector('form'));
for (let [key, value] of formData.entries()) {
const [section, setting] = key.split('.');
if (!settings[section]) { settings[section] = {}; }
settings[section][setting] = value;
}
let config = '';
for (const [section, values] of Object.entries(settings)) {
config += `[${section}]\n`;
for (const [key, value] of Object.entries(values)) { config += `${key} = ${value}\n`; }
config += '\n';
}
const element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(config));
element.setAttribute('download', 'settings.ini');
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
document.querySelector('form').addEventListener('submit', function(e) {
const ports = ['Server.smtp_port', 'Server.smtp_tls_port'];
for (const portField of ports) {
const input = document.querySelector(`[name="${portField}"]`);
const port = parseInt(input.value);
if (port < 1 || port > 65535) { e.preventDefault(); showToast(`Invalid port number: ${port}.`, 'danger'); input.focus(); return; }
}
const smtpPort = document.querySelector('[name="Server.smtp_port"]').value;
const tlsPort = document.querySelector('[name="Server.smtp_tls_port"]').value;
if (smtpPort === tlsPort) { e.preventDefault(); showToast('SMTP and TLS ports must be different.', 'danger'); return; }
const serverBanner = document.querySelector('[name="Server.server_banner"]');
if (serverBanner && !serverBanner.value.trim()) { serverBanner.value = '""'; }
const attachmentsPath = document.querySelector('input[name="Attachments.attachments_path"]');
if (!attachmentsPath.value.trim()) { e.preventDefault(); showToast('Please specify a valid attachments storage path', 'danger'); attachmentsPath.focus(); }
});
document.getElementById('certFileUpload').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('cert_file', file);
fetch('/pymta-manager/api/settings/upload_cert', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.status === 'success') { document.querySelector('[name="TLS.tls_cert_file"]').value = data.filepath; showToast('Certificate uploaded', 'success'); }
else { showToast(data.message || 'Failed to upload certificate', 'danger'); }
}).catch(() => showToast('Failed to upload certificate', 'danger'));
});
document.getElementById('keyFileUpload').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('key_file', file);
fetch('/pymta-manager/api/settings/upload_key', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.status === 'success') { document.querySelector('[name="TLS.tls_key_file"]').value = data.filepath; showToast('Key uploaded', 'success'); }
else { showToast(data.message || 'Failed to upload key', 'danger'); }
}).catch(() => showToast('Failed to upload key', 'danger'));
});
function testDatabaseConnection() {
const url = document.getElementById('databaseUrl').value;
fetch('/pymta-manager/api/settings/test_database', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({url}) })
.then(r => r.json())
.then(data => showToast(data.message || (data.status === 'success' ? 'Connection successful!' : 'Failed to connect'), data.status === 'success' ? 'success' : 'danger'))
.catch(() => showToast('Failed to test database connection', 'danger'));
}
function getPublicIP() {
fetch('/pymta-manager/api/settings/get_public_ip')
.then(r => r.json())
.then(data => {
if (data.ip) { document.querySelector('[name="DKIM.spf_server_ip"]').value = data.ip; showToast('Public IP fetched', 'success'); }
else { showToast('Failed to fetch public IP', 'danger'); }
}).catch(() => showToast('Failed to fetch public IP', 'danger'));
}
function validateAttachmentsPath() {
const path = document.querySelector('input[name="Attachments.attachments_path"]').value;
const feedback = document.getElementById('attachments-path-feedback');
if (!feedback) return;
fetch('/pymta-manager/test_attachments_path', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: `path=${encodeURIComponent(path)}` })
.then(r => r.json())
.then(data => {
feedback.innerHTML = data.message + (data.success ? `<br><small class="text-muted">Absolute path: ${data.absolute_path}</small>` : '');
feedback.className = data.success ? 'text-success mt-2' : 'text-danger mt-2';
}).catch(error => { feedback.innerHTML = `Error validating path: ${error}`; feedback.className = 'text-danger mt-2'; });
}
document.querySelector('input[name="Attachments.attachments_path"]')?.addEventListener('change', validateAttachmentsPath);
</script>
{{end}}
+150
View File
@@ -0,0 +1,150 @@
{{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>
<div class="flex-grow-1 overflow-auto">
<ul class="nav nav-pills flex-column p-3">
<li class="nav-item mb-2">
<a href="/pymta-manager/" class="nav-link text-white {{if eq (dget . "active") "dashboard"}}active{{end}}">
<i class="bi bi-speedometer2 me-2"></i>
Dashboard
</a>
</li>
<li class="nav-item mb-2">
<h6 class="text-muted text-uppercase small mb-2 mt-3">
<i class="bi bi-globe me-1"></i>
Email Server Management
</h6>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/domains" class="nav-link text-white {{if eq (dget . "active") "domains"}}active{{end}}">
<i class="bi bi-list-ul me-2"></i>
Domains
<span class="badge bg-secondary ms-auto">{{dget . "domain_count"}}</span>
</a>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/senders" class="nav-link text-white {{if eq (dget . "active") "senders"}}active{{end}}">
<i class="bi bi-people me-2"></i>
Allowed Senders
<span class="badge bg-secondary ms-auto">{{dget . "sender_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>
Whitelisted IPs
<span class="badge bg-secondary ms-auto">{{dget . "ip_count"}}</span>
</a>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/dkim" class="nav-link text-white {{if eq (dget . "active") "dkim"}}active{{end}}">
<i class="bi bi-shield-check me-2"></i>
DKIM Keys
<span class="badge bg-secondary ms-auto">{{dget . "dkim_count"}}</span>
</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>
Emails Log
</a>
</li>
<li class="nav-item mb-2">
<h6 class="text-muted text-uppercase small mb-2 mt-3">
<i class="bi bi-gear me-1"></i>
Configuration
</h6>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/settings" class="nav-link text-white {{if eq (dget . "active") "settings"}}active{{end}}">
<i class="bi bi-sliders me-2"></i>
Server Settings
</a>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/admins" class="nav-link text-white {{if eq (dget . "active") "admins"}}active{{end}}">
<i class="bi bi-people-fill me-2"></i>
Admins
</a>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/account" class="nav-link text-white {{if eq (dget . "active") "account"}}active{{end}}">
<i class="bi bi-person-circle me-2"></i>
Account
</a>
</li>
<li class="nav-item mb-1">
<form method="post" action="/pymta-manager/logout">
<button type="submit" class="nav-link text-white w-100 text-start border-0 bg-transparent">
<i class="bi bi-box-arrow-right me-2"></i>
Sign out
</button>
</form>
</li>
</ul>
</div>
<div class="p-3 border-top border-secondary">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<small class="text-muted d-block">Server Status</small>
<small class="{{if eq .health.Status "healthy"}}text-success{{else}}text-warning{{end}} status-indicator"
data-bs-toggle="tooltip"
data-bs-html="true"
data-bs-placement="top"
title="{{safe (printf "<div class='text-start'><strong>Service Status:</strong><br>SMTP Server: %s<br>Web Frontend: %s<br>Database: %s</div>" (title .health.Services.smtp_server) (title .health.Services.web_frontend) (title .health.Services.database))}}">
<i class="bi bi-circle-fill me-1" style="font-size: 0.5rem;"></i>
{{title .health.Status}}
</small>
</div>
<button class="btn btn-outline-secondary btn-sm" title="Refresh Status" onclick="location.reload()">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
</div>
</nav>
<style>
.sidebar .nav-link { border-radius: 0.375rem; padding: 0.75rem 1rem; margin-bottom: 0.25rem; transition: all 0.2s ease; }
.sidebar .nav-link:hover { background-color: rgba(255, 255, 255, 0.1); transform: translateX(4px); }
.sidebar .nav-link.active { background-color: #0d6efd; color: white !important; }
.sidebar .nav-link.active:hover { background-color: #0b5ed7; }
.sidebar h6 { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.05em; border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding-bottom: 0.5rem; margin-bottom: 1rem !important; }
.sidebar .badge { font-size: 0.7rem; }
.status-indicator { cursor: pointer; }
@media (max-width: 768px) {
.sidebar { transform: translateX(-100%); transition: transform 0.3s ease; }
.sidebar.show { transform: translateX(0); }
.content-area { margin-left: 0 !important; }
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltipTriggerList.forEach(function(tooltipTriggerEl) {
new bootstrap.Tooltip(tooltipTriggerEl, { html: true, placement: 'top', trigger: 'hover' });
});
});
</script>
{{end}}
+30
View File
@@ -0,0 +1,30 @@
{{define "title"}}Set up authenticator app{{end}}
{{define "page_title"}}Set up authenticator app{{end}}
{{define "content"}}
<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="/pymta-manager/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="/pymta-manager/account" 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>
{{end}}
@@ -0,0 +1,49 @@
{{define "title"}}View Full Message - Email Log{{end}}
{{define "content"}}
<div class="container mt-4">
<h2>Full Message Content</h2>
<div class="mb-3">
<strong>From:</strong> {{.log.mail_from}}<br>
<strong>To:</strong> {{.log.to_address}}<br>
<strong>CC:</strong> {{if .log.cc_addresses}}{{.log.cc_addresses}}{{else}}None{{end}}<br>
<strong>BCC:</strong> {{if .log.bcc_addresses}}{{.log.bcc_addresses}}{{else}}None{{end}}<br>
<strong>Subject:</strong> {{if .log.subject}}{{.log.subject}}{{else}}N/A{{end}}<br>
<strong>Date:</strong> {{strftime "%Y-%m-%d %H:%M:%S" .log.created_at}}<br>
</div>
{{if .log.attachments}}
<div class="card mb-3">
<div class="card-header"><strong>Attachments:</strong></div>
<div class="card-body">
<ul class="list-group">
{{range .log.attachments}}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div><i class="fas fa-paperclip"></i> {{.Filename}} <small class="text-muted">({{filesize .Size}})</small></div>
<div class="btn-group" role="group">
<a href="/pymta-manager/msg/attachment/{{.ID}}/download" class="btn btn-sm btn-outline-primary" target="_blank" title="Open in new tab"><i class="fas fa-external-link-alt"></i> View</a>
<a href="/pymta-manager/msg/attachment/{{.ID}}/download?download=true" class="btn btn-sm btn-outline-secondary" title="Download file"><i class="fas fa-download"></i> Download</a>
<form method="POST" action="/pymta-manager/msg/attachment/{{.ID}}/delete" style="display: inline;">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete attachment" data-confirm="Are you sure you want to delete this attachment?"><i class="fas fa-trash-alt"></i> Delete</button>
</form>
</div>
</li>
{{end}}
</ul>
</div>
</div>
{{end}}
<div class="card">
<div class="card-header"><strong>Message Content:</strong></div>
<div class="card-body"><pre style="white-space: pre-wrap; word-break: break-all;">{{.log.message_body}}</pre></div>
</div>
<div class="card mt-3">
<div class="card-header"><strong>Message Headers:</strong></div>
<div class="card-body"><pre style="white-space: pre-wrap;">{{.log.email_headers}}</pre></div>
</div>
<a href="/pymta-manager/logs?type=emails" class="btn btn-secondary mt-3">Back to Logs</a>
</div>
{{end}}
+137
View File
@@ -0,0 +1,137 @@
package webui
import (
"context"
"crypto/tls"
"io"
"net"
"net/http"
"strings"
"time"
"gopkg.in/ini.v1"
)
// getPublicIP mirrors server_web_ui/utils.py's get_public_ip: try ifconfig.me, then
// httpbin.org, then the configured SPF_SERVER_IP fallback, then 127.0.0.1.
func getPublicIP(cfg *ini.File) string {
client := &http.Client{
Timeout: 3 * time.Second,
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
}
if ip := fetchBody(client, "http://ifconfig.me/ip"); ip != "" {
return strings.TrimSpace(ip)
}
if ip := fetchBody(client, "http://httpbin.org/ip"); ip != "" {
return strings.TrimSpace(ip)
}
fallback := cfg.Section("DKIM").Key("SPF_SERVER_IP").MustString("")
if net.ParseIP(fallback) != nil {
return fallback
}
return "127.0.0.1"
}
func fetchBody(client *http.Client, url string) string {
resp, err := client.Get(url)
if err != nil {
return ""
}
defer resp.Body.Close()
b, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return ""
}
return string(b)
}
// resolverAt builds a resolver pinned to a specific DNS server, mirroring
// utils.check_dns_record's hardcoded Cloudflare resolver (1.1.1.1), 5s timeout.
func resolverAt(serverIP string) *net.Resolver {
return &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{Timeout: 5 * time.Second}
return d.DialContext(ctx, network, net.JoinHostPort(serverIP, "53"))
},
}
}
var pinnedResolver = resolverAt("1.1.1.1")
type dnsCheckResult struct {
Success bool
Message string
Records []string
}
// checkDNSRecord mirrors utils.check_dns_record for TXT lookups.
func checkDNSRecord(domain string) dnsCheckResult {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
recs, err := pinnedResolver.LookupTXT(ctx, domain)
if err != nil {
return dnsCheckResult{Success: false, Message: err.Error()}
}
if len(recs) == 0 {
return dnsCheckResult{Success: false, Message: "No TXT records found"}
}
return dnsCheckResult{Success: true, Records: recs}
}
// verificationRecordName is the DNS TXT record name a domain's ownership proof lives
// at, e.g. "_pymta-verify.example.com".
func verificationRecordName(domain string) string {
return "_pymta-verify." + domain
}
func verificationRecordValue(token string) string {
return "pymta-verify=" + token
}
// checkDomainOwnership looks up the verification TXT record via two independent public
// resolvers (1.1.1.1 and 8.8.8.8, per the user's requirement) and considers the domain
// verified if the expected token shows up via either — a domain that's genuinely been
// updated can otherwise show as unverified for a while against whichever resolver has
// a stale cache, so requiring both to agree at the same instant would be a flaky check.
func checkDomainOwnership(domain, token string) (verified bool, checkedRecords []string, err error) {
recordName := verificationRecordName(domain)
expected := verificationRecordValue(token)
var lastErr error
for _, resolverIP := range []string{"1.1.1.1", "8.8.8.8"} {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
recs, lookupErr := resolverAt(resolverIP).LookupTXT(ctx, recordName)
cancel()
if lookupErr != nil {
lastErr = lookupErr
continue
}
checkedRecords = append(checkedRecords, recs...)
for _, r := range recs {
if strings.TrimSpace(r) == expected {
return true, checkedRecords, nil
}
}
}
if len(checkedRecords) == 0 && lastErr != nil {
return false, nil, lastErr
}
return false, checkedRecords, nil
}
// generateSPFRecord mirrors utils.generate_spf_record.
func generateSPFRecord(serverIP, existingSPF string) string {
if existingSPF == "" {
return "v=spf1 ip4:" + serverIP + " ~all"
}
if strings.Contains(existingSPF, "ip4:"+serverIP) {
return existingSPF
}
for _, all := range []string{"-all", "~all", "all"} {
if idx := strings.LastIndex(existingSPF, all); idx >= 0 {
return strings.TrimSpace(existingSPF[:idx]) + " ip4:" + serverIP + " " + all
}
}
return existingSPF + " ip4:" + serverIP + " ~all"
}
+147
View File
@@ -0,0 +1,147 @@
package webui
import (
"net/http"
"os"
"strings"
"mailgoserver/internal/db"
)
// emailLogAccessible checks a scoped admin's domain assignment against the sender
// domain of an email log's MAIL FROM address — these logs predate per-domain admin
// scoping and have no domain_id column, so this is the same text-domain heuristic as
// accessibleDomainNames/emailDomain, not a foreign key.
func (a *App) emailLogAccessible(r *http.Request, mailFrom string) (bool, error) {
names, isGlobal, err := a.accessibleDomainNames(r)
if err != nil {
return false, err
}
return isGlobal || names[emailDomain(mailFrom)], nil
}
// viewMessageContent mirrors view_message.py's view_message_content().
func (a *App) viewMessageContent(w http.ResponseWriter, r *http.Request) {
log, err := a.DB.GetEmailLogByID(pathID(r))
if err != nil || log == nil {
http.NotFound(w, r)
return
}
if ok, err := a.emailLogAccessible(r, log.MailFrom); err != nil || !ok {
http.NotFound(w, r)
return
}
attachments, _ := a.DB.ListAttachmentsForEmail(log.ID)
a.render(w, r, "view_message_content.html", M{"active": "logs", "log": M{
"id": log.ID, "mail_from": log.MailFrom, "to_address": log.ToAddress,
"cc_addresses": log.CcAddresses, "bcc_addresses": log.BccAddresses,
"subject": log.Subject, "created_at": log.CreatedAt, "message_body": log.MessageBody,
"email_headers": log.EmailHeaders, "attachments": attachments,
}})
}
var extContentType = map[string]string{
".txt": "text/plain", ".csv": "text/csv", ".pdf": "application/pdf",
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
".svg": "image/svg+xml", ".html": "text/html", ".htm": "text/html",
".json": "application/json", ".xml": "application/xml", ".md": "text/markdown",
}
// downloadAttachment mirrors view_message.py's download_attachment(), including its
// CSV-to-HTML-table inline preview special case.
func (a *App) downloadAttachment(w http.ResponseWriter, r *http.Request) {
att, err := a.DB.GetAttachmentByID(pathID(r))
if err != nil || att == nil {
http.NotFound(w, r)
return
}
if !a.attachmentAccessible(w, r, att) {
return
}
if _, err := os.Stat(att.FilePath); err != nil {
setFlash(w, "error", "Attachment file not found on disk")
http.Redirect(w, r, Prefix+"/logs?type=emails", http.StatusFound)
return
}
contentType := att.ContentType
if contentType == "" || contentType == "application/octet-stream" {
if ext := extOfName(att.Filename); ext != "" {
if ct, ok := extContentType[ext]; ok {
contentType = ct
}
}
}
asAttachment := r.URL.Query().Get("download") == "true"
if contentType == "text/csv" && !asAttachment {
data, err := os.ReadFile(att.FilePath)
if err != nil {
http.Error(w, "read error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte("<table border=1>"))
for _, line := range strings.Split(string(data), "\n") {
w.Write([]byte("<tr>"))
for _, cell := range strings.Split(line, ",") {
w.Write([]byte("<td>" + template_htmlEscape(cell) + "</td>"))
}
w.Write([]byte("</tr>"))
}
w.Write([]byte("</table>"))
return
}
if asAttachment {
w.Header().Set("Content-Disposition", `attachment; filename="`+att.Filename+`"`)
}
w.Header().Set("Content-Type", contentType)
http.ServeFile(w, r, att.FilePath)
}
// attachmentAccessible checks the scoped-admin domain restriction against the parent
// email log's sender domain; writes 404 and returns false if disallowed.
func (a *App) attachmentAccessible(w http.ResponseWriter, r *http.Request, att *db.EmailAttachment) bool {
log, err := a.DB.GetEmailLogByID(att.EmailLogID)
if err != nil || log == nil {
http.NotFound(w, r)
return false
}
if ok, err := a.emailLogAccessible(r, log.MailFrom); err != nil || !ok {
http.NotFound(w, r)
return false
}
return true
}
func extOfName(filename string) string {
if i := strings.LastIndex(filename, "."); i >= 0 {
return strings.ToLower(filename[i:])
}
return ""
}
func template_htmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
return r.Replace(s)
}
// deleteAttachment mirrors view_message.py's delete_attachment(): accepts GET or POST.
func (a *App) deleteAttachment(w http.ResponseWriter, r *http.Request) {
att, err := a.DB.GetAttachmentByID(pathID(r))
if err != nil || att == nil {
http.NotFound(w, r)
return
}
if !a.attachmentAccessible(w, r, att) {
return
}
os.Remove(att.FilePath)
if err := a.DB.RemoveAttachment(att.ID); err != nil {
setFlash(w, "error", "Error deleting attachment")
} else {
setFlash(w, "success", "Attachment deleted")
}
http.Redirect(w, r, Prefix+"/logs?type=emails", http.StatusFound)
}
+244
View File
@@ -0,0 +1,244 @@
package webui
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"strconv"
"github.com/go-webauthn/webauthn/webauthn"
"mailgoserver/internal/db"
)
// webauthnSessionCookie carries the SessionData between a WebAuthn ceremony's Begin
// and Finish steps — short-lived, httponly, holds no secret beyond the challenge
// itself (which is meaningless without the matching authenticator response).
const webauthnSessionCookie = "mailgoserver_webauthn_session"
// webauthnUser adapts an AdminUser + their stored credentials to webauthn.User.
type webauthnUser struct {
user *db.AdminUser
creds []db.WebAuthnCredential
}
func (u *webauthnUser) WebAuthnID() []byte {
sum := sha256.Sum256([]byte("admin-" + strconv.FormatInt(u.user.ID, 10)))
return sum[:]
}
func (u *webauthnUser) WebAuthnName() string { return u.user.Username }
func (u *webauthnUser) WebAuthnDisplayName() string { return u.user.Username }
func (u *webauthnUser) 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) webauthnUserFor(user *db.AdminUser) (*webauthnUser, error) {
creds, err := a.DB.ListWebAuthnCredentials(user.ID)
if err != nil {
return nil, err
}
return &webauthnUser{user: user, creds: creds}, nil
}
func (a *App) buildWebAuthn() (*webauthn.WebAuthn, error) {
sec := a.Cfg.Section("Auth")
return webauthn.New(&webauthn.Config{
RPID: sec.Key("rp_id").MustString("localhost"),
RPDisplayName: sec.Key("rp_display_name").MustString("mailgoserver"),
RPOrigins: []string{sec.Key("rp_origin").MustString("http://localhost:5000")},
})
}
func saveWebauthnSession(w http.ResponseWriter, s *webauthn.SessionData) error {
b, err := json.Marshal(s)
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: webauthnSessionCookie, Value: base64.URLEncoding.EncodeToString(b),
Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 5 * 60,
})
return nil
}
func loadWebauthnSession(r *http.Request) (*webauthn.SessionData, error) {
c, err := r.Cookie(webauthnSessionCookie)
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 clearWebauthnSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: webauthnSessionCookie, Value: "", Path: "/", MaxAge: -1})
}
// passkeyRegisterBegin starts enrolling a new passkey for the logged-in admin.
func (a *App) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
user := userFromContext(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.webauthnUserFor(user)
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 := saveWebauthnSession(w, session); err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start registration"})
return
}
writeJSON(w, http.StatusOK, creation)
}
// passkeyRegisterFinish completes enrollment and stores the new credential.
func (a *App) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r)
wa, err := a.buildWebAuthn()
if err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()})
return
}
session, err := loadWebauthnSession(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, M{"error": "Registration session expired — try again"})
return
}
wu, err := a.webauthnUserFor(user)
if err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not load account"})
return
}
cred, err := wa.FinishRegistration(wu, *session, r)
clearWebauthnSession(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.CreateWebAuthnCredential(user.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) passkeyRemove(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r)
if err := a.DB.DeleteWebAuthnCredential(pathID(r), user.ID); err != nil {
setFlash(w, "error", "Could not remove passkey")
} else {
setFlash(w, "success", "Passkey removed")
}
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
}
// passkeyLoginBegin starts the passkey ceremony for the user who's already passed
// their password and is now at the MFA step.
func (a *App) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
userID := pendingMFAUserID(r)
if userID == 0 {
writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"})
return
}
user, err := a.DB.GetAdminUserByID(userID)
if err != nil || user == 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.webauthnUserFor(user)
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 := saveWebauthnSession(w, session); err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start login"})
return
}
writeJSON(w, http.StatusOK, assertion)
}
// passkeyLoginFinish verifies the assertion and, on success, promotes the pending
// login into a fully-verified session — the same outcome as a correct TOTP code.
func (a *App) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
userID := pendingMFAUserID(r)
if userID == 0 {
writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"})
return
}
user, err := a.DB.GetAdminUserByID(userID)
if err != nil || user == 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 := loadWebauthnSession(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, M{"error": "Login session expired — try again"})
return
}
wu, err := a.webauthnUserFor(user)
if err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not load account"})
return
}
if _, err := wa.FinishLogin(wu, *session, r); err != nil {
clearWebauthnSession(w)
writeJSON(w, http.StatusUnauthorized, M{"error": "Passkey verification failed"})
return
}
clearWebauthnSession(w)
token, err := a.DB.CreateSession(user.ID, true, sessionTTL)
if err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start session"})
return
}
clearPendingMFACookie(w)
setSessionCookie(w, token, r.TLS != nil)
writeJSON(w, http.StatusOK, M{"success": true})
}
+170
View File
@@ -0,0 +1,170 @@
// Package webui is the admin web interface, mirroring email_server/server_web_ui/.
// It's mounted at /pymta-manager, matching the Flask blueprint's url_prefix exactly.
package webui
import (
"html/template"
"io/fs"
"net/http"
"time"
"gopkg.in/ini.v1"
"mailgoserver/internal/db"
"mailgoserver/internal/dkim"
"mailgoserver/internal/toolbox"
)
const Prefix = "/pymta-manager"
// App holds every dependency the web routes need, mirroring the module-level
// singletons (Session, DKIMManager, settings) that server_web_ui/*.py imports.
type App struct {
DB *db.DB
DKIM *dkim.Manager
Cfg *ini.File
ConfigPath string
Logger *toolbox.Logger
SMTPUp func() bool // reports whether the SMTP listeners are currently running
templates map[string]*template.Template
}
// 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}
if err := a.loadTemplates(); err != nil {
return nil, err
}
return a, nil
}
type healthStatus struct {
Status string
Timestamp string
Services map[string]string
}
// checkHealth mirrors app.py's SMTPServerApp.check_health.
func (a *App) checkHealth() healthStatus {
dbStatus := "ok"
if err := a.DB.Ping(); err != nil {
dbStatus = "error"
}
smtpStatus := "stopped"
if a.SMTPUp != nil && a.SMTPUp() {
smtpStatus = "running"
}
overall := "healthy"
if smtpStatus == "stopped" || dbStatus == "error" {
overall = "degraded"
}
return healthStatus{
Status: overall,
Timestamp: time.Now().Format(time.RFC3339),
Services: map[string]string{"smtp_server": smtpStatus, "web_frontend": "running", "database": dbStatus},
}
}
// Mux builds the *http.ServeMux for the whole admin UI, mirroring routes.py's
// blueprint registration plus every route file's own routes. Everything except
// login/MFA and static assets requires a valid, fully-verified session — see
// requireAuth in auth.go.
func (a *App) Mux() *http.ServeMux {
outer := http.NewServeMux()
staticFS, err := fs.Sub(assets, "static")
if err != nil {
panic(err) // embed.go's directive is malformed if this ever fails
}
outer.Handle("GET "+Prefix+"/static/", http.StripPrefix(Prefix+"/static/", http.FileServerFS(staticFS)))
outer.HandleFunc("GET "+Prefix+"/login", a.loginForm)
outer.HandleFunc("POST "+Prefix+"/login", a.loginSubmit)
outer.HandleFunc("GET "+Prefix+"/login/mfa", a.mfaForm)
outer.HandleFunc("POST "+Prefix+"/login/mfa", a.mfaSubmit)
outer.HandleFunc("GET "+Prefix+"/login/passkey/begin", a.passkeyLoginBegin)
outer.HandleFunc("POST "+Prefix+"/login/passkey/finish", a.passkeyLoginFinish)
outer.HandleFunc("POST "+Prefix+"/logout", a.logout)
mux := http.NewServeMux()
mux.HandleFunc("GET "+Prefix+"/", a.dashboard)
mux.HandleFunc("GET "+Prefix+"/account", a.accountPage)
mux.HandleFunc("POST "+Prefix+"/account/password", a.changePassword)
mux.HandleFunc("POST "+Prefix+"/account/totp/setup", a.totpSetupBegin)
mux.HandleFunc("POST "+Prefix+"/account/totp/confirm", a.totpSetupConfirm)
mux.HandleFunc("POST "+Prefix+"/account/totp/disable", a.totpDisable)
mux.HandleFunc("POST "+Prefix+"/account/passkey/begin", a.passkeyRegisterBegin)
mux.HandleFunc("POST "+Prefix+"/account/passkey/finish", a.passkeyRegisterFinish)
mux.HandleFunc("POST "+Prefix+"/account/passkey/{id}/remove", a.passkeyRemove)
mux.HandleFunc("GET "+Prefix+"/first-login", a.firstLoginForm)
mux.HandleFunc("POST "+Prefix+"/first-login", a.firstLoginSubmit)
mux.HandleFunc("GET "+Prefix+"/admins", a.adminsList)
mux.HandleFunc("GET "+Prefix+"/admins/add", a.addAdminForm)
mux.HandleFunc("POST "+Prefix+"/admins/add", a.addAdmin)
mux.HandleFunc("GET "+Prefix+"/admins/{id}/edit", a.editAdminDomainsForm)
mux.HandleFunc("POST "+Prefix+"/admins/{id}/edit", a.editAdminDomains)
mux.HandleFunc("POST "+Prefix+"/admins/{id}/remove", a.removeAdmin)
mux.HandleFunc("GET "+Prefix+"/domains", a.domainsList)
mux.HandleFunc("GET "+Prefix+"/domains/add", a.addDomainForm)
mux.HandleFunc("POST "+Prefix+"/domains/add", a.addDomain)
mux.HandleFunc("POST "+Prefix+"/domains/{id}/delete", a.toggleDomainOff)
mux.HandleFunc("GET "+Prefix+"/domains/{id}/edit", a.editDomainForm)
mux.HandleFunc("POST "+Prefix+"/domains/{id}/edit", a.editDomain)
mux.HandleFunc("POST "+Prefix+"/domains/{id}/toggle", a.toggleDomain)
mux.HandleFunc("POST "+Prefix+"/domains/{id}/remove", a.removeDomain)
mux.HandleFunc("POST "+Prefix+"/domains/{id}/verify_check", a.verifyDomainCheck)
mux.HandleFunc("GET "+Prefix+"/senders", a.sendersList)
mux.HandleFunc("GET "+Prefix+"/senders/add", a.addSenderForm)
mux.HandleFunc("POST "+Prefix+"/senders/add", a.addSender)
mux.HandleFunc("POST "+Prefix+"/senders/{id}/delete", a.disableSender)
mux.HandleFunc("POST "+Prefix+"/senders/{id}/enable", a.enableSender)
mux.HandleFunc("POST "+Prefix+"/senders/{id}/remove", a.removeSender)
mux.HandleFunc("GET "+Prefix+"/senders/{id}/edit", a.editSenderForm)
mux.HandleFunc("POST "+Prefix+"/senders/{id}/edit", a.editSender)
mux.HandleFunc("GET "+Prefix+"/ips", a.ipsList)
mux.HandleFunc("GET "+Prefix+"/ips/add", a.addIPForm)
mux.HandleFunc("POST "+Prefix+"/ips/add", a.addIP)
mux.HandleFunc("POST "+Prefix+"/ips/{id}/delete", a.disableIP)
mux.HandleFunc("POST "+Prefix+"/ips/{id}/enable", a.enableIP)
mux.HandleFunc("POST "+Prefix+"/ips/{id}/remove", a.removeIP)
mux.HandleFunc("GET "+Prefix+"/ips/{id}/edit", a.editIPForm)
mux.HandleFunc("POST "+Prefix+"/ips/{id}/edit", a.editIP)
mux.HandleFunc("GET "+Prefix+"/dkim", a.dkimList)
mux.HandleFunc("POST "+Prefix+"/dkim/create", a.createDKIM)
mux.HandleFunc("POST "+Prefix+"/dkim/{id}/regenerate", a.regenerateDKIM)
mux.HandleFunc("GET "+Prefix+"/dkim/{id}/edit", a.editDKIMForm)
mux.HandleFunc("POST "+Prefix+"/dkim/{id}/edit", a.editDKIM)
mux.HandleFunc("POST "+Prefix+"/dkim/{id}/toggle", a.toggleDKIM)
mux.HandleFunc("POST "+Prefix+"/dkim/{id}/remove", a.removeDKIM)
mux.HandleFunc("POST "+Prefix+"/dkim/check_dns", a.checkDKIMDNS)
mux.HandleFunc("POST "+Prefix+"/dkim/check_spf", a.checkSPFDNS)
mux.HandleFunc("GET "+Prefix+"/logs", a.logs)
mux.HandleFunc("GET "+Prefix+"/settings", a.settingsPage)
mux.HandleFunc("POST "+Prefix+"/settings_update", a.settingsUpdate)
mux.HandleFunc("POST "+Prefix+"/api/settings/test_database", a.testDatabaseConnection)
mux.HandleFunc("POST "+Prefix+"/api/settings/upload_cert", a.uploadCert)
mux.HandleFunc("POST "+Prefix+"/api/settings/upload_key", a.uploadKey)
mux.HandleFunc("GET "+Prefix+"/api/settings/get_public_ip", a.getServerIP)
mux.HandleFunc("POST "+Prefix+"/test_attachments_path", a.testAttachmentsPath)
mux.HandleFunc("GET "+Prefix+"/msg/content/{id}", a.viewMessageContent)
mux.HandleFunc("GET "+Prefix+"/msg/attachment/{id}/download", a.downloadAttachment)
mux.HandleFunc("GET "+Prefix+"/msg/attachment/{id}/delete", a.deleteAttachment)
mux.HandleFunc("POST "+Prefix+"/msg/attachment/{id}/delete", a.deleteAttachment)
mux.HandleFunc("GET "+Prefix, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, Prefix+"/", http.StatusFound)
})
outer.Handle(Prefix+"/", a.requireAuth(mux))
return outer
}
+215
View File
@@ -0,0 +1,215 @@
package webui
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"gopkg.in/ini.v1"
"mailgoserver/internal/db"
"mailgoserver/internal/dkim"
"mailgoserver/internal/toolbox"
)
func newTestApp(t *testing.T) *App {
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() })
// Seed one of everything so every page has real data to render.
domainID, err := database.CreateDomain("example.com")
if err != nil {
t.Fatal(err)
}
hash, _ := db.HashPassword("testpass123")
senderID, err := database.CreateSender("test@example.com", hash, domainID, true, true)
if err != nil {
t.Fatal(err)
}
_, err = database.CreateWhitelistedIP("127.0.0.1", domainID, false)
if err != nil {
t.Fatal(err)
}
dkimMgr := dkim.New(database, 1024)
ok, err := dkimMgr.GenerateDKIMKeypair("example.com", "sel1", false)
if err != nil || !ok {
t.Fatalf("dkim gen: ok=%v err=%v", ok, err)
}
key, err := database.GetActiveDKIMKeyByDomainID(domainID)
if err != nil || key == nil {
t.Fatalf("get active dkim key: %v %v", key, err)
}
logID, err := database.InsertEmailLog(db.EmailLog{
MessageID: "abc123@example.com", Timestamp: time.Now(), PeerIP: "127.0.0.1",
MailFrom: "test@example.com", ToAddress: "rcpt@example.org", Subject: "hi",
EmailHeaders: "From: test@example.com\nTo: rcpt@example.org", MessageBody: "hello",
Status: "relayed", DKIMSigned: true, Username: "test@example.com",
})
if err != nil {
t.Fatal(err)
}
if err := database.InsertEmailRecipientLog(db.EmailRecipientLog{EmailLogID: logID, Recipient: "rcpt@example.org", RecipientType: "to", Status: "success"}); err != nil {
t.Fatal(err)
}
attFile := filepath.Join(dir, "att.txt")
os.WriteFile(attFile, []byte("attachment data"), 0o644)
if err := database.InsertEmailAttachment(db.EmailAttachment{EmailLogID: logID, Filename: "att.txt", ContentType: "text/plain", FilePath: attFile, Size: 15}); err != nil {
t.Fatal(err)
}
if err := database.LogAuthAttempt("sender", "test@example.com", "127.0.0.1", true, "ok"); err != nil {
t.Fatal(err)
}
cfg := ini.Empty()
serverSec, _ := cfg.NewSection("Server")
serverSec.NewKey("smtp_port", "4025")
serverSec.NewKey("smtp_tls_port", "40465")
serverSec.NewKey("bind_ip", "0.0.0.0")
serverSec.NewKey("time_zone", "UTC")
serverSec.NewKey("hostname", "mail.example.com")
serverSec.NewKey("helo_hostname", "mail.example.com")
serverSec.NewKey("server_banner", "")
dbSec, _ := cfg.NewSection("Database")
dbSec.NewKey("database_url", "sqlite:///server_data/smtp_server.db")
logSec, _ := cfg.NewSection("Logging")
logSec.NewKey("log_level", "INFO")
logSec.NewKey("hide_info_aiosmtpd", "true")
relaySec, _ := cfg.NewSection("Relay")
relaySec.NewKey("relay_timeout", "30")
tlsSec, _ := cfg.NewSection("TLS")
tlsSec.NewKey("tls_cert_file", "ssl_certs/server.crt")
tlsSec.NewKey("tls_key_file", "ssl_certs/server.key")
dkimSec, _ := cfg.NewSection("DKIM")
dkimSec.NewKey("dkim_key_size", "2048")
dkimSec.NewKey("spf_server_ip", "192.168.1.1")
attSec, _ := cfg.NewSection("Attachments")
attSec.NewKey("attachments_path", filepath.Join(dir, "attachments"))
configPath := filepath.Join(dir, "settings.ini")
cfg.SaveTo(configPath)
app, err := New(database, dkimMgr, cfg, configPath, toolbox.GetLogger("test"), func() bool { return true })
if err != nil {
t.Fatalf("New: %v", err)
}
_ = senderID
_ = key
return app
}
// loginSession creates a fully-verified admin session (no MFA enrolled) and returns
// its cookie, for tests that need to hit routes behind requireAuth.
func loginSession(t *testing.T, app *App) *http.Cookie {
t.Helper()
hash, err := db.HashPassword("test-password-123!")
if err != nil {
t.Fatal(err)
}
userID, err := app.DB.CreateAdminUser("test-admin", hash, false)
if err != nil {
t.Fatal(err)
}
token, err := app.DB.CreateSession(userID, true, time.Hour)
if err != nil {
t.Fatal(err)
}
return &http.Cookie{Name: sessionCookieName, Value: token}
}
func TestAllPagesRender(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
cookie := loginSession(t, app)
domains, _ := app.DB.ListDomains()
senders, _ := app.DB.ListSenders()
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))
}
pagesToCheck := []string{
"/",
"/account",
"/domains", "/domains/add", "/domains/" + itoa(domains[0].ID) + "/edit",
"/senders", "/senders/add", "/senders/" + itoa(senders[0].ID) + "/edit",
"/ips", "/ips/add", "/ips/" + itoa(ips[0].ID) + "/edit",
"/dkim", "/dkim/" + itoa(keys[0].ID) + "/edit",
"/logs", "/logs?type=emails", "/logs?type=auth",
"/settings",
"/msg/content/" + itoa(logs[0].ID),
"/admins", "/admins/add",
}
for _, path := range pagesToCheck {
req := httptest.NewRequest(http.MethodGet, Prefix+path, nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET %s: status %d, body: %s", path, rec.Code, rec.Body.String())
}
}
}
func TestUnauthenticatedRequestsRedirectToLogin(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
req := httptest.NewRequest(http.MethodGet, Prefix+"/domains", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected redirect to login, got status %d", rec.Code)
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, Prefix+"/login") {
t.Fatalf("expected redirect to login page, got %q", loc)
}
}
func TestFirstLoginForcedBeforeDashboard(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
hash, err := db.HashPassword("Password123!")
if err != nil {
t.Fatal(err)
}
userID, err := app.DB.CreateAdminUser("admin", hash, true) // must_change_password
if err != nil {
t.Fatal(err)
}
token, err := app.DB.CreateSession(userID, true, time.Hour)
if err != nil {
t.Fatal(err)
}
cookie := &http.Cookie{Name: sessionCookieName, Value: token}
req := httptest.NewRequest(http.MethodGet, Prefix+"/domains", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != Prefix+"/first-login" {
t.Fatalf("expected redirect to /first-login, got status %d location %q", rec.Code, rec.Header().Get("Location"))
}
}
func itoa(id int64) string {
return strconv.FormatInt(id, 10)
}