admin dash - added relay

This commit is contained in:
2026-05-25 17:29:15 +00:00
parent 3d46ccde33
commit fdda0cae34
14 changed files with 1063 additions and 198 deletions
+62 -34
View File
@@ -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...)
}