diff --git a/internal/db/admin.go b/internal/db/admin.go index 5696fc8..f141b17 100644 --- a/internal/db/admin.go +++ b/internal/db/admin.go @@ -232,7 +232,7 @@ func (d *DB) DeleteUser(ctx context.Context, userID int64) error { func (d *DB) ListAllUsers(ctx context.Context) ([]*UserWithDomain, error) { rows, err := d.db.QueryContext(ctx, ` SELECT u.id, u.domain_id, u.username, u.email, u.display_name, - u.quota_bytes, u.used_bytes, u.enabled, u.admin, u.domain_admin, + u.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 diff --git a/internal/db/migrate.go b/internal/db/migrate.go index cfefa31..9a8ba83 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -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 = ` diff --git a/internal/db/relay.go b/internal/db/relay.go new file mode 100644 index 0000000..8d76f97 --- /dev/null +++ b/internal/db/relay.go @@ -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) +} diff --git a/internal/db/users.go b/internal/db/users.go index 76e7649..5f3eb42 100644 --- a/internal/db/users.go +++ b/internal/db/users.go @@ -13,7 +13,7 @@ import ( func (d *DB) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { row := d.db.QueryRowContext(ctx, ` SELECT id, domain_id, username, email, password_hash, display_name, - quota_bytes, used_bytes, enabled, admin, domain_admin, + 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, ) diff --git a/internal/models/models.go b/internal/models/models.go index b2a9444..3f1fc80 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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 { diff --git a/internal/smtp/submission.go b/internal/smtp/submission.go index e0d2ba1..5806e02 100644 --- a/internal/smtp/submission.go +++ b/internal/smtp/submission.go @@ -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 } diff --git a/internal/webadmin/handlers.go b/internal/webadmin/handlers.go index 6eebd32..9fadd0a 100644 --- a/internal/webadmin/handlers.go +++ b/internal/webadmin/handlers.go @@ -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 { diff --git a/internal/webadmin/server.go b/internal/webadmin/server.go index 7dcc219..a287683 100644 --- a/internal/webadmin/server.go +++ b/internal/webadmin/server.go @@ -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)) diff --git a/web/admin/templates/base.html b/web/admin/templates/base.html index ac9e248..0dc59aa 100644 --- a/web/admin/templates/base.html +++ b/web/admin/templates/base.html @@ -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} diff --git a/web/admin/templates/domain.html b/web/admin/templates/domain.html index 23470a7..6313041 100644 --- a/web/admin/templates/domain.html +++ b/web/admin/templates/domain.html @@ -163,6 +163,65 @@ + + +
+
IP Relay Rules
+
+ Allow specific IP addresses (or CIDR ranges) to submit email for this domain + without SMTP authentication. Optionally restrict to specific sender addresses. +
+ {{if .RelayIPRules}} +
+ + + + + + + + + + + {{range .RelayIPRules}} + + + + + + + {{end}} + +
IP / CIDRSender patternDescription
{{.CIDR}}{{.SenderPattern}}{{.Description}} +
+ + +
+
+
+ {{else}} +
No IP relay rules configured.
+ {{end}} +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
diff --git a/web/admin/templates/user.html b/web/admin/templates/user.html index 4dee1b7..cfbb317 100644 --- a/web/admin/templates/user.html +++ b/web/admin/templates/user.html @@ -6,11 +6,12 @@

{{.U.Email}}

{{if .U.Enabled}}active{{else}}disabled{{end}} {{if .U.Admin}}admin{{else if .U.DomainAdmin}}domain admin{{end}} + {{if .U.IsRelay}}relay{{end}}
- +
User settings
@@ -20,11 +21,13 @@
+ {{if not .U.IsRelay}}
-
+ {{end}} +
+ + +
+
Relay account mode
+
+ 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. +
+
+ + {{if .U.IsRelay}} + + + {{else}} + + + {{end}} +
+
- +
Account info
Email: {{.U.Email}}
Domain: {{.U.DomainName}}
+ {{if not .U.IsRelay}}
Used: {{humanBytes .U.UsedBytes}} of {{if .U.QuotaBytes}}{{humanBytes .U.QuotaBytes}}{{else}}unlimited{{end}}
+ {{end}}
Created: {{shortTime .U.CreatedAt}}
Last login: {{if isZero .U.LastLogin}}never{{else}}{{shortTime .U.LastLogin}}{{end}}
MFA: {{if .U.MFAEnabled}}enabled{{else}}disabled{{end}}
+
Type: {{if .U.IsRelay}}relay (SMTP only){{else}}mailbox{{end}}
+ {{if .U.IsRelay}} + +
+
Permitted sender addresses
+
+ This account can always send as its own address. Add patterns to allow additional + sender addresses. Use *@domain.com + to allow any address at a domain. +
+ {{if .SendAs}} +
+ {{range .SendAs}} +
+ {{.Pattern}} +
+ + +
+
+ {{end}} +
+ {{else}} +
No additional patterns configured. Only own address allowed.
+ {{end}} +
+ +
+ + +
+ +
+
+ {{end}} +
Delete user
-
Permanently deletes the user account, all mailboxes, and all messages. Cannot be undone.
+
Permanently deletes this account and all associated data. Cannot be undone.
diff --git a/web/admin/templates/users.html b/web/admin/templates/users.html index d2d5a10..fe2143e 100644 --- a/web/admin/templates/users.html +++ b/web/admin/templates/users.html @@ -40,6 +40,10 @@
+
+ + +
@@ -72,6 +76,7 @@ {{if .Admin}}admin {{else if .DomainAdmin}}domain admin + {{else if .IsRelay}}relay {{else}}user{{end}} {{humanBytes .UsedBytes}} / {{if .QuotaBytes}}{{humanBytes .QuotaBytes}}{{else}}unlimited{{end}}