332 lines
9.9 KiB
Go
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
|
|
}
|