admin dash - added relay

This commit is contained in:
2026-05-25 17:29:15 +00:00
parent 3d46ccde33
commit fdda0cae34
14 changed files with 1063 additions and 198 deletions
+2 -3
View File
@@ -232,7 +232,7 @@ func (d *DB) DeleteUser(ctx context.Context, userID int64) error {
func (d *DB) ListAllUsers(ctx context.Context) ([]*UserWithDomain, error) { func (d *DB) ListAllUsers(ctx context.Context) ([]*UserWithDomain, error) {
rows, err := d.db.QueryContext(ctx, ` rows, err := d.db.QueryContext(ctx, `
SELECT u.id, u.domain_id, u.username, u.email, u.display_name, 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, u.mfa_enabled, u.created_at, u.last_login,
d.name AS domain_name d.name AS domain_name
FROM users u FROM users u
@@ -249,7 +249,7 @@ func (d *DB) ListAllUsers(ctx context.Context) ([]*UserWithDomain, error) {
var lastLogin sql.NullTime var lastLogin sql.NullTime
err := rows.Scan( err := rows.Scan(
&u.ID, &u.DomainID, &u.Username, &u.Email, &u.DisplayName, &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.MFAEnabled, &u.CreatedAt, &lastLogin,
&u.DomainName, &u.DomainName,
) )
@@ -277,7 +277,6 @@ type UserWithDomain struct {
Enabled bool Enabled bool
Admin bool Admin bool
DomainAdmin bool DomainAdmin bool
IsRelay bool
MFAEnabled bool MFAEnabled bool
CreatedAt time.Time CreatedAt time.Time
LastLogin time.Time LastLogin time.Time
+71 -8
View File
@@ -18,6 +18,8 @@ var migrations = []migration{
{1, schemav1}, {1, schemav1},
{2, schemav2}, {2, schemav2},
{3, schemav3}, {3, schemav3},
{4, schemav4},
{5, schemav5},
} }
// migrate applies any unapplied migrations in order. // 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); 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 = ` const schemav5 = `
ALTER TABLE users ADD COLUMN is_relay BOOLEAN NOT NULL DEFAULT 0; 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 ( CREATE TABLE IF NOT EXISTS relay_send_as (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, relay_account_id INTEGER NOT NULL REFERENCES relay_accounts(id) ON DELETE CASCADE,
pattern TEXT NOT NULL, pattern TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 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 ( CREATE TABLE IF NOT EXISTS relay_ip_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
+253 -26
View File
@@ -1,19 +1,125 @@
// Package db — relay account and IP relay rule operations.
package db package db
import ( import (
"context" "context"
"database/sql"
"fmt"
"net" "net"
"strings" "strings"
"time"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models" "ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
) )
// ---- Relay send-as (per user) ---- // ---- Relay accounts ----
// GetRelaySendAs returns all allowed sender patterns for a relay user. // GetRelayAccountByUsername returns the relay account with the given username (case-insensitive), or nil.
func (d *DB) GetRelaySendAs(ctx context.Context, userID int64) ([]*models.RelaySendAs, error) { 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, 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 { if err != nil {
return nil, err return nil, err
} }
@@ -22,7 +128,7 @@ func (d *DB) GetRelaySendAs(ctx context.Context, userID int64) ([]*models.RelayS
var out []*models.RelaySendAs var out []*models.RelaySendAs
for rows.Next() { for rows.Next() {
r := &models.RelaySendAs{} 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 return nil, err
} }
out = append(out, r) out = append(out, r)
@@ -30,33 +136,26 @@ func (d *DB) GetRelaySendAs(ctx context.Context, userID int64) ([]*models.RelayS
return out, rows.Err() return out, rows.Err()
} }
// AddRelaySendAs adds a sender pattern for a relay user. Duplicate patterns are ignored. // AddRelaySendAs adds a sender pattern for a relay account. Duplicate patterns are ignored.
func (d *DB) AddRelaySendAs(ctx context.Context, userID int64, pattern string) error { func (d *DB) AddRelaySendAs(ctx context.Context, relayAccountID int64, pattern string) error {
_, err := d.db.ExecContext(ctx, _, 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 return err
} }
// DeleteRelaySendAs removes a sender pattern. userID is verified to prevent cross-user deletion. // DeleteRelaySendAs removes a sender pattern. relayAccountID is verified.
func (d *DB) DeleteRelaySendAs(ctx context.Context, id, userID int64) error { func (d *DB) DeleteRelaySendAs(ctx context.Context, id, relayAccountID int64) error {
_, err := d.db.ExecContext(ctx, _, 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 return err
} }
// IsRelaySenderAllowed returns true when the relay user is permitted to send as fromEmail. // IsRelaySenderAllowed returns true when the relay account is permitted to send as fromEmail.
// Own email is always allowed. Otherwise checks relay_send_as patterns. // Any pattern in relay_send_as matching the address is sufficient.
func (d *DB) IsRelaySenderAllowed(ctx context.Context, userID int64, fromEmail string) (bool, error) { func (d *DB) IsRelaySenderAllowed(ctx context.Context, relayAccountID 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
}
rows, err := d.db.QueryContext(ctx, 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 { if err != nil {
return false, err return false, err
} }
@@ -112,7 +211,7 @@ func (d *DB) DeleteRelayIPRule(ctx context.Context, id, domainID int64) error {
return err 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. // via any active IP relay rule across all enabled domains.
func (d *DB) CheckIPRelay(ctx context.Context, clientIP, senderEmail string) (bool, error) { func (d *DB) CheckIPRelay(ctx context.Context, clientIP, senderEmail string) (bool, error) {
rows, err := d.db.QueryContext(ctx, ` 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() 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 ---- // ---- 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. // matchSenderPattern checks if email matches pattern.
// "*@domain.com" matches any address at that domain. // "*@domain.com" matches any address at that domain.
// Anything else is an exact case-insensitive match. // 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. // 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 { func ipInCIDR(cidr, ip string) bool {
parsedIP := net.ParseIP(ip) parsedIP := net.ParseIP(ip)
if parsedIP == nil { if parsedIP == nil {
return false return false
} }
if !strings.Contains(cidr, "/") { if !strings.Contains(cidr, "/") {
// Plain IP — exact match.
target := net.ParseIP(cidr) target := net.ParseIP(cidr)
return target != nil && target.Equal(parsedIP) return target != nil && target.Equal(parsedIP)
} }
+48 -8
View File
@@ -13,7 +13,7 @@ import (
func (d *DB) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { func (d *DB) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
row := d.db.QueryRowContext(ctx, ` row := d.db.QueryRowContext(ctx, `
SELECT id, domain_id, username, email, password_hash, display_name, 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 mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login
FROM users WHERE lower(email)=lower(?)`, email) FROM users WHERE lower(email)=lower(?)`, email)
return scanUser(row) 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) { func (d *DB) GetUserByID(ctx context.Context, id int64) (*models.User, error) {
row := d.db.QueryRowContext(ctx, ` row := d.db.QueryRowContext(ctx, `
SELECT id, domain_id, username, email, password_hash, display_name, 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 mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login
FROM users WHERE id=?`, id) FROM users WHERE id=?`, id)
return scanUser(row) 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) { func (d *DB) ListUsers(ctx context.Context, domainID int64) ([]*models.User, error) {
rows, err := d.db.QueryContext(ctx, ` rows, err := d.db.QueryContext(ctx, `
SELECT id, domain_id, username, email, password_hash, display_name, 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 mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login
FROM users WHERE domain_id=? ORDER BY email`, domainID) FROM users WHERE domain_id=? ORDER BY email`, domainID)
if err != nil { if err != nil {
@@ -135,7 +135,7 @@ func (d *DB) ListUsers(ctx context.Context, domainID int64) ([]*models.User, err
err := rows.Scan( err := rows.Scan(
&u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash, &u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash,
&u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled, &u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled,
&u.Admin, &u.DomainAdmin, &u.IsRelay, &u.Admin, &u.DomainAdmin,
&mfaEnc, &u.MFAEnabled, &rcEnc, &mfaEnc, &u.MFAEnabled, &rcEnc,
&u.CreatedAt, &lastLogin, &u.CreatedAt, &lastLogin,
) )
@@ -152,9 +152,49 @@ func (d *DB) ListUsers(ctx context.Context, domainID int64) ([]*models.User, err
return users, rows.Err() return users, rows.Err()
} }
// SetUserIsRelay sets the is_relay flag for a user. // ---- User aliases ----
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) // 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 return err
} }
@@ -168,7 +208,7 @@ func scanUser(row *sql.Row) (*models.User, error) {
err := row.Scan( err := row.Scan(
&u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash, &u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash,
&u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled, &u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled,
&u.Admin, &u.DomainAdmin, &u.IsRelay, &u.Admin, &u.DomainAdmin,
&mfaEnc, &u.MFAEnabled, &rcEnc, &mfaEnc, &u.MFAEnabled, &rcEnc,
&u.CreatedAt, &lastLogin, &u.CreatedAt, &lastLogin,
) )
+29 -6
View File
@@ -69,7 +69,6 @@ type User struct {
Enabled bool Enabled bool
Admin bool // global admin Admin bool // global admin
DomainAdmin bool // admin of own domain only 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 MFASecretEnc []byte // encrypted TOTP secret; nil = MFA disabled
MFAEnabled bool MFAEnabled bool
RecoveryCodesEnc []byte // encrypted JSON array of one-time codes RecoveryCodesEnc []byte // encrypted JSON array of one-time codes
@@ -312,13 +311,26 @@ type SpamToken struct {
// ---- Relay ---- // ---- 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). // Pattern may be an exact address or a wildcard (*@domain.com).
type RelaySendAs struct { type RelaySendAs struct {
ID int64 ID int64
UserID int64 RelayAccountID int64
Pattern string Pattern string
CreatedAt time.Time CreatedAt time.Time
} }
// RelayIPRule allows unauthenticated SMTP relay from a specific IP/CIDR // RelayIPRule allows unauthenticated SMTP relay from a specific IP/CIDR
@@ -332,6 +344,17 @@ type RelayIPRule struct {
CreatedAt time.Time 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) ---- // ---- Compose helpers (not persisted directly) ----
type Attachment_Upload struct { type Attachment_Upload struct {
+62 -34
View File
@@ -20,7 +20,7 @@ import (
) )
// SubmissionBackend implements gosmtp.Backend for ports 587/465. // 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 { type SubmissionBackend struct {
deps *Deps deps *Deps
} }
@@ -45,39 +45,57 @@ func (b *SubmissionBackend) NewSession(c *gosmtp.Conn) (gosmtp.Session, error) {
}, nil }, nil
} }
// SubmissionSession handles one authenticated submission connection. // SubmissionSession handles one authenticated or IP-relay submission connection.
type SubmissionSession struct { type SubmissionSession struct {
deps *Deps deps *Deps
clientIP string clientIP string
user *models.User // set after AUTH user *models.User // set after AUTH as regular user
ipRelayMode bool // set when IP relay authorization succeeds (no AUTH) relayAccount *models.RelayAccount // set after AUTH as relay account
from string ipRelayMode bool // set when IP relay authorization succeeds (no AUTH)
rcpts []string from string
rcpts []string
} }
func (s *SubmissionSession) AuthPlain(username, password string) error { func (s *SubmissionSession) AuthPlain(username, password string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
// Try regular user first.
user, err := s.deps.DB.GetUserByEmail(ctx, username) user, err := s.deps.DB.GetUserByEmail(ctx, username)
if err != nil { if err != nil {
log.Printf("[smtp/submission] auth lookup error %s: %v", username, err) log.Printf("[smtp/submission] auth lookup error %s: %v", username, err)
return &gosmtp.SMTPError{Code: 535, Message: "authentication failed"} return &gosmtp.SMTPError{Code: 535, Message: "authentication failed"}
} }
if user == nil || !user.Enabled { if user != nil && user.Enabled {
s.logAttempt(ctx, username, false) if err := crypto.CheckPassword(user.PasswordHash, password); err != nil {
return &gosmtp.SMTPError{Code: 535, Message: "authentication failed"} 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 { // Try relay account.
s.logAttempt(ctx, username, false) relayAcc, err := s.deps.DB.GetRelayAccountByUsername(ctx, username)
log.Printf("[smtp/submission] auth failed for %s from %s", username, s.clientIP) if err != nil {
log.Printf("[smtp/submission] relay auth lookup error %s: %v", username, err)
return &gosmtp.SMTPError{Code: 535, Message: "authentication failed"} return &gosmtp.SMTPError{Code: 535, Message: "authentication failed"}
} }
if relayAcc == nil || !relayAcc.Enabled {
s.user = user s.logAttempt(ctx, username, false)
s.deps.DB.UpdateLastLogin(ctx, user.ID) log.Printf("[smtp/submission] auth failed for relay %s from %s", username, s.clientIP)
log.Printf("[smtp/submission] auth OK for %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 return nil
} }
@@ -88,11 +106,24 @@ func (s *SubmissionSession) Mail(from string, opts *gosmtp.MailOptions) error {
} }
fromEmail := strings.ToLower(addr.Address) fromEmail := strings.ToLower(addr.Address)
if s.user == nil { if s.user == nil && s.relayAccount == nil {
// Unauthenticated — check IP relay rules. // Unauthenticated — check relay account IP whitelist first, then domain-level IP rules.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() 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) allowed, err := s.deps.DB.CheckIPRelay(ctx, s.clientIP, fromEmail)
if err != nil { if err != nil {
log.Printf("[smtp/submission] ip relay check error from %s: %v", s.clientIP, err) 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 return nil
} }
if s.user.IsRelay { if s.relayAccount != nil {
// Relay account — validate sender against allowed patterns. // Relay account — validate sender against allowed send-as patterns.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() 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 { 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"} return &gosmtp.SMTPError{Code: 451, EnhancedCode: gosmtp.EnhancedCode{4, 3, 0}, Message: "temporary error"}
} }
if !allowed { 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 s.from = addr.Address
return nil 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) { if !strings.EqualFold(fromEmail, s.user.Email) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() 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 { 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"} 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 { 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"} return &gosmtp.SMTPError{Code: 530, Message: "authentication required"}
} }
if len(s.rcpts) == 0 { if len(s.rcpts) == 0 {
@@ -178,7 +209,6 @@ func (s *SubmissionSession) Data(r io.Reader) error {
senderDomain := domainOf(s.from) senderDomain := domainOf(s.from)
raw = s.signDKIM(ctx, raw, senderDomain) raw = s.signDKIM(ctx, raw, senderDomain)
msgID := extractMsgID(raw) msgID := extractMsgID(raw)
dom, err := s.deps.DB.GetDomain(ctx, senderDomain) 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) log.Printf("[smtp/submission] queued %s → %s", s.from, rcpt)
} }
// Save a Sent copy only for regular (non-relay) authenticated users. // Save Sent copy only for regular users (relay accounts have no mailboxes).
if s.user != nil && !s.user.IsRelay { if s.user != nil {
s.saveSentCopy(ctx, raw) s.saveSentCopy(ctx, raw)
} }
@@ -222,11 +252,10 @@ func (s *SubmissionSession) Reset() {
func (s *SubmissionSession) Logout() error { return nil } func (s *SubmissionSession) Logout() error { return nil }
// signDKIM signs the message with the sender domain's DKIM key if available. // 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 { func (s *SubmissionSession) signDKIM(ctx context.Context, raw []byte, senderDomain string) []byte {
dom, err := s.deps.DB.GetDomain(ctx, senderDomain) dom, err := s.deps.DB.GetDomain(ctx, senderDomain)
if err != nil || dom == nil || dom.DKIMPrivateEnc == nil { if err != nil || dom == nil || dom.DKIMPrivateEnc == nil {
return raw // no key configured return raw
} }
privPEM, err := s.deps.Crypt.DecryptGlobal("dkim", dom.DKIMPrivateEnc) 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 return raw
} }
// Prepend DKIM-Signature header.
return append([]byte(header+"\r\n"), raw...) return append([]byte(header+"\r\n"), raw...)
} }
+291 -58
View File
@@ -108,20 +108,20 @@ func (s *Server) domainsCreate(w http.ResponseWriter, r *http.Request) {
type domainDetailData struct { type domainDetailData struct {
basePage basePage
Domain *models.Domain Domain *models.Domain
Users []*models.User Users []*models.User
Hostname string Hostname string
DMARCReportCount int DMARCReportCount int
RelayIPRules []*models.RelayIPRule
// DNS records (what to configure)
MXRecord string MXRecord string
DKIMRecord string
SPFHint string SPFHint string
DKIMRecord string
DMARCHint string DMARCHint string
AutoconfigCNAME string AutoconfigCNAME string
AutodiscoverCNAME string AutodiscoverCNAME string
SMTPSRVRecord string SMTPSRVRecord string
IMAPSRVRecord string IMAPSRVRecord string
RelayAccounts []*models.RelayAccount
RelayIPRules []*models.RelayIPRule
} }
func (s *Server) domainDetail(w http.ResponseWriter, r *http.Request) { 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) dmarcCount, _ := s.deps.DB.DMARCReportCount(ctx, id)
relayAccounts, _ := s.deps.DB.ListRelayAccounts(ctx, id)
relayRules, _ := s.deps.DB.GetRelayIPRules(ctx, id) relayRules, _ := s.deps.DB.GetRelayIPRules(ctx, id)
s.render(w, "domain", domainDetailData{ s.render(w, "domain", domainDetailData{
@@ -170,15 +171,16 @@ func (s *Server) domainDetail(w http.ResponseWriter, r *http.Request) {
Users: users, Users: users,
Hostname: hostname, Hostname: hostname,
DMARCReportCount: dmarcCount, DMARCReportCount: dmarcCount,
RelayIPRules: relayRules,
MXRecord: fmt.Sprintf(`%s IN MX 10 %s.`, dom.Name, hostname), 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), SPFHint: fmt.Sprintf(`%s IN TXT "v=spf1 a mx ~all"`, dom.Name),
DKIMRecord: dkimRec,
DMARCHint: dmarcHint, DMARCHint: dmarcHint,
AutoconfigCNAME: fmt.Sprintf(`autoconfig.%s IN CNAME %s.`, dom.Name, hostname), AutoconfigCNAME: fmt.Sprintf(`autoconfig.%s IN CNAME %s.`, dom.Name, hostname),
AutodiscoverCNAME: fmt.Sprintf(`autodiscover.%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), 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), 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) quotaMB, _ := strconv.ParseInt(r.FormValue("quota_mb"), 10, 64)
domainAdmin := r.FormValue("domain_admin") == "1" domainAdmin := r.FormValue("domain_admin") == "1"
isRelay := r.FormValue("relay") == "1"
if domainID <= 0 || !validUsername(username) || len(password) < 8 || len(password) > 1024 { 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.") redirect(w, r, "/admin/users", "", "Invalid input. Username must be alphanumeric, password min 8 chars.")
return return
@@ -443,15 +443,6 @@ func (s *Server) usersCreate(w http.ResponseWriter, r *http.Request) {
return 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. // Create default mailboxes, calendar, address book.
if err := createDefaultMailboxes(ctx, s.deps.DB, userID); err != nil { if err := createDefaultMailboxes(ctx, s.deps.DB, userID); err != nil {
log.Printf("[admin] default mailboxes: %v", err) log.Printf("[admin] default mailboxes: %v", err)
@@ -468,8 +459,8 @@ func (s *Server) usersCreate(w http.ResponseWriter, r *http.Request) {
type userDetailData struct { type userDetailData struct {
basePage basePage
U *db.UserWithDomain U *db.UserWithDomain
SendAs []*models.RelaySendAs Aliases []*models.UserAlias
} }
func (s *Server) userDetail(w http.ResponseWriter, r *http.Request) { 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 return
} }
var sendAs []*models.RelaySendAs aliases, err := s.deps.DB.GetUserAliases(ctx, id)
if found.IsRelay { if err != nil {
sendAs, _ = s.deps.DB.GetRelaySendAs(ctx, found.ID) log.Printf("[admin] get aliases user %d: %v", id, err)
} }
flash, errMsg := flashFrom(r) flash, errMsg := flashFrom(r)
s.render(w, "user", userDetailData{ s.render(w, "user", userDetailData{
basePage: s.newBase(r, flash, errMsg), basePage: s.newBase(r, flash, errMsg),
U: found, 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) { func (s *Server) userUpdate(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel() defer cancel()
@@ -870,32 +902,107 @@ func generateToken(n int) (string, error) {
return fmt.Sprintf("%x", buf), nil return fmt.Sprintf("%x", buf), nil
} }
// ---- Relay user management ---- // ---- Relay overview ----
func (s *Server) userRelayToggle(w http.ResponseWriter, r *http.Request) { type relayListData struct {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) basePage
defer cancel() Accounts []*db.RelayAccountWithDomain
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, "")
} }
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) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel() defer cancel()
@@ -903,23 +1010,118 @@ func (s *Server) userRelayAddSendAs(w http.ResponseWriter, r *http.Request) {
return 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"))) pattern := strings.ToLower(strings.TrimSpace(r.FormValue("pattern")))
if !validRelayPattern(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 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) 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 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) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel() defer cancel()
@@ -927,17 +1129,37 @@ func (s *Server) userRelayDeleteSendAs(w http.ResponseWriter, r *http.Request) {
return return
} }
id := pathID(r, "id") domainID := pathID(r, "id")
aid := pathID(r, "aid")
sid, err := strconv.ParseInt(r.PathValue("sid"), 10, 64) sid, err := strconv.ParseInt(r.PathValue("sid"), 10, 64)
if err != nil || sid <= 0 { if err != nil || sid <= 0 {
http.NotFound(w, r) http.NotFound(w, r)
return 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) 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) ---- // ---- Relay IP rules (per domain) ----
@@ -1052,6 +1274,17 @@ func validUsername(s string) bool {
return true 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. // validIdentifier accepts [a-zA-Z0-9_-], 1-63 chars.
func validIdentifier(s string) bool { func validIdentifier(s string) bool {
if len(s) < 1 || len(s) > 63 { if len(s) < 1 || len(s) > 63 {
+18 -5
View File
@@ -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}/update", s.require(s.userUpdate))
m.HandleFunc("POST /admin/users/{id}/password", s.require(s.userPassword)) 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}/delete", s.require(s.userDelete))
m.HandleFunc("POST /admin/users/{id}/relay/toggle", s.require(s.userRelayToggle)) // User aliases
m.HandleFunc("POST /admin/users/{id}/relay/sendas", s.require(s.userRelayAddSendAs)) m.HandleFunc("POST /admin/users/{id}/alias", s.require(s.userAliasAdd))
m.HandleFunc("POST /admin/users/{id}/relay/{sid}/delete", s.require(s.userRelayDeleteSendAs)) m.HandleFunc("POST /admin/users/{id}/alias/{aid}/delete", s.require(s.userAliasDelete))
m.HandleFunc("POST /admin/domains/{id}/relay/add", s.require(s.domainRelayAdd)) // Relay overview
m.HandleFunc("POST /admin/domains/{id}/relay/{rid}/delete", s.require(s.domainRelayDelete)) 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("GET /admin/queue", s.require(s.queueList))
m.HandleFunc("POST /admin/queue/{id}/retry", s.require(s.queueRetry)) m.HandleFunc("POST /admin/queue/{id}/retry", s.require(s.queueRetry))
+1
View File
@@ -44,6 +44,7 @@
<a href="/admin/" class="nav-link">Dashboard</a> <a href="/admin/" class="nav-link">Dashboard</a>
<a href="/admin/domains" class="nav-link">Domains</a> <a href="/admin/domains" class="nav-link">Domains</a>
<a href="/admin/users" class="nav-link">Users</a> <a href="/admin/users" class="nav-link">Users</a>
<a href="/admin/relay" class="nav-link">Relay</a>
<a href="/admin/queue" class="nav-link">Delivery Queue</a> <a href="/admin/queue" class="nav-link">Delivery Queue</a>
<a href="/admin/bans" class="nav-link">IP Bans</a> <a href="/admin/bans" class="nav-link">IP Bans</a>
<a href="/admin/events" class="nav-link">Security Events</a> <a href="/admin/events" class="nav-link">Security Events</a>
+73 -2
View File
@@ -189,7 +189,7 @@
<td style="padding:.25rem .5rem;font-family:monospace;color:#a78bfa">{{.SenderPattern}}</td> <td style="padding:.25rem .5rem;font-family:monospace;color:#a78bfa">{{.SenderPattern}}</td>
<td style="padding:.25rem .5rem;color:#9ca3af">{{.Description}}</td> <td style="padding:.25rem .5rem;color:#9ca3af">{{.Description}}</td>
<td style="padding:.25rem .5rem;text-align:right"> <td style="padding:.25rem .5rem;text-align:right">
<form method="POST" action="/admin/domains/{{$.Domain.ID}}/relay/{{.ID}}/delete" style="margin:0" <form method="POST" action="/admin/domains/{{$.Domain.ID}}/iprelay/{{.ID}}/delete" style="margin:0"
onsubmit="return confirm('Remove this IP relay rule?')"> onsubmit="return confirm('Remove this IP relay rule?')">
<input type="hidden" name="_csrf" value="{{$.CSRF}}"> <input type="hidden" name="_csrf" value="{{$.CSRF}}">
<button type="submit" class="btn btn-danger btn-sm" style="padding:.125rem .5rem;font-size:.7rem">Remove</button> <button type="submit" class="btn btn-danger btn-sm" style="padding:.125rem .5rem;font-size:.7rem">Remove</button>
@@ -203,7 +203,7 @@
{{else}} {{else}}
<div class="text-xs text-gray-500 mb-3">No IP relay rules configured.</div> <div class="text-xs text-gray-500 mb-3">No IP relay rules configured.</div>
{{end}} {{end}}
<form method="POST" action="/admin/domains/{{.Domain.ID}}/relay/add"> <form method="POST" action="/admin/domains/{{.Domain.ID}}/iprelay/add">
<input type="hidden" name="_csrf" value="{{.CSRF}}"> <input type="hidden" name="_csrf" value="{{.CSRF}}">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
<div class="field" style="margin:0"> <div class="field" style="margin:0">
@@ -272,6 +272,77 @@
</div> </div>
</div> </div>
<!-- Relay Accounts -->
<div class="mt-4">
<div class="flex items-center justify-between mb-2">
<h2 class="text-sm font-semibold text-gray-300">Relay Accounts ({{len .RelayAccounts}})</h2>
</div>
<div class="card" style="padding:0;overflow:hidden">
{{if .RelayAccounts}}
<table>
<thead>
<tr>
<th>Username</th>
<th>Display name</th>
<th>Status</th>
<th>Description</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .RelayAccounts}}
<tr>
<td style="font-family:monospace;font-size:.8rem">
<a href="/admin/domains/{{$.Domain.ID}}/relayaccount/{{.ID}}" class="text-blue-400 hover:underline">{{.Username}}</a>
</td>
<td class="text-gray-300">{{.DisplayName}}</td>
<td>
{{if .Enabled}}<span class="badge badge-green">active</span>
{{else}}<span class="badge badge-red">disabled</span>{{end}}
</td>
<td class="text-gray-400 text-xs">{{.Description}}</td>
<td class="text-gray-400 text-xs">{{shortTime .CreatedAt}}</td>
<td><a href="/admin/domains/{{$.Domain.ID}}/relayaccount/{{.ID}}" class="btn btn-primary btn-sm">Manage</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="p-6 text-center text-gray-500 text-sm">No relay accounts configured.</div>
{{end}}
</div>
<!-- Create relay account form -->
<div class="card mt-3">
<div class="text-sm font-semibold text-gray-300 mb-3">Create relay account</div>
<form method="POST" action="/admin/domains/{{.Domain.ID}}/relayaccount">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:.75rem;margin-bottom:.75rem">
<div class="field" style="margin:0">
<label>Username (email or arbitrary string, max 240 chars)</label>
<input type="text" name="username" required maxlength="240" placeholder="relay@example.com or myapp-relay">
</div>
<div class="field" style="margin:0">
<label>Display name</label>
<input type="text" name="display_name" maxlength="255" placeholder="My App">
</div>
<div class="field" style="margin:0">
<label>Password (min 8 chars)</label>
<input type="password" name="password" required minlength="8" maxlength="1024">
</div>
</div>
<div style="display:grid;grid-template-columns:2fr auto;gap:.75rem;align-items:flex-end">
<div class="field" style="margin:0">
<label>Description (optional)</label>
<input type="text" name="description" maxlength="255" placeholder="Internal application relay">
</div>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
<style> <style>
.dns-record { .dns-record {
background: #111827; background: #111827;
+54
View File
@@ -0,0 +1,54 @@
{{define "title"}}Relay Accounts{{end}}
{{define "content"}}
<h1 class="text-xl font-bold text-white mb-6">Relay Accounts</h1>
<div class="text-xs text-gray-400 mb-4">
Relay accounts allow external applications and servers to send mail via this server using SMTP AUTH.
They have no IMAP mailboxes. Managed per domain — click an account to configure send-as permissions.
</div>
<div class="card" style="padding:0;overflow:hidden">
{{if .Accounts}}
<table>
<thead>
<tr>
<th>Username</th>
<th>Domain</th>
<th>Display name</th>
<th>Status</th>
<th>Description</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Accounts}}
<tr>
<td style="font-family:monospace;font-size:.8rem">
<a href="/admin/domains/{{.DomainID}}/relayaccount/{{.ID}}" class="text-blue-400 hover:underline">{{.Username}}</a>
</td>
<td><a href="/admin/domains/{{.DomainID}}" class="text-gray-400 hover:text-white text-xs">{{.DomainName}}</a></td>
<td class="text-gray-300">{{.DisplayName}}</td>
<td>
{{if .Enabled}}<span class="badge badge-green">active</span>
{{else}}<span class="badge badge-red">disabled</span>{{end}}
</td>
<td class="text-gray-400 text-xs">{{.Description}}</td>
<td class="text-gray-400 text-xs">{{shortTime .CreatedAt}}</td>
<td><a href="/admin/domains/{{.DomainID}}/relayaccount/{{.ID}}" class="btn btn-primary btn-sm">Manage</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="p-8 text-center text-gray-500 text-sm">
No relay accounts configured. Go to a <a href="/admin/domains" class="text-blue-400 hover:underline">Domain</a> to create one.
</div>
{{end}}
</div>
<div class="mt-4 text-xs text-gray-500">
To create relay accounts, navigate to a domain and use the Relay Accounts section.
IP relay rules (unauthenticated sending by IP) are also configured per domain.
</div>
{{end}}
+147
View File
@@ -0,0 +1,147 @@
{{define "title"}}Relay Account — {{.Account.Username}}{{end}}
{{define "content"}}
<div class="flex items-center gap-3 mb-6">
<a href="/admin/domains" class="text-gray-400 text-sm hover:text-white">Domains</a>
<span class="text-gray-600">/</span>
<a href="/admin/domains/{{.Domain.ID}}" class="text-gray-400 text-sm hover:text-white">{{.Domain.Name}}</a>
<span class="text-gray-600">/</span>
<h1 class="text-xl font-bold text-white" style="font-family:monospace">{{.Account.Username}}</h1>
{{if .Account.Enabled}}<span class="badge badge-green">active</span>{{else}}<span class="badge badge-red">disabled</span>{{end}}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem">
<!-- Left column -->
<div>
<!-- Enable/disable -->
<div class="card">
<div class="text-sm font-semibold text-gray-300 mb-3">Status</div>
<form method="POST" action="/admin/domains/{{.Domain.ID}}/relayaccount/{{.Account.ID}}/toggle">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
{{if .Account.Enabled}}
<input type="hidden" name="enabled" value="0">
<button type="submit" class="btn btn-danger btn-sm">Disable account</button>
{{else}}
<input type="hidden" name="enabled" value="1">
<button type="submit" class="btn btn-primary btn-sm">Enable account</button>
{{end}}
</form>
</div>
<!-- Change password -->
<div class="card">
<div class="text-sm font-semibold text-gray-300 mb-3">Change password</div>
<form method="POST" action="/admin/domains/{{.Domain.ID}}/relayaccount/{{.Account.ID}}/password">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="field">
<label>New password (min 8 characters)</label>
<input type="password" name="password" required minlength="8" maxlength="1024">
</div>
<button type="submit" class="btn btn-primary btn-sm">Set password</button>
</form>
</div>
<!-- Delete -->
<div class="card" style="border:1px solid #7f1d1d">
<div class="text-sm font-semibold text-red-400 mb-2">Delete relay account</div>
<div class="text-xs text-gray-400 mb-3">Permanently removes this relay account and all its send-as patterns. Cannot be undone.</div>
<form method="POST" action="/admin/domains/{{.Domain.ID}}/relayaccount/{{.Account.ID}}/delete"
onsubmit="return confirm('Delete relay account {{.Account.Username}}?')">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<button type="submit" class="btn btn-danger btn-sm">Delete account</button>
</form>
</div>
</div>
<!-- Right column -->
<div>
<!-- Account info -->
<div class="card">
<div class="text-sm font-semibold text-gray-300 mb-3">Account info</div>
<div class="text-xs space-y-1.5 text-gray-400">
<div>Username: <span class="text-white" style="font-family:monospace">{{.Account.Username}}</span></div>
<div>Domain: <span class="text-white">{{.Domain.Name}}</span></div>
{{if .Account.DisplayName}}<div>Display name: <span class="text-white">{{.Account.DisplayName}}</span></div>{{end}}
{{if .Account.Description}}<div>Description: <span class="text-white">{{.Account.Description}}</span></div>{{end}}
<div>Created: <span class="text-white">{{shortTime .Account.CreatedAt}}</span></div>
<div class="text-gray-500 mt-2">This is a relay-only account. It has no mailbox or IMAP access.
Authenticate via SMTP using the username and password above.</div>
</div>
</div>
<!-- Send-as patterns -->
<div class="card">
<div class="text-sm font-semibold text-gray-300 mb-2">Permitted sender addresses</div>
<div class="text-xs text-gray-400 mb-3">
Define which From addresses this relay account may use. Use
<span style="font-family:monospace;color:#93c5fd">*@{{.Domain.Name}}</span>
to allow any address at the domain, or specify exact addresses.
</div>
{{if .SendAs}}
<div style="margin-bottom:.75rem">
{{range .SendAs}}
<div style="display:flex;align-items:center;justify-content:space-between;padding:.375rem .5rem;background:#111827;border-radius:.25rem;margin-bottom:.375rem">
<span style="font-family:monospace;font-size:.75rem;color:#93c5fd">{{.Pattern}}</span>
<form method="POST" action="/admin/domains/{{$.Domain.ID}}/relayaccount/{{$.Account.ID}}/sendas/{{.ID}}/delete" style="margin:0">
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
<button type="submit" class="btn btn-danger btn-sm" style="padding:.125rem .5rem;font-size:.7rem">Remove</button>
</form>
</div>
{{end}}
</div>
{{else}}
<div class="text-xs text-gray-500 mb-3">No sender patterns configured. This account cannot send yet.</div>
{{end}}
<form method="POST" action="/admin/domains/{{.Domain.ID}}/relayaccount/{{.Account.ID}}/sendas" style="display:flex;gap:.5rem;align-items:flex-end">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="field" style="margin:0;flex:1">
<label style="font-size:.7rem">Pattern (exact email or *@domain.com)</label>
<input type="text" name="pattern" required placeholder="sender@{{.Domain.Name}} or *@{{.Domain.Name}}" maxlength="255">
</div>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
</div>
<!-- IP whitelist -->
<div class="card">
<div class="text-sm font-semibold text-gray-300 mb-2">Allowed source IPs</div>
<div class="text-xs text-gray-400 mb-3">
Connections from these IPs may send as any permitted sender address above
<span style="color:#d1d5db">without SMTP authentication</span>.
Accepts plain IPs or CIDR ranges.
</div>
{{if .IPs}}
<div style="margin-bottom:.75rem">
{{range .IPs}}
<div style="display:flex;align-items:center;justify-content:space-between;padding:.375rem .5rem;background:#111827;border-radius:.25rem;margin-bottom:.375rem">
<div>
<span style="font-family:monospace;font-size:.75rem;color:#6ee7b7">{{.CIDR}}</span>
{{if .Description}}<span style="font-size:.7rem;color:#6b7280;margin-left:.5rem">{{.Description}}</span>{{end}}
</div>
<form method="POST" action="/admin/domains/{{$.Domain.ID}}/relayaccount/{{$.Account.ID}}/ip/{{.ID}}/delete" style="margin:0">
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
<button type="submit" class="btn btn-danger btn-sm" style="padding:.125rem .5rem;font-size:.7rem">Remove</button>
</form>
</div>
{{end}}
</div>
{{else}}
<div class="text-xs text-gray-500 mb-3">No IP rules. Authentication always required.</div>
{{end}}
<form method="POST" action="/admin/domains/{{.Domain.ID}}/relayaccount/{{.Account.ID}}/ip" style="display:flex;gap:.5rem;align-items:flex-end">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="field" style="margin:0;flex:1">
<label style="font-size:.7rem">IP or CIDR</label>
<input type="text" name="cidr" required placeholder="10.0.0.1 or 10.0.0.0/24" maxlength="50">
</div>
<div class="field" style="margin:0;flex:1">
<label style="font-size:.7rem">Description (optional)</label>
<input type="text" name="description" placeholder="App server" maxlength="255">
</div>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
</div>
</div>
</div>
{{end}}
+14 -43
View File
@@ -6,7 +6,6 @@
<h1 class="text-xl font-bold text-white">{{.U.Email}}</h1> <h1 class="text-xl font-bold text-white">{{.U.Email}}</h1>
{{if .U.Enabled}}<span class="badge badge-green">active</span>{{else}}<span class="badge badge-red">disabled</span>{{end}} {{if .U.Enabled}}<span class="badge badge-green">active</span>{{else}}<span class="badge badge-red">disabled</span>{{end}}
{{if .U.Admin}}<span class="badge badge-yellow">admin</span>{{else if .U.DomainAdmin}}<span class="badge badge-gray">domain admin</span>{{end}} {{if .U.Admin}}<span class="badge badge-yellow">admin</span>{{else if .U.DomainAdmin}}<span class="badge badge-gray">domain admin</span>{{end}}
{{if .U.IsRelay}}<span class="badge badge-blue">relay</span>{{end}}
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem">
@@ -21,13 +20,11 @@
<label>Display name</label> <label>Display name</label>
<input type="text" name="display_name" value="{{.U.DisplayName}}" maxlength="255"> <input type="text" name="display_name" value="{{.U.DisplayName}}" maxlength="255">
</div> </div>
{{if not .U.IsRelay}}
<div class="field"> <div class="field">
<label>Quota (MB)</label> <label>Quota (MB)</label>
<input type="number" name="quota_mb" value="{{mb .U.QuotaBytes}}" min="0" max="1048576"> <input type="number" name="quota_mb" value="{{mb .U.QuotaBytes}}" min="0" max="1048576">
</div> </div>
{{end}} <div style="display:flex;gap:1.5rem;margin-bottom:.875rem">
<div style="display:flex;gap:1.5rem;margin-bottom:.875rem;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:.375rem;cursor:pointer;margin:0"> <label style="display:flex;align-items:center;gap:.375rem;cursor:pointer;margin:0">
<input type="checkbox" name="enabled" value="1" {{if .U.Enabled}}checked{{end}} style="width:auto"> <input type="checkbox" name="enabled" value="1" {{if .U.Enabled}}checked{{end}} style="width:auto">
<span class="text-sm">Enabled</span> <span class="text-sm">Enabled</span>
@@ -45,7 +42,6 @@
</form> </form>
</div> </div>
<!-- Change password -->
<div class="card"> <div class="card">
<div class="text-sm font-semibold text-gray-300 mb-3">Change password</div> <div class="text-sm font-semibold text-gray-300 mb-3">Change password</div>
<form method="POST" action="/admin/users/{{.U.ID}}/password"> <form method="POST" action="/admin/users/{{.U.ID}}/password">
@@ -57,25 +53,6 @@
<button type="submit" class="btn btn-primary btn-sm">Set password</button> <button type="submit" class="btn btn-primary btn-sm">Set password</button>
</form> </form>
</div> </div>
<!-- Relay mode toggle -->
<div class="card">
<div class="text-sm font-semibold text-gray-300 mb-2">Relay account mode</div>
<div class="text-xs text-gray-400 mb-3">
Relay accounts authenticate via SMTP only — no IMAP mailboxes, no webmail.
They can send email as their own address or any permitted send-as pattern below.
</div>
<form method="POST" action="/admin/users/{{.U.ID}}/relay/toggle">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
{{if .U.IsRelay}}
<input type="hidden" name="relay" value="0">
<button type="submit" class="btn btn-danger btn-sm">Disable relay mode</button>
{{else}}
<input type="hidden" name="relay" value="1">
<button type="submit" class="btn btn-primary btn-sm">Enable relay mode</button>
{{end}}
</form>
</div>
</div> </div>
<!-- Right column --> <!-- Right column -->
@@ -85,32 +62,27 @@
<div class="text-xs space-y-1.5 text-gray-400"> <div class="text-xs space-y-1.5 text-gray-400">
<div>Email: <span class="text-white">{{.U.Email}}</span></div> <div>Email: <span class="text-white">{{.U.Email}}</span></div>
<div>Domain: <span class="text-white">{{.U.DomainName}}</span></div> <div>Domain: <span class="text-white">{{.U.DomainName}}</span></div>
{{if not .U.IsRelay}}
<div>Used: <span class="text-white">{{humanBytes .U.UsedBytes}}</span> <div>Used: <span class="text-white">{{humanBytes .U.UsedBytes}}</span>
of <span class="text-white">{{if .U.QuotaBytes}}{{humanBytes .U.QuotaBytes}}{{else}}unlimited{{end}}</span></div> of <span class="text-white">{{if .U.QuotaBytes}}{{humanBytes .U.QuotaBytes}}{{else}}unlimited{{end}}</span></div>
{{end}}
<div>Created: <span class="text-white">{{shortTime .U.CreatedAt}}</span></div> <div>Created: <span class="text-white">{{shortTime .U.CreatedAt}}</span></div>
<div>Last login: <span class="text-white">{{if isZero .U.LastLogin}}never{{else}}{{shortTime .U.LastLogin}}{{end}}</span></div> <div>Last login: <span class="text-white">{{if isZero .U.LastLogin}}never{{else}}{{shortTime .U.LastLogin}}{{end}}</span></div>
<div>MFA: <span class="text-white">{{if .U.MFAEnabled}}enabled{{else}}disabled{{end}}</span></div> <div>MFA: <span class="text-white">{{if .U.MFAEnabled}}enabled{{else}}disabled{{end}}</span></div>
<div>Type: <span class="text-white">{{if .U.IsRelay}}relay (SMTP only){{else}}mailbox{{end}}</span></div>
</div> </div>
</div> </div>
{{if .U.IsRelay}} <!-- Email aliases -->
<!-- Send-as patterns -->
<div class="card"> <div class="card">
<div class="text-sm font-semibold text-gray-300 mb-2">Permitted sender addresses</div> <div class="text-sm font-semibold text-gray-300 mb-2">Email aliases</div>
<div class="text-xs text-gray-400 mb-3"> <div class="text-xs text-gray-400 mb-3">
This account can always send as its own address. Add patterns to allow additional Alias addresses delivered to this account. The user may also send as any alias via SMTP.
sender addresses. Use <span style="font-family:monospace;color:#93c5fd">*@domain.com</span>
to allow any address at a domain.
</div> </div>
{{if .SendAs}} {{if .Aliases}}
<div style="margin-bottom:.75rem"> <div style="margin-bottom:.75rem">
{{range .SendAs}} {{range .Aliases}}
<div style="display:flex;align-items:center;justify-content:space-between;padding:.375rem .5rem;background:#111827;border-radius:.25rem;margin-bottom:.375rem"> <div style="display:flex;align-items:center;justify-content:space-between;padding:.375rem .5rem;background:#111827;border-radius:.25rem;margin-bottom:.375rem">
<span style="font-family:monospace;font-size:.75rem;color:#93c5fd">{{.Pattern}}</span> <span style="font-family:monospace;font-size:.75rem;color:#93c5fd">{{.AliasEmail}}</span>
<form method="POST" action="/admin/users/{{$.U.ID}}/relay/{{.ID}}/delete" style="margin:0"> <form method="POST" action="/admin/users/{{$.U.ID}}/alias/{{.ID}}/delete" style="margin:0"
onsubmit="return confirm('Remove alias {{.AliasEmail}}?')">
<input type="hidden" name="_csrf" value="{{$.CSRF}}"> <input type="hidden" name="_csrf" value="{{$.CSRF}}">
<button type="submit" class="btn btn-danger btn-sm" style="padding:.125rem .5rem;font-size:.7rem">Remove</button> <button type="submit" class="btn btn-danger btn-sm" style="padding:.125rem .5rem;font-size:.7rem">Remove</button>
</form> </form>
@@ -118,22 +90,21 @@
{{end}} {{end}}
</div> </div>
{{else}} {{else}}
<div class="text-xs text-gray-500 mb-3">No additional patterns configured. Only own address allowed.</div> <div class="text-xs text-gray-500 mb-3">No aliases configured.</div>
{{end}} {{end}}
<form method="POST" action="/admin/users/{{.U.ID}}/relay/sendas" style="display:flex;gap:.5rem;align-items:flex-end"> <form method="POST" action="/admin/users/{{.U.ID}}/alias" style="display:flex;gap:.5rem;align-items:flex-end">
<input type="hidden" name="_csrf" value="{{.CSRF}}"> <input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="field" style="margin:0;flex:1"> <div class="field" style="margin:0;flex:1">
<label style="font-size:.7rem">Pattern (email or *@domain.com)</label> <label style="font-size:.7rem">Alias email address</label>
<input type="text" name="pattern" required placeholder="newsletter@example.com or *@example.com" maxlength="255"> <input type="email" name="alias_email" required maxlength="320" placeholder="alias@{{.U.DomainName}}">
</div> </div>
<button type="submit" class="btn btn-primary btn-sm">Add</button> <button type="submit" class="btn btn-primary btn-sm">Add</button>
</form> </form>
</div> </div>
{{end}}
<div class="card" style="border:1px solid #7f1d1d"> <div class="card" style="border:1px solid #7f1d1d">
<div class="text-sm font-semibold text-red-400 mb-2">Delete user</div> <div class="text-sm font-semibold text-red-400 mb-2">Delete user</div>
<div class="text-xs text-gray-400 mb-3">Permanently deletes this account and all associated data. Cannot be undone.</div> <div class="text-xs text-gray-400 mb-3">Permanently deletes the user account, all mailboxes, and all messages. Cannot be undone.</div>
<form method="POST" action="/admin/users/{{.U.ID}}/delete" <form method="POST" action="/admin/users/{{.U.ID}}/delete"
onsubmit="return confirm('Delete user {{.U.Email}} and all their data?')"> onsubmit="return confirm('Delete user {{.U.Email}} and all their data?')">
<input type="hidden" name="_csrf" value="{{.CSRF}}"> <input type="hidden" name="_csrf" value="{{.CSRF}}">
-5
View File
@@ -40,10 +40,6 @@
<input type="checkbox" name="domain_admin" value="1" id="da" style="width:auto"> <input type="checkbox" name="domain_admin" value="1" id="da" style="width:auto">
<label for="da" style="margin:0;cursor:pointer">Domain admin</label> <label for="da" style="margin:0;cursor:pointer">Domain admin</label>
</div> </div>
<div class="field" style="margin:0;display:flex;align-items:center;gap:.5rem;padding-top:1.25rem">
<input type="checkbox" name="relay" value="1" id="relay" style="width:auto">
<label for="relay" style="margin:0;cursor:pointer">Relay account (SMTP only)</label>
</div>
</div> </div>
</form> </form>
</div> </div>
@@ -76,7 +72,6 @@
<td> <td>
{{if .Admin}}<span class="badge badge-yellow">admin</span> {{if .Admin}}<span class="badge badge-yellow">admin</span>
{{else if .DomainAdmin}}<span class="badge badge-gray">domain admin</span> {{else if .DomainAdmin}}<span class="badge badge-gray">domain admin</span>
{{else if .IsRelay}}<span class="badge badge-blue">relay</span>
{{else}}<span class="badge badge-gray">user</span>{{end}} {{else}}<span class="badge badge-gray">user</span>{{end}}
</td> </td>
<td class="text-gray-400 text-xs">{{humanBytes .UsedBytes}} / {{if .QuotaBytes}}{{humanBytes .QuotaBytes}}{{else}}unlimited{{end}}</td> <td class="text-gray-400 text-xs">{{humanBytes .UsedBytes}} / {{if .QuotaBytes}}{{humanBytes .QuotaBytes}}{{else}}unlimited{{end}}</td>