first commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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) + ")"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}} → <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}} → <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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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("&", "&", "<", "<", ">", ">")
|
||||
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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user