diff --git a/internal/db/admin.go b/internal/db/admin.go index f141b17..5696fc8 100644 --- a/internal/db/admin.go +++ b/internal/db/admin.go @@ -232,7 +232,7 @@ func (d *DB) DeleteUser(ctx context.Context, userID int64) error { func (d *DB) ListAllUsers(ctx context.Context) ([]*UserWithDomain, error) { rows, err := d.db.QueryContext(ctx, ` SELECT u.id, u.domain_id, u.username, u.email, u.display_name, - u.quota_bytes, u.used_bytes, u.enabled, u.admin, u.domain_admin, u.is_relay, + u.quota_bytes, u.used_bytes, u.enabled, u.admin, u.domain_admin, u.mfa_enabled, u.created_at, u.last_login, d.name AS domain_name FROM users u @@ -249,7 +249,7 @@ func (d *DB) ListAllUsers(ctx context.Context) ([]*UserWithDomain, error) { var lastLogin sql.NullTime err := rows.Scan( &u.ID, &u.DomainID, &u.Username, &u.Email, &u.DisplayName, - &u.QuotaBytes, &u.UsedBytes, &u.Enabled, &u.Admin, &u.DomainAdmin, &u.IsRelay, + &u.QuotaBytes, &u.UsedBytes, &u.Enabled, &u.Admin, &u.DomainAdmin, &u.MFAEnabled, &u.CreatedAt, &lastLogin, &u.DomainName, ) @@ -277,7 +277,6 @@ type UserWithDomain struct { Enabled bool Admin bool DomainAdmin bool - IsRelay bool MFAEnabled bool CreatedAt time.Time LastLogin time.Time diff --git a/internal/db/migrate.go b/internal/db/migrate.go index 9a8ba83..f506dbf 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -18,6 +18,8 @@ var migrations = []migration{ {1, schemav1}, {2, schemav2}, {3, schemav3}, + {4, schemav4}, + {5, schemav5}, } // migrate applies any unapplied migrations in order. @@ -338,18 +340,79 @@ CREATE TABLE IF NOT EXISTS spam_tokens ( CREATE INDEX IF NOT EXISTS idx_spam_tokens_user ON spam_tokens(user_id, token); ` -// ---- Schema v3: Relay accounts ---- +// ---- Schema v5: Per-relay-account IP whitelist ---- -const schemav3 = ` -ALTER TABLE users ADD COLUMN is_relay BOOLEAN NOT NULL DEFAULT 0; +const schemav5 = ` +CREATE TABLE IF NOT EXISTS relay_account_ips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + relay_account_id INTEGER NOT NULL REFERENCES relay_accounts(id) ON DELETE CASCADE, + cidr TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_relay_account_ips_account ON relay_account_ips(relay_account_id); +` + +// ---- Schema v4: Ensure relay tables exist (v3 may have been applied with old content) ---- + +const schemav4 = ` +CREATE TABLE IF NOT EXISTS relay_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT 1, + description TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_relay_accounts_username ON relay_accounts(username); +CREATE INDEX IF NOT EXISTS idx_relay_accounts_domain ON relay_accounts(domain_id); CREATE TABLE IF NOT EXISTS relay_send_as ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - pattern TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + id INTEGER PRIMARY KEY AUTOINCREMENT, + relay_account_id INTEGER NOT NULL REFERENCES relay_accounts(id) ON DELETE CASCADE, + pattern TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -CREATE INDEX IF NOT EXISTS idx_relay_send_as_user ON relay_send_as(user_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_relay_send_as_unique ON relay_send_as(relay_account_id, pattern); +CREATE INDEX IF NOT EXISTS idx_relay_send_as_account ON relay_send_as(relay_account_id); + +CREATE TABLE IF NOT EXISTS relay_ip_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + cidr TEXT NOT NULL, + sender_pattern TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_relay_ip_rules_domain ON relay_ip_rules(domain_id); +` + +// ---- Schema v3: Relay accounts (domain-level, independent of user accounts) ---- + +const schemav3 = ` +CREATE TABLE IF NOT EXISTS relay_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT 1, + description TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_relay_accounts_username ON relay_accounts(username); +CREATE INDEX IF NOT EXISTS idx_relay_accounts_domain ON relay_accounts(domain_id); + +CREATE TABLE IF NOT EXISTS relay_send_as ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + relay_account_id INTEGER NOT NULL REFERENCES relay_accounts(id) ON DELETE CASCADE, + pattern TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_relay_send_as_unique ON relay_send_as(relay_account_id, pattern); +CREATE INDEX IF NOT EXISTS idx_relay_send_as_account ON relay_send_as(relay_account_id); CREATE TABLE IF NOT EXISTS relay_ip_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/internal/db/relay.go b/internal/db/relay.go index 8d76f97..c39033b 100644 --- a/internal/db/relay.go +++ b/internal/db/relay.go @@ -1,19 +1,125 @@ +// Package db — relay account and IP relay rule operations. package db import ( "context" + "database/sql" + "fmt" "net" "strings" + "time" "ghb.freebede.com/nahakubuilder/mailgosend/internal/models" ) -// ---- Relay send-as (per user) ---- +// ---- Relay accounts ---- -// GetRelaySendAs returns all allowed sender patterns for a relay user. -func (d *DB) GetRelaySendAs(ctx context.Context, userID int64) ([]*models.RelaySendAs, error) { +// GetRelayAccountByUsername returns the relay account with the given username (case-insensitive), or nil. +func (d *DB) GetRelayAccountByUsername(ctx context.Context, username string) (*models.RelayAccount, error) { + row := d.db.QueryRowContext(ctx, ` + SELECT id, domain_id, username, password_hash, display_name, enabled, description, created_at + FROM relay_accounts WHERE lower(username)=lower(?)`, username) + return scanRelayAccount(row) +} + +// GetRelayAccountByID returns the relay account with the given ID, or nil. +func (d *DB) GetRelayAccountByID(ctx context.Context, id int64) (*models.RelayAccount, error) { + row := d.db.QueryRowContext(ctx, ` + SELECT id, domain_id, username, password_hash, display_name, enabled, description, created_at + FROM relay_accounts WHERE id=?`, id) + return scanRelayAccount(row) +} + +// RelayAccountWithDomain pairs a RelayAccount with its domain name for cross-domain listings. +type RelayAccountWithDomain struct { + *models.RelayAccount + DomainName string +} + +// ListAllRelayAccounts returns every relay account across all domains, joined with domain name. +func (d *DB) ListAllRelayAccounts(ctx context.Context) ([]*RelayAccountWithDomain, error) { + rows, err := d.db.QueryContext(ctx, ` + SELECT r.id, r.domain_id, r.username, r.password_hash, r.display_name, + r.enabled, r.description, r.created_at, d.name + FROM relay_accounts r + JOIN domains d ON d.id = r.domain_id + ORDER BY d.name, r.username`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []*RelayAccountWithDomain + for rows.Next() { + a := &models.RelayAccount{} + var domainName string + if err := rows.Scan(&a.ID, &a.DomainID, &a.Username, &a.PasswordHash, + &a.DisplayName, &a.Enabled, &a.Description, &a.CreatedAt, &domainName); err != nil { + return nil, fmt.Errorf("scan relay account with domain: %w", err) + } + out = append(out, &RelayAccountWithDomain{RelayAccount: a, DomainName: domainName}) + } + return out, rows.Err() +} + +// ListRelayAccounts returns all relay accounts for a domain, ordered by username. +func (d *DB) ListRelayAccounts(ctx context.Context, domainID int64) ([]*models.RelayAccount, error) { + rows, err := d.db.QueryContext(ctx, ` + SELECT id, domain_id, username, password_hash, display_name, enabled, description, created_at + FROM relay_accounts WHERE domain_id=? ORDER BY username`, domainID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []*models.RelayAccount + for rows.Next() { + a, err := scanRelayAccountRow(rows) + if err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// CreateRelayAccount inserts a new relay account. Returns the new ID. +func (d *DB) CreateRelayAccount(ctx context.Context, domainID int64, username, passwordHash, displayName, description string) (int64, error) { + res, err := d.db.ExecContext(ctx, ` + INSERT INTO relay_accounts (domain_id, username, password_hash, display_name, description, created_at) + VALUES (?,?,?,?,?,?)`, + domainID, username, passwordHash, displayName, description, time.Now().UTC()) + if err != nil { + return 0, fmt.Errorf("create relay account: %w", err) + } + return res.LastInsertId() +} + +// SetRelayAccountEnabled enables or disables a relay account. +func (d *DB) SetRelayAccountEnabled(ctx context.Context, id int64, enabled bool) error { + _, err := d.db.ExecContext(ctx, "UPDATE relay_accounts SET enabled=? WHERE id=?", enabled, id) + return err +} + +// SetRelayAccountPassword updates the password hash for a relay account. +func (d *DB) SetRelayAccountPassword(ctx context.Context, id int64, hash string) error { + _, err := d.db.ExecContext(ctx, "UPDATE relay_accounts SET password_hash=? WHERE id=?", hash, id) + return err +} + +// DeleteRelayAccount permanently removes a relay account and its send-as patterns. +func (d *DB) DeleteRelayAccount(ctx context.Context, id int64) error { + _, err := d.db.ExecContext(ctx, "DELETE FROM relay_accounts WHERE id=?", id) + return err +} + +// ---- Relay send-as (per relay account) ---- + +// GetRelaySendAs returns all allowed sender patterns for a relay account. +func (d *DB) GetRelaySendAs(ctx context.Context, relayAccountID int64) ([]*models.RelaySendAs, error) { rows, err := d.db.QueryContext(ctx, - "SELECT id, user_id, pattern, created_at FROM relay_send_as WHERE user_id=? ORDER BY id", userID) + "SELECT id, relay_account_id, pattern, created_at FROM relay_send_as WHERE relay_account_id=? ORDER BY id", + relayAccountID) if err != nil { return nil, err } @@ -22,7 +128,7 @@ func (d *DB) GetRelaySendAs(ctx context.Context, userID int64) ([]*models.RelayS var out []*models.RelaySendAs for rows.Next() { r := &models.RelaySendAs{} - if err := rows.Scan(&r.ID, &r.UserID, &r.Pattern, &r.CreatedAt); err != nil { + if err := rows.Scan(&r.ID, &r.RelayAccountID, &r.Pattern, &r.CreatedAt); err != nil { return nil, err } out = append(out, r) @@ -30,33 +136,26 @@ func (d *DB) GetRelaySendAs(ctx context.Context, userID int64) ([]*models.RelayS return out, rows.Err() } -// AddRelaySendAs adds a sender pattern for a relay user. Duplicate patterns are ignored. -func (d *DB) AddRelaySendAs(ctx context.Context, userID int64, pattern string) error { +// AddRelaySendAs adds a sender pattern for a relay account. Duplicate patterns are ignored. +func (d *DB) AddRelaySendAs(ctx context.Context, relayAccountID int64, pattern string) error { _, err := d.db.ExecContext(ctx, - "INSERT OR IGNORE INTO relay_send_as (user_id, pattern) VALUES (?,?)", userID, pattern) + "INSERT OR IGNORE INTO relay_send_as (relay_account_id, pattern) VALUES (?,?)", + relayAccountID, pattern) return err } -// DeleteRelaySendAs removes a sender pattern. userID is verified to prevent cross-user deletion. -func (d *DB) DeleteRelaySendAs(ctx context.Context, id, userID int64) error { +// DeleteRelaySendAs removes a sender pattern. relayAccountID is verified. +func (d *DB) DeleteRelaySendAs(ctx context.Context, id, relayAccountID int64) error { _, err := d.db.ExecContext(ctx, - "DELETE FROM relay_send_as WHERE id=? AND user_id=?", id, userID) + "DELETE FROM relay_send_as WHERE id=? AND relay_account_id=?", id, relayAccountID) return err } -// IsRelaySenderAllowed returns true when the relay user is permitted to send as fromEmail. -// Own email is always allowed. Otherwise checks relay_send_as patterns. -func (d *DB) IsRelaySenderAllowed(ctx context.Context, userID int64, fromEmail string) (bool, error) { - user, err := d.GetUserByID(ctx, userID) - if err != nil { - return false, err - } - if user != nil && strings.EqualFold(user.Email, fromEmail) { - return true, nil - } - +// IsRelaySenderAllowed returns true when the relay account is permitted to send as fromEmail. +// Any pattern in relay_send_as matching the address is sufficient. +func (d *DB) IsRelaySenderAllowed(ctx context.Context, relayAccountID int64, fromEmail string) (bool, error) { rows, err := d.db.QueryContext(ctx, - "SELECT pattern FROM relay_send_as WHERE user_id=?", userID) + "SELECT pattern FROM relay_send_as WHERE relay_account_id=?", relayAccountID) if err != nil { return false, err } @@ -112,7 +211,7 @@ func (d *DB) DeleteRelayIPRule(ctx context.Context, id, domainID int64) error { return err } -// CheckIPRelay returns true when the client IP is authorized to send as senderEmail +// CheckIPRelay returns true when clientIP is authorized to send as senderEmail // via any active IP relay rule across all enabled domains. func (d *DB) CheckIPRelay(ctx context.Context, clientIP, senderEmail string) (bool, error) { rows, err := d.db.QueryContext(ctx, ` @@ -137,8 +236,137 @@ func (d *DB) CheckIPRelay(ctx context.Context, clientIP, senderEmail string) (bo return false, rows.Err() } +// ---- Relay account IP whitelist ---- + +// GetRelayAccountIPs returns all IP whitelist entries for a relay account. +func (d *DB) GetRelayAccountIPs(ctx context.Context, relayAccountID int64) ([]*models.RelayAccountIP, error) { + rows, err := d.db.QueryContext(ctx, + "SELECT id, relay_account_id, cidr, description, created_at FROM relay_account_ips WHERE relay_account_id=? ORDER BY id", + relayAccountID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []*models.RelayAccountIP + for rows.Next() { + r := &models.RelayAccountIP{} + if err := rows.Scan(&r.ID, &r.RelayAccountID, &r.CIDR, &r.Description, &r.CreatedAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// AddRelayAccountIP adds an IP/CIDR whitelist entry for a relay account. +func (d *DB) AddRelayAccountIP(ctx context.Context, relayAccountID int64, cidr, description string) error { + _, err := d.db.ExecContext(ctx, + "INSERT INTO relay_account_ips (relay_account_id, cidr, description) VALUES (?,?,?)", + relayAccountID, cidr, description) + return err +} + +// DeleteRelayAccountIP removes an IP whitelist entry. relayAccountID is verified. +func (d *DB) DeleteRelayAccountIP(ctx context.Context, id, relayAccountID int64) error { + _, err := d.db.ExecContext(ctx, + "DELETE FROM relay_account_ips WHERE id=? AND relay_account_id=?", id, relayAccountID) + return err +} + +// FindRelayAccountByIP returns the first enabled relay account whose IP whitelist +// contains clientIP and whose send-as patterns allow senderEmail. +// Returns nil (no error) if no match. +func (d *DB) FindRelayAccountByIP(ctx context.Context, clientIP, senderEmail string) (*models.RelayAccount, error) { + // Load all enabled relay accounts that have at least one IP whitelist entry. + rows, err := d.db.QueryContext(ctx, ` + SELECT DISTINCT r.id, r.domain_id, r.username, r.password_hash, r.display_name, + r.enabled, r.description, r.created_at + FROM relay_accounts r + JOIN relay_account_ips ip ON ip.relay_account_id = r.id + WHERE r.enabled = 1`) + if err != nil { + return nil, err + } + defer rows.Close() + + type candidate struct { + acc *models.RelayAccount + } + var candidates []candidate + for rows.Next() { + a := &models.RelayAccount{} + if err := rows.Scan(&a.ID, &a.DomainID, &a.Username, &a.PasswordHash, + &a.DisplayName, &a.Enabled, &a.Description, &a.CreatedAt); err != nil { + return nil, fmt.Errorf("scan relay account: %w", err) + } + candidates = append(candidates, candidate{acc: a}) + } + if err := rows.Close(); err != nil { + return nil, err + } + + for _, c := range candidates { + // Check IP whitelist. + ipRows, err := d.db.QueryContext(ctx, + "SELECT cidr FROM relay_account_ips WHERE relay_account_id=?", c.acc.ID) + if err != nil { + return nil, err + } + ipMatch := false + for ipRows.Next() { + var cidr string + if err := ipRows.Scan(&cidr); err != nil { + ipRows.Close() + return nil, err + } + if ipInCIDR(cidr, clientIP) { + ipMatch = true + break + } + } + ipRows.Close() + if !ipMatch { + continue + } + + // IP matched — check send-as patterns. + allowed, err := d.IsRelaySenderAllowed(ctx, c.acc.ID, senderEmail) + if err != nil { + return nil, err + } + if allowed { + return c.acc, nil + } + } + return nil, nil +} + // ---- Helpers ---- +func scanRelayAccount(row *sql.Row) (*models.RelayAccount, error) { + a := &models.RelayAccount{} + err := row.Scan(&a.ID, &a.DomainID, &a.Username, &a.PasswordHash, + &a.DisplayName, &a.Enabled, &a.Description, &a.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scan relay account: %w", err) + } + return a, nil +} + +func scanRelayAccountRow(rows *sql.Rows) (*models.RelayAccount, error) { + a := &models.RelayAccount{} + err := rows.Scan(&a.ID, &a.DomainID, &a.Username, &a.PasswordHash, + &a.DisplayName, &a.Enabled, &a.Description, &a.CreatedAt) + if err != nil { + return nil, fmt.Errorf("scan relay account row: %w", err) + } + return a, nil +} + // matchSenderPattern checks if email matches pattern. // "*@domain.com" matches any address at that domain. // Anything else is an exact case-insensitive match. @@ -157,14 +385,13 @@ func matchSenderPattern(pattern, email string) bool { } // ipInCIDR returns true when ip falls within the cidr range. -// cidr may be a plain IP (treated as single-host) or CIDR notation. +// cidr may be a plain IP or CIDR notation. func ipInCIDR(cidr, ip string) bool { parsedIP := net.ParseIP(ip) if parsedIP == nil { return false } if !strings.Contains(cidr, "/") { - // Plain IP — exact match. target := net.ParseIP(cidr) return target != nil && target.Equal(parsedIP) } diff --git a/internal/db/users.go b/internal/db/users.go index 5f3eb42..95b826b 100644 --- a/internal/db/users.go +++ b/internal/db/users.go @@ -13,7 +13,7 @@ import ( func (d *DB) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { row := d.db.QueryRowContext(ctx, ` SELECT id, domain_id, username, email, password_hash, display_name, - quota_bytes, used_bytes, enabled, admin, domain_admin, is_relay, + quota_bytes, used_bytes, enabled, admin, domain_admin, mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login FROM users WHERE lower(email)=lower(?)`, email) return scanUser(row) @@ -23,7 +23,7 @@ func (d *DB) GetUserByEmail(ctx context.Context, email string) (*models.User, er func (d *DB) GetUserByID(ctx context.Context, id int64) (*models.User, error) { row := d.db.QueryRowContext(ctx, ` SELECT id, domain_id, username, email, password_hash, display_name, - quota_bytes, used_bytes, enabled, admin, domain_admin, is_relay, + quota_bytes, used_bytes, enabled, admin, domain_admin, mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login FROM users WHERE id=?`, id) return scanUser(row) @@ -119,7 +119,7 @@ func (d *DB) UpdateLastLogin(ctx context.Context, userID int64) { func (d *DB) ListUsers(ctx context.Context, domainID int64) ([]*models.User, error) { rows, err := d.db.QueryContext(ctx, ` SELECT id, domain_id, username, email, password_hash, display_name, - quota_bytes, used_bytes, enabled, admin, domain_admin, is_relay, + quota_bytes, used_bytes, enabled, admin, domain_admin, mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login FROM users WHERE domain_id=? ORDER BY email`, domainID) if err != nil { @@ -135,7 +135,7 @@ func (d *DB) ListUsers(ctx context.Context, domainID int64) ([]*models.User, err err := rows.Scan( &u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash, &u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled, - &u.Admin, &u.DomainAdmin, &u.IsRelay, + &u.Admin, &u.DomainAdmin, &mfaEnc, &u.MFAEnabled, &rcEnc, &u.CreatedAt, &lastLogin, ) @@ -152,9 +152,49 @@ func (d *DB) ListUsers(ctx context.Context, domainID int64) ([]*models.User, err return users, rows.Err() } -// SetUserIsRelay sets the is_relay flag for a user. -func (d *DB) SetUserIsRelay(ctx context.Context, userID int64, isRelay bool) error { - _, err := d.db.ExecContext(ctx, "UPDATE users SET is_relay=? WHERE id=?", isRelay, userID) +// ---- User aliases ---- + +// GetUserAliases returns all email aliases for a user. +func (d *DB) GetUserAliases(ctx context.Context, userID int64) ([]*models.UserAlias, error) { + rows, err := d.db.QueryContext(ctx, + "SELECT id, user_id, alias_email FROM user_aliases WHERE user_id=? ORDER BY alias_email", + userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []*models.UserAlias + for rows.Next() { + a := &models.UserAlias{} + if err := rows.Scan(&a.ID, &a.UserID, &a.AliasEmail); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// AddUserAlias creates a new email alias for a user. +// Returns an error if aliasEmail is already used by any user or alias. +func (d *DB) AddUserAlias(ctx context.Context, userID int64, aliasEmail string) error { + exists, err := d.UserExistsByEmail(ctx, aliasEmail) + if err != nil { + return fmt.Errorf("add alias check: %w", err) + } + if exists { + return fmt.Errorf("alias already in use") + } + _, err = d.db.ExecContext(ctx, + "INSERT INTO user_aliases (user_id, alias_email) VALUES (?,?)", + userID, aliasEmail) + return err +} + +// DeleteUserAlias removes an alias. userID is verified to prevent cross-user deletion. +func (d *DB) DeleteUserAlias(ctx context.Context, aliasID, userID int64) error { + _, err := d.db.ExecContext(ctx, + "DELETE FROM user_aliases WHERE id=? AND user_id=?", aliasID, userID) return err } @@ -168,7 +208,7 @@ func scanUser(row *sql.Row) (*models.User, error) { err := row.Scan( &u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash, &u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled, - &u.Admin, &u.DomainAdmin, &u.IsRelay, + &u.Admin, &u.DomainAdmin, &mfaEnc, &u.MFAEnabled, &rcEnc, &u.CreatedAt, &lastLogin, ) diff --git a/internal/models/models.go b/internal/models/models.go index 3f1fc80..a36d3be 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -69,7 +69,6 @@ type User struct { Enabled bool Admin bool // global admin DomainAdmin bool // admin of own domain only - IsRelay bool // relay account: SMTP auth only, no IMAP mailbox MFASecretEnc []byte // encrypted TOTP secret; nil = MFA disabled MFAEnabled bool RecoveryCodesEnc []byte // encrypted JSON array of one-time codes @@ -312,13 +311,26 @@ type SpamToken struct { // ---- Relay ---- -// RelaySendAs is a permitted sender pattern for a relay user account. +// RelayAccount is a domain-level SMTP-only account used for relay purposes. +// It has no mailbox or IMAP access. +type RelayAccount struct { + ID int64 + DomainID int64 + Username string // may be email or arbitrary string up to 240 chars + PasswordHash string + DisplayName string + Enabled bool + Description string + CreatedAt time.Time +} + +// RelaySendAs is a permitted sender pattern for a relay account. // Pattern may be an exact address or a wildcard (*@domain.com). type RelaySendAs struct { - ID int64 - UserID int64 - Pattern string - CreatedAt time.Time + ID int64 + RelayAccountID int64 + Pattern string + CreatedAt time.Time } // RelayIPRule allows unauthenticated SMTP relay from a specific IP/CIDR @@ -332,6 +344,17 @@ type RelayIPRule struct { CreatedAt time.Time } +// RelayAccountIP whitelists an IP/CIDR for a relay account. +// Connections from the IP may send as any address permitted by the account's +// send-as patterns without SMTP AUTH. +type RelayAccountIP struct { + ID int64 + RelayAccountID int64 + CIDR string + Description string + CreatedAt time.Time +} + // ---- Compose helpers (not persisted directly) ---- type Attachment_Upload struct { diff --git a/internal/smtp/submission.go b/internal/smtp/submission.go index 5806e02..6cac7fd 100644 --- a/internal/smtp/submission.go +++ b/internal/smtp/submission.go @@ -20,7 +20,7 @@ import ( ) // SubmissionBackend implements gosmtp.Backend for ports 587/465. -// Requires authenticated users. Signs outbound mail with DKIM. Queues for delivery. +// Supports: regular users, relay accounts (SMTP-only), and unauthenticated IP relay. type SubmissionBackend struct { deps *Deps } @@ -45,39 +45,57 @@ func (b *SubmissionBackend) NewSession(c *gosmtp.Conn) (gosmtp.Session, error) { }, nil } -// SubmissionSession handles one authenticated submission connection. +// SubmissionSession handles one authenticated or IP-relay submission connection. type SubmissionSession struct { - deps *Deps - clientIP string - user *models.User // set after AUTH - ipRelayMode bool // set when IP relay authorization succeeds (no AUTH) - from string - rcpts []string + deps *Deps + clientIP string + user *models.User // set after AUTH as regular user + relayAccount *models.RelayAccount // set after AUTH as relay account + ipRelayMode bool // set when IP relay authorization succeeds (no AUTH) + from string + rcpts []string } func (s *SubmissionSession) AuthPlain(username, password string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() + // Try regular user first. user, err := s.deps.DB.GetUserByEmail(ctx, username) if err != nil { log.Printf("[smtp/submission] auth lookup error %s: %v", username, err) return &gosmtp.SMTPError{Code: 535, Message: "authentication failed"} } - if user == nil || !user.Enabled { - s.logAttempt(ctx, username, false) - return &gosmtp.SMTPError{Code: 535, Message: "authentication failed"} + if user != nil && user.Enabled { + if err := crypto.CheckPassword(user.PasswordHash, password); err != nil { + s.logAttempt(ctx, username, false) + log.Printf("[smtp/submission] auth failed for user %s from %s", username, s.clientIP) + return &gosmtp.SMTPError{Code: 535, Message: "authentication failed"} + } + s.user = user + s.deps.DB.UpdateLastLogin(ctx, user.ID) + log.Printf("[smtp/submission] auth OK for user %s from %s", username, s.clientIP) + return nil } - if err := crypto.CheckPassword(user.PasswordHash, password); err != nil { - s.logAttempt(ctx, username, false) - log.Printf("[smtp/submission] auth failed for %s from %s", username, s.clientIP) + // Try relay account. + relayAcc, err := s.deps.DB.GetRelayAccountByUsername(ctx, username) + if err != nil { + log.Printf("[smtp/submission] relay auth lookup error %s: %v", username, err) return &gosmtp.SMTPError{Code: 535, Message: "authentication failed"} } - - s.user = user - s.deps.DB.UpdateLastLogin(ctx, user.ID) - log.Printf("[smtp/submission] auth OK for %s from %s", username, s.clientIP) + if relayAcc == nil || !relayAcc.Enabled { + s.logAttempt(ctx, username, false) + log.Printf("[smtp/submission] auth failed for relay %s from %s", username, s.clientIP) + return &gosmtp.SMTPError{Code: 535, Message: "authentication failed"} + } + if err := crypto.CheckPassword(relayAcc.PasswordHash, password); err != nil { + s.logAttempt(ctx, username, false) + log.Printf("[smtp/submission] auth failed for relay %s from %s", username, s.clientIP) + return &gosmtp.SMTPError{Code: 535, Message: "authentication failed"} + } + s.relayAccount = relayAcc + log.Printf("[smtp/submission] relay auth OK for %s from %s", username, s.clientIP) return nil } @@ -88,11 +106,24 @@ func (s *SubmissionSession) Mail(from string, opts *gosmtp.MailOptions) error { } fromEmail := strings.ToLower(addr.Address) - if s.user == nil { - // Unauthenticated — check IP relay rules. + if s.user == nil && s.relayAccount == nil { + // Unauthenticated — check relay account IP whitelist first, then domain-level IP rules. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + acc, err := s.deps.DB.FindRelayAccountByIP(ctx, s.clientIP, fromEmail) + if err != nil { + log.Printf("[smtp/submission] relay account ip check from %s: %v", s.clientIP, err) + return &gosmtp.SMTPError{Code: 451, EnhancedCode: gosmtp.EnhancedCode{4, 3, 0}, Message: "temporary error"} + } + if acc != nil { + // Treat as authenticated relay account — no password required. + s.relayAccount = acc + s.from = addr.Address + log.Printf("[smtp/submission] ip-relay (account %s) from %s as %s", acc.Username, s.clientIP, fromEmail) + return nil + } + allowed, err := s.deps.DB.CheckIPRelay(ctx, s.clientIP, fromEmail) if err != nil { log.Printf("[smtp/submission] ip relay check error from %s: %v", s.clientIP, err) @@ -106,24 +137,24 @@ func (s *SubmissionSession) Mail(from string, opts *gosmtp.MailOptions) error { return nil } - if s.user.IsRelay { - // Relay account — validate sender against allowed patterns. + if s.relayAccount != nil { + // Relay account — validate sender against allowed send-as patterns. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - allowed, err := s.deps.DB.IsRelaySenderAllowed(ctx, s.user.ID, fromEmail) + allowed, err := s.deps.DB.IsRelaySenderAllowed(ctx, s.relayAccount.ID, fromEmail) if err != nil { - log.Printf("[smtp/submission] relay sender check error for %s: %v", s.user.Email, err) + log.Printf("[smtp/submission] relay sender check error for account %d: %v", s.relayAccount.ID, err) return &gosmtp.SMTPError{Code: 451, EnhancedCode: gosmtp.EnhancedCode{4, 3, 0}, Message: "temporary error"} } if !allowed { - return &gosmtp.SMTPError{Code: 553, EnhancedCode: gosmtp.EnhancedCode{5, 1, 8}, Message: "sender not permitted for this relay account"} + return &gosmtp.SMTPError{Code: 553, EnhancedCode: gosmtp.EnhancedCode{5, 1, 8}, Message: "sender address not permitted for this relay account"} } s.from = addr.Address return nil } - // Regular user — sender must be own email or an alias. + // Regular user — sender must be own email or an alias they own. if !strings.EqualFold(fromEmail, s.user.Email) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -139,7 +170,7 @@ func (s *SubmissionSession) Mail(from string, opts *gosmtp.MailOptions) error { } func (s *SubmissionSession) Rcpt(to string, opts *gosmtp.RcptOptions) error { - if s.user == nil && !s.ipRelayMode { + if s.user == nil && s.relayAccount == nil && !s.ipRelayMode { return &gosmtp.SMTPError{Code: 530, Message: "authentication required"} } @@ -153,7 +184,7 @@ func (s *SubmissionSession) Rcpt(to string, opts *gosmtp.RcptOptions) error { } func (s *SubmissionSession) Data(r io.Reader) error { - if s.user == nil && !s.ipRelayMode { + if s.user == nil && s.relayAccount == nil && !s.ipRelayMode { return &gosmtp.SMTPError{Code: 530, Message: "authentication required"} } if len(s.rcpts) == 0 { @@ -178,7 +209,6 @@ func (s *SubmissionSession) Data(r io.Reader) error { senderDomain := domainOf(s.from) raw = s.signDKIM(ctx, raw, senderDomain) - msgID := extractMsgID(raw) dom, err := s.deps.DB.GetDomain(ctx, senderDomain) @@ -205,8 +235,8 @@ func (s *SubmissionSession) Data(r io.Reader) error { log.Printf("[smtp/submission] queued %s → %s", s.from, rcpt) } - // Save a Sent copy only for regular (non-relay) authenticated users. - if s.user != nil && !s.user.IsRelay { + // Save Sent copy only for regular users (relay accounts have no mailboxes). + if s.user != nil { s.saveSentCopy(ctx, raw) } @@ -222,11 +252,10 @@ func (s *SubmissionSession) Reset() { func (s *SubmissionSession) Logout() error { return nil } // signDKIM signs the message with the sender domain's DKIM key if available. -// Returns the original raw on any error (DKIM is best-effort). func (s *SubmissionSession) signDKIM(ctx context.Context, raw []byte, senderDomain string) []byte { dom, err := s.deps.DB.GetDomain(ctx, senderDomain) if err != nil || dom == nil || dom.DKIMPrivateEnc == nil { - return raw // no key configured + return raw } privPEM, err := s.deps.Crypt.DecryptGlobal("dkim", dom.DKIMPrivateEnc) @@ -247,7 +276,6 @@ func (s *SubmissionSession) signDKIM(ctx context.Context, raw []byte, senderDoma return raw } - // Prepend DKIM-Signature header. return append([]byte(header+"\r\n"), raw...) } diff --git a/internal/webadmin/handlers.go b/internal/webadmin/handlers.go index 9fadd0a..122ef10 100644 --- a/internal/webadmin/handlers.go +++ b/internal/webadmin/handlers.go @@ -108,20 +108,20 @@ func (s *Server) domainsCreate(w http.ResponseWriter, r *http.Request) { type domainDetailData struct { basePage - Domain *models.Domain - Users []*models.User - Hostname string - DMARCReportCount int - RelayIPRules []*models.RelayIPRule - // DNS records (what to configure) + Domain *models.Domain + Users []*models.User + Hostname string + DMARCReportCount int MXRecord string - DKIMRecord string SPFHint string + DKIMRecord string DMARCHint string AutoconfigCNAME string AutodiscoverCNAME string SMTPSRVRecord string IMAPSRVRecord string + RelayAccounts []*models.RelayAccount + RelayIPRules []*models.RelayIPRule } func (s *Server) domainDetail(w http.ResponseWriter, r *http.Request) { @@ -162,6 +162,7 @@ func (s *Server) domainDetail(w http.ResponseWriter, r *http.Request) { } dmarcCount, _ := s.deps.DB.DMARCReportCount(ctx, id) + relayAccounts, _ := s.deps.DB.ListRelayAccounts(ctx, id) relayRules, _ := s.deps.DB.GetRelayIPRules(ctx, id) s.render(w, "domain", domainDetailData{ @@ -170,15 +171,16 @@ func (s *Server) domainDetail(w http.ResponseWriter, r *http.Request) { Users: users, Hostname: hostname, DMARCReportCount: dmarcCount, - RelayIPRules: relayRules, MXRecord: fmt.Sprintf(`%s IN MX 10 %s.`, dom.Name, hostname), - DKIMRecord: dkimRec, SPFHint: fmt.Sprintf(`%s IN TXT "v=spf1 a mx ~all"`, dom.Name), + DKIMRecord: dkimRec, DMARCHint: dmarcHint, AutoconfigCNAME: fmt.Sprintf(`autoconfig.%s IN CNAME %s.`, dom.Name, hostname), AutodiscoverCNAME: fmt.Sprintf(`autodiscover.%s IN CNAME %s.`, dom.Name, hostname), SMTPSRVRecord: fmt.Sprintf(`_submission._tcp.%s IN SRV 0 1 587 %s.`, dom.Name, hostname), IMAPSRVRecord: fmt.Sprintf(`_imaps._tcp.%s IN SRV 0 1 993 %s.`, dom.Name, hostname), + RelayAccounts: relayAccounts, + RelayIPRules: relayRules, }) } @@ -407,8 +409,6 @@ func (s *Server) usersCreate(w http.ResponseWriter, r *http.Request) { quotaMB, _ := strconv.ParseInt(r.FormValue("quota_mb"), 10, 64) domainAdmin := r.FormValue("domain_admin") == "1" - isRelay := r.FormValue("relay") == "1" - if domainID <= 0 || !validUsername(username) || len(password) < 8 || len(password) > 1024 { redirect(w, r, "/admin/users", "", "Invalid input. Username must be alphanumeric, password min 8 chars.") return @@ -443,15 +443,6 @@ func (s *Server) usersCreate(w http.ResponseWriter, r *http.Request) { return } - if isRelay { - if err := s.deps.DB.SetUserIsRelay(ctx, userID, true); err != nil { - log.Printf("[admin] set relay flag: %v", err) - } - // Relay users skip mailbox/calendar/address book creation. - redirect(w, r, fmt.Sprintf("/admin/users/%d", userID), "Relay user created.", "") - return - } - // Create default mailboxes, calendar, address book. if err := createDefaultMailboxes(ctx, s.deps.DB, userID); err != nil { log.Printf("[admin] default mailboxes: %v", err) @@ -468,8 +459,8 @@ func (s *Server) usersCreate(w http.ResponseWriter, r *http.Request) { type userDetailData struct { basePage - U *db.UserWithDomain - SendAs []*models.RelaySendAs + U *db.UserWithDomain + Aliases []*models.UserAlias } func (s *Server) userDetail(w http.ResponseWriter, r *http.Request) { @@ -495,19 +486,60 @@ func (s *Server) userDetail(w http.ResponseWriter, r *http.Request) { return } - var sendAs []*models.RelaySendAs - if found.IsRelay { - sendAs, _ = s.deps.DB.GetRelaySendAs(ctx, found.ID) + aliases, err := s.deps.DB.GetUserAliases(ctx, id) + if err != nil { + log.Printf("[admin] get aliases user %d: %v", id, err) } flash, errMsg := flashFrom(r) s.render(w, "user", userDetailData{ basePage: s.newBase(r, flash, errMsg), U: found, - SendAs: sendAs, + Aliases: aliases, }) } +func (s *Server) userAliasAdd(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + if !s.validateCSRF(w, r) { + return + } + + id := pathID(r, "id") + aliasEmail := strings.ToLower(strings.TrimSpace(r.FormValue("alias_email"))) + if !validEmail(aliasEmail) { + redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Invalid alias email address.") + return + } + + if err := s.deps.DB.AddUserAlias(ctx, id, aliasEmail); err != nil { + log.Printf("[admin] add alias user %d: %v", id, err) + redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Alias already in use or error adding.") + return + } + redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "Alias added.", "") +} + +func (s *Server) userAliasDelete(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + if !s.validateCSRF(w, r) { + return + } + + id := pathID(r, "id") + aid := pathID(r, "aid") + if err := s.deps.DB.DeleteUserAlias(ctx, aid, id); err != nil { + log.Printf("[admin] delete alias %d user %d: %v", aid, id, err) + redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Failed to remove alias.") + return + } + redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "Alias removed.", "") +} + func (s *Server) userUpdate(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() @@ -870,32 +902,107 @@ func generateToken(n int) (string, error) { return fmt.Sprintf("%x", buf), nil } -// ---- Relay user management ---- +// ---- Relay overview ---- -func (s *Server) userRelayToggle(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) - defer cancel() - - if !s.validateCSRF(w, r) { - return - } - - id := pathID(r, "id") - enable := r.FormValue("relay") == "1" - - if err := s.deps.DB.SetUserIsRelay(ctx, id, enable); err != nil { - log.Printf("[admin] user relay toggle: %v", err) - redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Failed to update relay mode.") - return - } - msg := "Relay mode enabled." - if !enable { - msg = "Relay mode disabled." - } - redirect(w, r, fmt.Sprintf("/admin/users/%d", id), msg, "") +type relayListData struct { + basePage + Accounts []*db.RelayAccountWithDomain } -func (s *Server) userRelayAddSendAs(w http.ResponseWriter, r *http.Request) { +func (s *Server) relayList(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + accounts, err := s.deps.DB.ListAllRelayAccounts(ctx) + if err != nil { + log.Printf("[admin] list relay accounts: %v", err) + } + flash, errMsg := flashFrom(r) + s.render(w, "relay", relayListData{ + basePage: s.newBase(r, flash, errMsg), + Accounts: accounts, + }) +} + +// ---- Relay accounts ---- + +type relayAccountData struct { + basePage + Domain *models.Domain + Account *models.RelayAccount + SendAs []*models.RelaySendAs + IPs []*models.RelayAccountIP +} + +func (s *Server) relayAccountCreate(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + if !s.validateCSRF(w, r) { + return + } + + domainID := pathID(r, "id") + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + displayName := strings.TrimSpace(r.FormValue("display_name")) + description := strings.TrimSpace(r.FormValue("description")) + + if len(username) < 1 || len(username) > 240 { + redirect(w, r, fmt.Sprintf("/admin/domains/%d", domainID), "", "Username must be 1-240 characters.") + return + } + if len(password) < 8 || len(password) > 1024 { + redirect(w, r, fmt.Sprintf("/admin/domains/%d", domainID), "", "Password must be 8-1024 characters.") + return + } + + hash, err := appCrypto.HashPassword(password) + if err != nil { + redirect(w, r, fmt.Sprintf("/admin/domains/%d", domainID), "", "Password hashing failed.") + return + } + + aid, err := s.deps.DB.CreateRelayAccount(ctx, domainID, username, hash, displayName, description) + if err != nil { + log.Printf("[admin] create relay account: %v", err) + redirect(w, r, fmt.Sprintf("/admin/domains/%d", domainID), "", "Failed to create relay account. Username may already exist.") + return + } + redirect(w, r, fmt.Sprintf("/admin/domains/%d/relayaccount/%d", domainID, aid), "Relay account created.", "") +} + +func (s *Server) relayAccountDetail(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + domainID := pathID(r, "id") + aid := pathID(r, "aid") + + dom, err := s.deps.DB.GetDomainByID(ctx, domainID) + if err != nil || dom == nil { + http.NotFound(w, r) + return + } + acc, err := s.deps.DB.GetRelayAccountByID(ctx, aid) + if err != nil || acc == nil || acc.DomainID != domainID { + http.NotFound(w, r) + return + } + sendAs, _ := s.deps.DB.GetRelaySendAs(ctx, aid) + ips, _ := s.deps.DB.GetRelayAccountIPs(ctx, aid) + + flash, errMsg := flashFrom(r) + s.render(w, "relayaccount", relayAccountData{ + basePage: s.newBase(r, flash, errMsg), + Domain: dom, + Account: acc, + SendAs: sendAs, + IPs: ips, + }) +} + +func (s *Server) relayAccountAddIP(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() @@ -903,23 +1010,118 @@ func (s *Server) userRelayAddSendAs(w http.ResponseWriter, r *http.Request) { return } - id := pathID(r, "id") + domainID := pathID(r, "id") + aid := pathID(r, "aid") + cidr := strings.TrimSpace(r.FormValue("cidr")) + description := strings.TrimSpace(r.FormValue("description")) + + if !validCIDR(cidr) { + redirect(w, r, fmt.Sprintf("/admin/domains/%d/relayaccount/%d", domainID, aid), "", "Invalid IP or CIDR notation.") + return + } + if len(description) > 255 { + description = description[:255] + } + + if err := s.deps.DB.AddRelayAccountIP(ctx, aid, cidr, description); err != nil { + log.Printf("[admin] relay account add ip: %v", err) + redirect(w, r, fmt.Sprintf("/admin/domains/%d/relayaccount/%d", domainID, aid), "", "Failed to add IP rule.") + return + } + redirect(w, r, fmt.Sprintf("/admin/domains/%d/relayaccount/%d", domainID, aid), "IP added.", "") +} + +func (s *Server) relayAccountDeleteIP(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + if !s.validateCSRF(w, r) { + return + } + + domainID := pathID(r, "id") + aid := pathID(r, "aid") + iid := pathID(r, "iid") + + if err := s.deps.DB.DeleteRelayAccountIP(ctx, iid, aid); err != nil { + log.Printf("[admin] relay account delete ip: %v", err) + } + redirect(w, r, fmt.Sprintf("/admin/domains/%d/relayaccount/%d", domainID, aid), "IP removed.", "") +} + +func (s *Server) relayAccountToggle(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + if !s.validateCSRF(w, r) { + return + } + + domainID := pathID(r, "id") + aid := pathID(r, "aid") + enabled := r.FormValue("enabled") == "1" + + if err := s.deps.DB.SetRelayAccountEnabled(ctx, aid, enabled); err != nil { + log.Printf("[admin] relay account toggle: %v", err) + } + redirect(w, r, fmt.Sprintf("/admin/domains/%d/relayaccount/%d", domainID, aid), "", "") +} + +func (s *Server) relayAccountPassword(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + if !s.validateCSRF(w, r) { + return + } + + domainID := pathID(r, "id") + aid := pathID(r, "aid") + password := r.FormValue("password") + + if len(password) < 8 || len(password) > 1024 { + redirect(w, r, fmt.Sprintf("/admin/domains/%d/relayaccount/%d", domainID, aid), "", "Password must be 8-1024 characters.") + return + } + hash, err := appCrypto.HashPassword(password) + if err != nil { + redirect(w, r, fmt.Sprintf("/admin/domains/%d/relayaccount/%d", domainID, aid), "", "Password error.") + return + } + if err := s.deps.DB.SetRelayAccountPassword(ctx, aid, hash); err != nil { + log.Printf("[admin] relay account password: %v", err) + redirect(w, r, fmt.Sprintf("/admin/domains/%d/relayaccount/%d", domainID, aid), "", "Failed to update password.") + return + } + redirect(w, r, fmt.Sprintf("/admin/domains/%d/relayaccount/%d", domainID, aid), "Password updated.", "") +} + +func (s *Server) relayAccountAddSendAs(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + if !s.validateCSRF(w, r) { + return + } + + domainID := pathID(r, "id") + aid := pathID(r, "aid") pattern := strings.ToLower(strings.TrimSpace(r.FormValue("pattern"))) if !validRelayPattern(pattern) { - redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Invalid pattern. Use exact email or *@domain.com.") + redirect(w, r, fmt.Sprintf("/admin/domains/%d/relayaccount/%d", domainID, aid), "", "Invalid pattern. Use exact email or *@domain.com.") return } - if err := s.deps.DB.AddRelaySendAs(ctx, id, pattern); err != nil { + if err := s.deps.DB.AddRelaySendAs(ctx, aid, pattern); err != nil { log.Printf("[admin] add relay sendas: %v", err) - redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Failed to add pattern.") + redirect(w, r, fmt.Sprintf("/admin/domains/%d/relayaccount/%d", domainID, aid), "", "Failed to add pattern.") return } - redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "Pattern added.", "") + redirect(w, r, fmt.Sprintf("/admin/domains/%d/relayaccount/%d", domainID, aid), "Pattern added.", "") } -func (s *Server) userRelayDeleteSendAs(w http.ResponseWriter, r *http.Request) { +func (s *Server) relayAccountDeleteSendAs(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() @@ -927,17 +1129,37 @@ func (s *Server) userRelayDeleteSendAs(w http.ResponseWriter, r *http.Request) { return } - id := pathID(r, "id") + domainID := pathID(r, "id") + aid := pathID(r, "aid") sid, err := strconv.ParseInt(r.PathValue("sid"), 10, 64) if err != nil || sid <= 0 { http.NotFound(w, r) return } - if err := s.deps.DB.DeleteRelaySendAs(ctx, sid, id); err != nil { + if err := s.deps.DB.DeleteRelaySendAs(ctx, sid, aid); err != nil { log.Printf("[admin] delete relay sendas: %v", err) } - redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "Pattern removed.", "") + redirect(w, r, fmt.Sprintf("/admin/domains/%d/relayaccount/%d", domainID, aid), "Pattern removed.", "") +} + +func (s *Server) relayAccountDelete(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + if !s.validateCSRF(w, r) { + return + } + + domainID := pathID(r, "id") + aid := pathID(r, "aid") + + if err := s.deps.DB.DeleteRelayAccount(ctx, aid); err != nil { + log.Printf("[admin] delete relay account: %v", err) + redirect(w, r, fmt.Sprintf("/admin/domains/%d/relayaccount/%d", domainID, aid), "", "Delete failed.") + return + } + redirect(w, r, fmt.Sprintf("/admin/domains/%d", domainID), "Relay account deleted.", "") } // ---- Relay IP rules (per domain) ---- @@ -1052,6 +1274,17 @@ func validUsername(s string) bool { return true } +// validEmail validates a basic email address (local@domain). +func validEmail(s string) bool { + at := strings.LastIndex(s, "@") + if at < 1 || at == len(s)-1 { + return false + } + local := s[:at] + domain := s[at+1:] + return len(local) >= 1 && len(local) <= 64 && validDomain(domain) +} + // validIdentifier accepts [a-zA-Z0-9_-], 1-63 chars. func validIdentifier(s string) bool { if len(s) < 1 || len(s) > 63 { diff --git a/internal/webadmin/server.go b/internal/webadmin/server.go index a287683..00f878e 100644 --- a/internal/webadmin/server.go +++ b/internal/webadmin/server.go @@ -93,11 +93,24 @@ func (s *Server) setupRoutes() { m.HandleFunc("POST /admin/users/{id}/update", s.require(s.userUpdate)) m.HandleFunc("POST /admin/users/{id}/password", s.require(s.userPassword)) m.HandleFunc("POST /admin/users/{id}/delete", s.require(s.userDelete)) - m.HandleFunc("POST /admin/users/{id}/relay/toggle", s.require(s.userRelayToggle)) - m.HandleFunc("POST /admin/users/{id}/relay/sendas", s.require(s.userRelayAddSendAs)) - m.HandleFunc("POST /admin/users/{id}/relay/{sid}/delete", s.require(s.userRelayDeleteSendAs)) - m.HandleFunc("POST /admin/domains/{id}/relay/add", s.require(s.domainRelayAdd)) - m.HandleFunc("POST /admin/domains/{id}/relay/{rid}/delete", s.require(s.domainRelayDelete)) + // User aliases + m.HandleFunc("POST /admin/users/{id}/alias", s.require(s.userAliasAdd)) + m.HandleFunc("POST /admin/users/{id}/alias/{aid}/delete", s.require(s.userAliasDelete)) + // Relay overview + m.HandleFunc("GET /admin/relay", s.require(s.relayList)) + // Relay accounts (domain-level) + m.HandleFunc("POST /admin/domains/{id}/relayaccount", s.require(s.relayAccountCreate)) + m.HandleFunc("GET /admin/domains/{id}/relayaccount/{aid}", s.require(s.relayAccountDetail)) + m.HandleFunc("POST /admin/domains/{id}/relayaccount/{aid}/toggle", s.require(s.relayAccountToggle)) + m.HandleFunc("POST /admin/domains/{id}/relayaccount/{aid}/password", s.require(s.relayAccountPassword)) + m.HandleFunc("POST /admin/domains/{id}/relayaccount/{aid}/sendas", s.require(s.relayAccountAddSendAs)) + m.HandleFunc("POST /admin/domains/{id}/relayaccount/{aid}/sendas/{sid}/delete", s.require(s.relayAccountDeleteSendAs)) + m.HandleFunc("POST /admin/domains/{id}/relayaccount/{aid}/ip", s.require(s.relayAccountAddIP)) + m.HandleFunc("POST /admin/domains/{id}/relayaccount/{aid}/ip/{iid}/delete", s.require(s.relayAccountDeleteIP)) + m.HandleFunc("POST /admin/domains/{id}/relayaccount/{aid}/delete", s.require(s.relayAccountDelete)) + // IP relay rules + m.HandleFunc("POST /admin/domains/{id}/iprelay/add", s.require(s.domainRelayAdd)) + m.HandleFunc("POST /admin/domains/{id}/iprelay/{rid}/delete", s.require(s.domainRelayDelete)) m.HandleFunc("GET /admin/queue", s.require(s.queueList)) m.HandleFunc("POST /admin/queue/{id}/retry", s.require(s.queueRetry)) diff --git a/web/admin/templates/base.html b/web/admin/templates/base.html index 0dc59aa..f3f4138 100644 --- a/web/admin/templates/base.html +++ b/web/admin/templates/base.html @@ -44,6 +44,7 @@ Dashboard Domains Users + Relay Delivery Queue IP Bans Security Events diff --git a/web/admin/templates/domain.html b/web/admin/templates/domain.html index 6313041..f7d9fc9 100644 --- a/web/admin/templates/domain.html +++ b/web/admin/templates/domain.html @@ -189,7 +189,7 @@