Files
mailgosend/internal/smtp/inbound.go
T
2026-05-24 17:15:48 +00:00

332 lines
9.9 KiB
Go

package smtp
import (
"context"
"fmt"
"io"
"log"
"net"
"net/mail"
"strings"
"time"
gosmtp "github.com/emersion/go-smtp"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/dkim"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/dmarcreport"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/spam"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/spf"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/storage"
)
// InboundBackend implements gosmtp.Backend for port 25 (receive from internet).
type InboundBackend struct {
deps *Deps
}
func (b *InboundBackend) NewSession(c *gosmtp.Conn) (gosmtp.Session, error) {
clientIP, _, _ := net.SplitHostPort(c.Conn().RemoteAddr().String())
// Check IP ban.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var banned bool
var err error
if b.deps.Cfg.BruteMaxTries > 0 && b.deps.Brute != nil {
banned, err = b.deps.Brute.IsBanned(ctx, clientIP)
if err != nil {
log.Printf("[smtp/inbound] ban check error %s: %v", clientIP, err)
}
}
if banned {
return nil, fmt.Errorf("connection refused")
}
return &InboundSession{
deps: b.deps,
clientIP: net.ParseIP(clientIP),
log: func(f string, a ...any) { log.Printf("[smtp/inbound] "+f, a...) },
}, nil
}
// InboundSession handles one inbound SMTP connection.
type InboundSession struct {
deps *Deps
clientIP net.IP
from string
fromDomain string
rcpts []string // validated local recipients (email addresses)
rcptUsers []int64 // corresponding user IDs
dmarcDomains []int64 // domain IDs whose dmarc_rua matched a recipient
spfResult spf.Result
log func(string, ...any)
}
// AuthPlain is not used on port 25; always return error.
func (s *InboundSession) AuthPlain(username, password string) error {
return fmt.Errorf("AUTH not supported on port 25")
}
func (s *InboundSession) Mail(from string, opts *gosmtp.MailOptions) error {
if from == "" {
// Bounce messages use empty envelope sender — allow.
s.from = ""
s.fromDomain = ""
return nil
}
addr, err := mail.ParseAddress(from)
if err != nil {
return &gosmtp.SMTPError{Code: 501, EnhancedCode: gosmtp.EnhancedCode{5, 1, 7}, Message: "invalid sender address"}
}
s.from = addr.Address
at := strings.LastIndex(s.from, "@")
if at < 0 {
return &gosmtp.SMTPError{Code: 501, EnhancedCode: gosmtp.EnhancedCode{5, 1, 7}, Message: "invalid sender domain"}
}
s.fromDomain = strings.ToLower(s.from[at+1:])
// SPF check (async, best-effort).
if s.deps.Cfg.SpamCheckSPF && s.clientIP != nil && s.fromDomain != "" {
s.spfResult, _ = spf.Check(s.clientIP, s.fromDomain)
}
return nil
}
func (s *InboundSession) Rcpt(to string, opts *gosmtp.RcptOptions) error {
addr, err := mail.ParseAddress(to)
if err != nil {
return &gosmtp.SMTPError{Code: 501, EnhancedCode: gosmtp.EnhancedCode{5, 1, 3}, Message: "invalid recipient"}
}
email := strings.ToLower(addr.Address)
at := strings.LastIndex(email, "@")
if at < 0 {
return &gosmtp.SMTPError{Code: 501, EnhancedCode: gosmtp.EnhancedCode{5, 1, 3}, Message: "invalid recipient"}
}
domain := email[at+1:]
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Must be a local domain.
local, err := s.deps.DB.IsLocalDomain(ctx, domain)
if err != nil || !local {
return &gosmtp.SMTPError{Code: 550, EnhancedCode: gosmtp.EnhancedCode{5, 1, 2}, Message: "relay access denied"}
}
// Resolve to a user (handles aliases).
user, err := s.deps.DB.ResolveEmail(ctx, email)
if err != nil {
return &gosmtp.SMTPError{Code: 451, EnhancedCode: gosmtp.EnhancedCode{4, 3, 0}, Message: "temporary lookup error"}
}
if user == nil || !user.Enabled {
// Check if this is a DMARC monitoring address for any domain.
dom, domErr := s.deps.DB.GetDomainByDMARCRua(ctx, email)
if domErr == nil && dom != nil {
s.dmarcDomains = append(s.dmarcDomains, dom.ID)
return nil // accepted for DMARC report processing
}
return &gosmtp.SMTPError{Code: 550, EnhancedCode: gosmtp.EnhancedCode{5, 1, 1}, Message: "user unknown"}
}
s.rcpts = append(s.rcpts, email)
s.rcptUsers = append(s.rcptUsers, user.ID)
return nil
}
func (s *InboundSession) Data(r io.Reader) error {
if len(s.rcpts) == 0 {
return &gosmtp.SMTPError{Code: 503, Message: "no recipients"}
}
// Read message with size cap (already enforced by go-smtp, but be safe).
raw, err := io.ReadAll(io.LimitReader(r, s.deps.Cfg.MaxMessageSize+1))
if err != nil {
return fmt.Errorf("read data: %w", err)
}
if int64(len(raw)) > s.deps.Cfg.MaxMessageSize {
return &gosmtp.SMTPError{Code: 552, EnhancedCode: gosmtp.EnhancedCode{5, 3, 4}, Message: "message too large"}
}
// Parse headers for metadata.
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
if err != nil {
return &gosmtp.SMTPError{Code: 550, EnhancedCode: gosmtp.EnhancedCode{5, 6, 0}, Message: "malformed message"}
}
subject := decodeHeader(msg.Header.Get("Subject"))
fromHeader := msg.Header.Get("From")
fromAddr, fromName := parseFromHeader(fromHeader)
if fromAddr == "" && s.from != "" {
fromAddr = s.from
}
msgID := msg.Header.Get("Message-ID")
dateStr := msg.Header.Get("Date")
msgDate, _ := mail.ParseDate(dateStr)
if msgDate.IsZero() {
msgDate = time.Now().UTC()
}
// DKIM verification.
dkimValid := false
dkimPresent := strings.Contains(string(raw), "DKIM-Signature:")
if dkimPresent {
_, dkimErr := dkim.Verify(raw)
dkimValid = dkimErr == nil
}
// Spam scoring (per recipient — each has their own Bayesian model).
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Deliver to each local recipient.
for i, userID := range s.rcptUsers {
rcptEmail := s.rcpts[i]
// Build spam score params.
params := &spam.Params{
ClientIP: s.clientIP,
SenderDomain: s.fromDomain,
SPFResult: s.spfResult,
DKIMValid: dkimValid,
DKIMPresent: dkimPresent,
Subject: subject,
FromHeader: fromHeader,
RecipCount: len(s.rcpts),
HasDateHeader: dateStr != "",
HasMsgIDHeader: msgID != "",
}
spamResult := s.deps.Scorer.Score(ctx, userID, params)
// Choose target mailbox.
mboxType := models.MailboxInbox
if spamResult.IsSpam {
mboxType = models.MailboxSpam
s.log("message for %s scored %d (spam), delivering to Spam", rcptEmail, spamResult.Total)
}
mbox, err := s.deps.DB.GetMailboxByType(ctx, userID, mboxType)
if err != nil || mbox == nil {
// Fallback to INBOX.
mbox, err = s.deps.DB.GetMailboxByType(ctx, userID, models.MailboxInbox)
if err != nil || mbox == nil {
s.log("no inbox for user %d (%s): %v", userID, rcptEmail, err)
continue
}
}
incoming := &storage.IncomingMessage{
Raw: raw,
FromEmail: fromAddr,
FromName: fromName,
ToList: strings.Join(s.rcpts, ", "),
Subject: subject,
Date: msgDate,
MessageID: msgID,
SpamScore: spamResult.Total,
}
_, err = s.deps.Store.SaveIncoming(ctx, userID, mbox.ID, incoming)
if err != nil {
s.log("store message for %s: %v", rcptEmail, err)
return &gosmtp.SMTPError{Code: 451, EnhancedCode: gosmtp.EnhancedCode{4, 3, 0}, Message: "storage error"}
}
s.log("delivered to %s (spam=%v score=%d)", rcptEmail, spamResult.IsSpam, spamResult.Total)
}
// Process DMARC aggregate reports if any recipient was a monitoring address.
if len(s.dmarcDomains) > 0 {
s.processDMARCReport(ctx, raw)
}
return nil
}
// processDMARCReport parses and stores a DMARC aggregate report for each monitoring domain.
func (s *InboundSession) processDMARCReport(ctx context.Context, raw []byte) {
feedback, err := dmarcreport.ParseReportEmail(raw)
if err != nil {
s.log("dmarc report parse error from %s: %v", s.from, err)
return
}
for _, domainID := range s.dmarcDomains {
report := feedbackToModel(domainID, feedback)
if storeErr := s.deps.DB.SaveDMARCReport(ctx, report); storeErr != nil {
s.log("dmarc report store error (domain %d): %v", domainID, storeErr)
} else {
s.log("stored dmarc report from %s for domain %d (report_id=%s, records=%d)",
feedback.ReportMetadata.OrgName, domainID,
feedback.ReportMetadata.ReportID, len(feedback.Records))
}
}
}
// feedbackToModel converts a parsed DMARC Feedback to a models.DMARCReport.
func feedbackToModel(domainID int64, f *dmarcreport.Feedback) *models.DMARCReport {
report := &models.DMARCReport{
DomainID: domainID,
OrgName: f.ReportMetadata.OrgName,
OrgEmail: f.ReportMetadata.Email,
ReportID: f.ReportMetadata.ReportID,
DateBegin: f.ReportMetadata.DateRange.Begin,
DateEnd: f.ReportMetadata.DateRange.End,
PolicyDomain: f.PolicyPublished.Domain,
PolicyADKIM: f.PolicyPublished.ADKIM,
PolicyASPF: f.PolicyPublished.ASPF,
PolicyP: f.PolicyPublished.P,
PolicyPct: f.PolicyPublished.PCT,
}
for _, rec := range f.Records {
report.Records = append(report.Records, models.DMARCRecord{
SourceIP: rec.Row.SourceIP,
Count: rec.Row.Count,
Disposition: rec.Row.PolicyEvaluated.Disposition,
DKIMResult: rec.Row.PolicyEvaluated.DKIM,
SPFResult: rec.Row.PolicyEvaluated.SPF,
HeaderFrom: rec.Identifiers.HeaderFrom,
EnvelopeFrom: rec.Identifiers.EnvelopeFrom,
DKIMDomain: rec.AuthResults.DKIM.Domain,
DKIMSelector: rec.AuthResults.DKIM.Selector,
SPFDomain: rec.AuthResults.SPF.Domain,
})
}
return report
}
func (s *InboundSession) Reset() {
s.from = ""
s.fromDomain = ""
s.rcpts = s.rcpts[:0]
s.rcptUsers = s.rcptUsers[:0]
s.dmarcDomains = s.dmarcDomains[:0]
s.spfResult = spf.ResultNone
}
func (s *InboundSession) Logout() error { return nil }
// ---- Helpers ----
func parseFromHeader(h string) (addr, name string) {
if h == "" {
return "", ""
}
a, err := mail.ParseAddress(h)
if err != nil {
return h, ""
}
return a.Address, a.Name
}
func decodeHeader(h string) string {
// mime.WordDecoder would be imported from mime package. Keep it simple.
return h
}