admin dash - added relay
This commit is contained in:
@@ -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
|
||||
|
||||
+71
-8
@@ -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,
|
||||
|
||||
+253
-26
@@ -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)
|
||||
}
|
||||
|
||||
+48
-8
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+62
-34
@@ -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...)
|
||||
}
|
||||
|
||||
|
||||
+291
-58
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
<a href="/admin/" class="nav-link">Dashboard</a>
|
||||
<a href="/admin/domains" class="nav-link">Domains</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/bans" class="nav-link">IP Bans</a>
|
||||
<a href="/admin/events" class="nav-link">Security Events</a>
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
<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;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?')">
|
||||
<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>
|
||||
@@ -203,7 +203,7 @@
|
||||
{{else}}
|
||||
<div class="text-xs text-gray-500 mb-3">No IP relay rules configured.</div>
|
||||
{{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}}">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
|
||||
<div class="field" style="margin:0">
|
||||
@@ -272,6 +272,77 @@
|
||||
</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>
|
||||
.dns-record {
|
||||
background: #111827;
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -6,7 +6,6 @@
|
||||
<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.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 style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem">
|
||||
@@ -21,13 +20,11 @@
|
||||
<label>Display name</label>
|
||||
<input type="text" name="display_name" value="{{.U.DisplayName}}" maxlength="255">
|
||||
</div>
|
||||
{{if not .U.IsRelay}}
|
||||
<div class="field">
|
||||
<label>Quota (MB)</label>
|
||||
<input type="number" name="quota_mb" value="{{mb .U.QuotaBytes}}" min="0" max="1048576">
|
||||
</div>
|
||||
{{end}}
|
||||
<div style="display:flex;gap:1.5rem;margin-bottom:.875rem;flex-wrap:wrap">
|
||||
<div style="display:flex;gap:1.5rem;margin-bottom:.875rem">
|
||||
<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">
|
||||
<span class="text-sm">Enabled</span>
|
||||
@@ -45,7 +42,6 @@
|
||||
</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/users/{{.U.ID}}/password">
|
||||
@@ -57,25 +53,6 @@
|
||||
<button type="submit" class="btn btn-primary btn-sm">Set password</button>
|
||||
</form>
|
||||
</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>
|
||||
|
||||
<!-- Right column -->
|
||||
@@ -85,32 +62,27 @@
|
||||
<div class="text-xs space-y-1.5 text-gray-400">
|
||||
<div>Email: <span class="text-white">{{.U.Email}}</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>
|
||||
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>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>Type: <span class="text-white">{{if .U.IsRelay}}relay (SMTP only){{else}}mailbox{{end}}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .U.IsRelay}}
|
||||
<!-- Send-as patterns -->
|
||||
<!-- Email aliases -->
|
||||
<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">
|
||||
This account can always send as its own address. Add patterns to allow additional
|
||||
sender addresses. Use <span style="font-family:monospace;color:#93c5fd">*@domain.com</span>
|
||||
to allow any address at a domain.
|
||||
Alias addresses delivered to this account. The user may also send as any alias via SMTP.
|
||||
</div>
|
||||
{{if .SendAs}}
|
||||
{{if .Aliases}}
|
||||
<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">
|
||||
<span style="font-family:monospace;font-size:.75rem;color:#93c5fd">{{.Pattern}}</span>
|
||||
<form method="POST" action="/admin/users/{{$.U.ID}}/relay/{{.ID}}/delete" style="margin:0">
|
||||
<span style="font-family:monospace;font-size:.75rem;color:#93c5fd">{{.AliasEmail}}</span>
|
||||
<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}}">
|
||||
<button type="submit" class="btn btn-danger btn-sm" style="padding:.125rem .5rem;font-size:.7rem">Remove</button>
|
||||
</form>
|
||||
@@ -118,22 +90,21 @@
|
||||
{{end}}
|
||||
</div>
|
||||
{{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}}
|
||||
<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}}">
|
||||
<div class="field" style="margin:0;flex:1">
|
||||
<label style="font-size:.7rem">Pattern (email or *@domain.com)</label>
|
||||
<input type="text" name="pattern" required placeholder="newsletter@example.com or *@example.com" maxlength="255">
|
||||
<label style="font-size:.7rem">Alias email address</label>
|
||||
<input type="email" name="alias_email" required maxlength="320" placeholder="alias@{{.U.DomainName}}">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<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-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"
|
||||
onsubmit="return confirm('Delete user {{.U.Email}} and all their data?')">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
|
||||
@@ -40,10 +40,6 @@
|
||||
<input type="checkbox" name="domain_admin" value="1" id="da" style="width:auto">
|
||||
<label for="da" style="margin:0;cursor:pointer">Domain admin</label>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
@@ -76,7 +72,6 @@
|
||||
<td>
|
||||
{{if .Admin}}<span class="badge badge-yellow">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}}
|
||||
</td>
|
||||
<td class="text-gray-400 text-xs">{{humanBytes .UsedBytes}} / {{if .QuotaBytes}}{{humanBytes .QuotaBytes}}{{else}}unlimited{{end}}</td>
|
||||
|
||||
Reference in New Issue
Block a user