added relay

This commit is contained in:
2026-05-25 14:30:41 +00:00
parent 063b3b643f
commit 3d46ccde33
12 changed files with 599 additions and 37 deletions
+3 -2
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) {
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.quota_bytes, u.used_bytes, u.enabled, u.admin, u.domain_admin, u.is_relay,
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.QuotaBytes, &u.UsedBytes, &u.Enabled, &u.Admin, &u.DomainAdmin, &u.IsRelay,
&u.MFAEnabled, &u.CreatedAt, &lastLogin,
&u.DomainName,
)
@@ -277,6 +277,7 @@ type UserWithDomain struct {
Enabled bool
Admin bool
DomainAdmin bool
IsRelay bool
MFAEnabled bool
CreatedAt time.Time
LastLogin time.Time
+25
View File
@@ -17,6 +17,7 @@ type migration struct {
var migrations = []migration{
{1, schemav1},
{2, schemav2},
{3, schemav3},
}
// migrate applies any unapplied migrations in order.
@@ -337,6 +338,30 @@ 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 ----
const schemav3 = `
ALTER TABLE users ADD COLUMN is_relay BOOLEAN NOT NULL DEFAULT 0;
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
);
CREATE INDEX IF NOT EXISTS idx_relay_send_as_user ON relay_send_as(user_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 v2: DMARC monitoring ----
const schemav2 = `
+176
View File
@@ -0,0 +1,176 @@
package db
import (
"context"
"net"
"strings"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
)
// ---- Relay send-as (per user) ----
// GetRelaySendAs returns all allowed sender patterns for a relay user.
func (d *DB) GetRelaySendAs(ctx context.Context, userID 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)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*models.RelaySendAs
for rows.Next() {
r := &models.RelaySendAs{}
if err := rows.Scan(&r.ID, &r.UserID, &r.Pattern, &r.CreatedAt); err != nil {
return nil, err
}
out = append(out, r)
}
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 {
_, err := d.db.ExecContext(ctx,
"INSERT OR IGNORE INTO relay_send_as (user_id, pattern) VALUES (?,?)", userID, 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 {
_, err := d.db.ExecContext(ctx,
"DELETE FROM relay_send_as WHERE id=? AND user_id=?", id, userID)
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
}
rows, err := d.db.QueryContext(ctx,
"SELECT pattern FROM relay_send_as WHERE user_id=?", userID)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var pattern string
if err := rows.Scan(&pattern); err != nil {
return false, err
}
if matchSenderPattern(pattern, fromEmail) {
return true, nil
}
}
return false, rows.Err()
}
// ---- Relay IP rules (per domain) ----
// GetRelayIPRules returns all IP relay rules for a domain.
func (d *DB) GetRelayIPRules(ctx context.Context, domainID int64) ([]*models.RelayIPRule, error) {
rows, err := d.db.QueryContext(ctx, `
SELECT id, domain_id, cidr, sender_pattern, description, created_at
FROM relay_ip_rules WHERE domain_id=? ORDER BY id`, domainID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*models.RelayIPRule
for rows.Next() {
r := &models.RelayIPRule{}
if err := rows.Scan(&r.ID, &r.DomainID, &r.CIDR, &r.SenderPattern, &r.Description, &r.CreatedAt); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// AddRelayIPRule adds an IP relay rule for a domain.
func (d *DB) AddRelayIPRule(ctx context.Context, domainID int64, cidr, senderPattern, description string) error {
_, err := d.db.ExecContext(ctx, `
INSERT INTO relay_ip_rules (domain_id, cidr, sender_pattern, description)
VALUES (?,?,?,?)`, domainID, cidr, senderPattern, description)
return err
}
// DeleteRelayIPRule removes an IP relay rule. domainID is verified.
func (d *DB) DeleteRelayIPRule(ctx context.Context, id, domainID int64) error {
_, err := d.db.ExecContext(ctx,
"DELETE FROM relay_ip_rules WHERE id=? AND domain_id=?", id, domainID)
return err
}
// CheckIPRelay returns true when the client IP 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, `
SELECT r.cidr, r.sender_pattern
FROM relay_ip_rules r
JOIN domains d ON d.id = r.domain_id
WHERE d.enabled = 1`)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var cidr, pattern string
if err := rows.Scan(&cidr, &pattern); err != nil {
return false, err
}
if ipInCIDR(cidr, clientIP) && matchSenderPattern(pattern, senderEmail) {
return true, nil
}
}
return false, rows.Err()
}
// ---- Helpers ----
// matchSenderPattern checks if email matches pattern.
// "*@domain.com" matches any address at that domain.
// Anything else is an exact case-insensitive match.
func matchSenderPattern(pattern, email string) bool {
pattern = strings.ToLower(strings.TrimSpace(pattern))
email = strings.ToLower(strings.TrimSpace(email))
if pattern == email {
return true
}
if strings.HasPrefix(pattern, "*@") {
domain := pattern[2:]
at := strings.LastIndex(email, "@")
return at > 0 && email[at+1:] == domain
}
return false
}
// ipInCIDR returns true when ip falls within the cidr range.
// cidr may be a plain IP (treated as single-host) 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)
}
_, network, err := net.ParseCIDR(cidr)
if err != nil {
return false
}
return network.Contains(parsedIP)
}
+11 -5
View File
@@ -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,
quota_bytes, used_bytes, enabled, admin, domain_admin, is_relay,
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,
quota_bytes, used_bytes, enabled, admin, domain_admin, is_relay,
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,
quota_bytes, used_bytes, enabled, admin, domain_admin, is_relay,
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.Admin, &u.DomainAdmin, &u.IsRelay,
&mfaEnc, &u.MFAEnabled, &rcEnc,
&u.CreatedAt, &lastLogin,
)
@@ -152,6 +152,12 @@ 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)
return err
}
// ---- private ----
func scanUser(row *sql.Row) (*models.User, error) {
@@ -162,7 +168,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.Admin, &u.DomainAdmin, &u.IsRelay,
&mfaEnc, &u.MFAEnabled, &rcEnc,
&u.CreatedAt, &lastLogin,
)
+23
View File
@@ -69,6 +69,7 @@ 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
@@ -309,6 +310,28 @@ type SpamToken struct {
HamCount int64
}
// ---- Relay ----
// RelaySendAs is a permitted sender pattern for a relay user account.
// Pattern may be an exact address or a wildcard (*@domain.com).
type RelaySendAs struct {
ID int64
UserID int64
Pattern string
CreatedAt time.Time
}
// RelayIPRule allows unauthenticated SMTP relay from a specific IP/CIDR
// for a given sender pattern, per domain.
type RelayIPRule struct {
ID int64
DomainID int64
CIDR string // IP or CIDR notation
SenderPattern string // exact email or *@domain.com
Description string
CreatedAt time.Time
}
// ---- Compose helpers (not persisted directly) ----
type Attachment_Upload struct {
+50 -22
View File
@@ -47,11 +47,12 @@ func (b *SubmissionBackend) NewSession(c *gosmtp.Conn) (gosmtp.Session, error) {
// SubmissionSession handles one authenticated submission connection.
type SubmissionSession struct {
deps *Deps
clientIP string
user *models.User // set after AUTH
from string
rcpts []string
deps *Deps
clientIP string
user *models.User // set after AUTH
ipRelayMode bool // set when IP relay authorization succeeds (no AUTH)
from string
rcpts []string
}
func (s *SubmissionSession) AuthPlain(username, password string) error {
@@ -81,19 +82,49 @@ func (s *SubmissionSession) AuthPlain(username, password string) error {
}
func (s *SubmissionSession) Mail(from string, opts *gosmtp.MailOptions) error {
if s.user == nil {
return &gosmtp.SMTPError{Code: 530, Message: "authentication required"}
}
addr, err := mail.ParseAddress(from)
if err != nil {
return &gosmtp.SMTPError{Code: 501, EnhancedCode: gosmtp.EnhancedCode{5, 1, 7}, Message: "invalid sender"}
}
// Sender must be user's own address or an alias they own.
fromEmail := strings.ToLower(addr.Address)
if s.user == nil {
// Unauthenticated — check IP relay rules.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
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)
return &gosmtp.SMTPError{Code: 451, EnhancedCode: gosmtp.EnhancedCode{4, 3, 0}, Message: "temporary error"}
}
if !allowed {
return &gosmtp.SMTPError{Code: 530, Message: "authentication required"}
}
s.ipRelayMode = true
s.from = addr.Address
return nil
}
if s.user.IsRelay {
// Relay account — validate sender against allowed patterns.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
allowed, err := s.deps.DB.IsRelaySenderAllowed(ctx, s.user.ID, fromEmail)
if err != nil {
log.Printf("[smtp/submission] relay sender check error for %s: %v", s.user.Email, 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"}
}
s.from = addr.Address
return nil
}
// Regular user — sender must be own email or an alias.
if !strings.EqualFold(fromEmail, s.user.Email) {
// Check aliases.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -108,7 +139,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 {
if s.user == nil && !s.ipRelayMode {
return &gosmtp.SMTPError{Code: 530, Message: "authentication required"}
}
@@ -122,7 +153,7 @@ func (s *SubmissionSession) Rcpt(to string, opts *gosmtp.RcptOptions) error {
}
func (s *SubmissionSession) Data(r io.Reader) error {
if s.user == nil {
if s.user == nil && !s.ipRelayMode {
return &gosmtp.SMTPError{Code: 530, Message: "authentication required"}
}
if len(s.rcpts) == 0 {
@@ -137,7 +168,6 @@ func (s *SubmissionSession) Data(r io.Reader) error {
return &gosmtp.SMTPError{Code: 552, EnhancedCode: gosmtp.EnhancedCode{5, 3, 4}, Message: "message too large"}
}
// Parse for basic header validation.
_, err = mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return &gosmtp.SMTPError{Code: 550, EnhancedCode: gosmtp.EnhancedCode{5, 6, 0}, Message: "malformed message"}
@@ -146,22 +176,17 @@ func (s *SubmissionSession) Data(r io.Reader) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// DKIM-sign the message if the sender's domain has keys configured.
senderDomain := domainOf(s.from)
raw = s.signDKIM(ctx, raw, senderDomain)
msgID := extractMsgID(raw)
// Queue each recipient for delivery.
// For local recipients we could deliver directly, but queuing is simpler and
// provides a consistent audit trail.
dom, err := s.deps.DB.GetDomain(ctx, senderDomain)
var domainID int64
if err == nil && dom != nil {
domainID = dom.ID
}
// Encrypt raw for queue storage using a global (non-user) key.
queueKey, err := s.deps.Crypt.DeriveKeyGlobal("queue")
if err != nil {
return fmt.Errorf("queue key: %w", err)
@@ -180,8 +205,10 @@ func (s *SubmissionSession) Data(r io.Reader) error {
log.Printf("[smtp/submission] queued %s → %s", s.from, rcpt)
}
// Also save a copy in sender's Sent folder.
s.saveSentCopy(ctx, raw)
// Save a Sent copy only for regular (non-relay) authenticated users.
if s.user != nil && !s.user.IsRelay {
s.saveSentCopy(ctx, raw)
}
return nil
}
@@ -189,6 +216,7 @@ func (s *SubmissionSession) Data(r io.Reader) error {
func (s *SubmissionSession) Reset() {
s.from = ""
s.rcpts = s.rcpts[:0]
s.ipRelayMode = false
}
func (s *SubmissionSession) Logout() error { return nil }
+177 -4
View File
@@ -8,6 +8,7 @@ import (
"log"
"net"
"net/http"
"net/mail"
"strconv"
"strings"
"time"
@@ -107,10 +108,11 @@ func (s *Server) domainsCreate(w http.ResponseWriter, r *http.Request) {
type domainDetailData struct {
basePage
Domain *models.Domain
Users []*models.User
Hostname string
Domain *models.Domain
Users []*models.User
Hostname string
DMARCReportCount int
RelayIPRules []*models.RelayIPRule
// DNS records (what to configure)
MXRecord string
DKIMRecord string
@@ -160,6 +162,7 @@ func (s *Server) domainDetail(w http.ResponseWriter, r *http.Request) {
}
dmarcCount, _ := s.deps.DB.DMARCReportCount(ctx, id)
relayRules, _ := s.deps.DB.GetRelayIPRules(ctx, id)
s.render(w, "domain", domainDetailData{
basePage: s.newBase(r, flash, errMsg),
@@ -167,6 +170,7 @@ 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),
@@ -403,6 +407,8 @@ 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
@@ -437,6 +443,15 @@ 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)
@@ -453,7 +468,8 @@ func (s *Server) usersCreate(w http.ResponseWriter, r *http.Request) {
type userDetailData struct {
basePage
U *db.UserWithDomain
U *db.UserWithDomain
SendAs []*models.RelaySendAs
}
func (s *Server) userDetail(w http.ResponseWriter, r *http.Request) {
@@ -479,10 +495,16 @@ 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)
}
flash, errMsg := flashFrom(r)
s.render(w, "user", userDetailData{
basePage: s.newBase(r, flash, errMsg),
U: found,
SendAs: sendAs,
})
}
@@ -848,6 +870,157 @@ func generateToken(n int) (string, error) {
return fmt.Sprintf("%x", buf), nil
}
// ---- Relay user management ----
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, "")
}
func (s *Server) userRelayAddSendAs(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")
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.")
return
}
if err := s.deps.DB.AddRelaySendAs(ctx, id, pattern); err != nil {
log.Printf("[admin] add relay sendas: %v", err)
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Failed to add pattern.")
return
}
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "Pattern added.", "")
}
func (s *Server) userRelayDeleteSendAs(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")
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 {
log.Printf("[admin] delete relay sendas: %v", err)
}
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "Pattern removed.", "")
}
// ---- Relay IP rules (per domain) ----
func (s *Server) domainRelayAdd(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")
cidr := strings.TrimSpace(r.FormValue("cidr"))
senderPattern := strings.ToLower(strings.TrimSpace(r.FormValue("sender_pattern")))
description := strings.TrimSpace(r.FormValue("description"))
if !validCIDR(cidr) {
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Invalid IP or CIDR notation.")
return
}
if !validRelayPattern(senderPattern) {
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Invalid sender pattern. Use exact email or *@domain.com.")
return
}
if len(description) > 255 {
description = description[:255]
}
if err := s.deps.DB.AddRelayIPRule(ctx, id, cidr, senderPattern, description); err != nil {
log.Printf("[admin] add relay ip rule: %v", err)
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Failed to add rule.")
return
}
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "IP relay rule added.", "")
}
func (s *Server) domainRelayDelete(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")
rid, err := strconv.ParseInt(r.PathValue("rid"), 10, 64)
if err != nil || rid <= 0 {
http.NotFound(w, r)
return
}
if err := s.deps.DB.DeleteRelayIPRule(ctx, rid, id); err != nil {
log.Printf("[admin] delete relay ip rule: %v", err)
}
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "Rule removed.", "")
}
// validRelayPattern accepts exact email addresses or *@domain.com wildcards.
func validRelayPattern(pattern string) bool {
if pattern == "" || len(pattern) > 255 {
return false
}
if strings.HasPrefix(pattern, "*@") {
return validDomain(pattern[2:])
}
// Must be a valid, bare email address.
addr, err := mail.ParseAddress(pattern)
return err == nil && strings.EqualFold(addr.Address, pattern)
}
// validCIDR accepts a plain IP or CIDR notation.
func validCIDR(s string) bool {
if s == "" || len(s) > 50 {
return false
}
if strings.Contains(s, "/") {
_, _, err := net.ParseCIDR(s)
return err == nil
}
return net.ParseIP(s) != nil
}
// validDomain accepts simple dot-separated labels (a-z0-9 and hyphens).
func validDomain(s string) bool {
if len(s) < 3 || len(s) > 253 {
+5
View File
@@ -93,6 +93,11 @@ 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))
m.HandleFunc("GET /admin/queue", s.require(s.queueList))
m.HandleFunc("POST /admin/queue/{id}/retry", s.require(s.queueRetry))
+1
View File
@@ -29,6 +29,7 @@
.badge-red{background:#7f1d1d;color:#fca5a5}
.badge-yellow{background:#78350f;color:#fcd34d}
.badge-gray{background:#374151;color:#9ca3af}
.badge-blue{background:#1e3a5f;color:#93c5fd}
.flash-ok{background:#064e3b;border:1px solid #065f46;color:#6ee7b7;padding:.75rem 1rem;border-radius:.375rem;margin-bottom:1rem;font-size:.875rem}
.flash-err{background:#7f1d1d;border:1px solid #991b1b;color:#fca5a5;padding:.75rem 1rem;border-radius:.375rem;margin-bottom:1rem;font-size:.875rem}
</style>
+59
View File
@@ -163,6 +163,65 @@
<!-- DNS check results injected here -->
<div id="dns-check-{{.Domain.ID}}" style="display:none;margin-top:1.25rem;padding-top:1rem;border-top:1px solid #374151"></div>
</div>
<!-- IP Relay Rules -->
<div class="card">
<div class="text-sm font-semibold text-gray-300 mb-2">IP Relay Rules</div>
<div class="text-xs text-gray-400 mb-3">
Allow specific IP addresses (or CIDR ranges) to submit email for this domain
without SMTP authentication. Optionally restrict to specific sender addresses.
</div>
{{if .RelayIPRules}}
<div style="margin-bottom:.75rem;overflow:auto">
<table style="font-size:.7rem;width:100%;border-collapse:collapse">
<thead>
<tr style="color:#6b7280;text-align:left;border-bottom:1px solid #374151">
<th style="padding:.25rem .5rem">IP / CIDR</th>
<th style="padding:.25rem .5rem">Sender pattern</th>
<th style="padding:.25rem .5rem">Description</th>
<th style="padding:.25rem .5rem"></th>
</tr>
</thead>
<tbody>
{{range .RelayIPRules}}
<tr style="border-bottom:1px solid #1f2937">
<td style="padding:.25rem .5rem;font-family:monospace;color:#93c5fd">{{.CIDR}}</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;text-align:right">
<form method="POST" action="/admin/domains/{{$.Domain.ID}}/relay/{{.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>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{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">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
<div class="field" style="margin:0">
<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">
<label style="font-size:.7rem">Sender pattern</label>
<input type="text" name="sender_pattern" required placeholder="*@{{.Domain.Name}}" maxlength="255">
</div>
</div>
<div class="field" style="margin:.5rem 0 .75rem">
<label style="font-size:.7rem">Description (optional)</label>
<input type="text" name="description" placeholder="Internal mail server" maxlength="255">
</div>
<button type="submit" class="btn btn-primary btn-sm">Add rule</button>
</form>
</div>
</div>
</div>
+64 -4
View File
@@ -6,11 +6,12 @@
<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">
<!-- Edit user -->
<!-- Left column -->
<div>
<div class="card">
<div class="text-sm font-semibold text-gray-300 mb-3">User settings</div>
@@ -20,11 +21,13 @@
<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>
<div style="display:flex;gap:1.5rem;margin-bottom:.875rem">
{{end}}
<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">
<input type="checkbox" name="enabled" value="1" {{if .U.Enabled}}checked{{end}} style="width:auto">
<span class="text-sm">Enabled</span>
@@ -54,26 +57,83 @@
<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>
<!-- Info + danger -->
<!-- Right column -->
<div>
<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>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 -->
<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">
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.
</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/users/{{$.U.ID}}/relay/{{.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 additional patterns configured. Only own address allowed.</div>
{{end}}
<form method="POST" action="/admin/users/{{.U.ID}}/relay/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 (email or *@domain.com)</label>
<input type="text" name="pattern" required placeholder="newsletter@example.com or *@example.com" maxlength="255">
</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 the user account, all mailboxes, and all messages. Cannot be undone.</div>
<div class="text-xs text-gray-400 mb-3">Permanently deletes this account and all associated data. 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}}">
+5
View File
@@ -40,6 +40,10 @@
<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>
@@ -72,6 +76,7 @@
<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>