// Package relay resolves MX records and delivers mail directly to recipient servers // (no smart-host relay), mirroring email_server/email_relay.py. package relay import ( "crypto/tls" "fmt" "net" "net/smtp" "strconv" "strings" "time" "gopkg.in/ini.v1" "mailgoserver/internal/db" "mailgoserver/internal/toolbox" ) const mxPort = 25 // Result mirrors one entry of email_relay's per-recipient results list. type Result struct { Recipient string RecipientType string // "to" | "cc" | "bcc" Status string // "success" | "failed" ErrorCode string ErrorMessage string ServerResponse string } type Relay struct { DB *db.DB Timeout time.Duration // Hostname is used as the outbound EHLO/HELO identity, mirroring // email_relay.py's self.hostname (helo_hostname, falling back to hostname). Hostname string Logger *toolbox.Logger } // New builds a Relay from settings.ini. Unlike email_relay.py (which reads // relay_timeout from the wrong [Server] section and so always falls back to its // hardcoded default of 30s), this reads the value from [Relay] as the config file's // own comments say it should — the approved bug fix. func New(database *db.DB, cfg *ini.File, logger *toolbox.Logger) *Relay { timeoutSecs := cfg.Section("Relay").Key("RELAY_TIMEOUT").MustInt(30) hostname := cfg.Section("Server").Key("helo_hostname").String() if hostname == "" { hostname = cfg.Section("Server").Key("HOSTNAME").MustString("localhost") } return &Relay{DB: database, Timeout: time.Duration(timeoutSecs) * time.Second, Hostname: hostname, Logger: logger} } // prepareEmailForRecipient mirrors email_relay._prepare_email_for_recipient: strips any // Bcc header line from the header block only, leaves the body untouched. func prepareEmailForRecipient(content string) string { idx := strings.Index(content, "\r\n\r\n") sep := "\r\n\r\n" if idx < 0 { idx = strings.Index(content, "\n\n") sep = "\n\n" if idx < 0 { idx = len(content) sep = "\r\n\r\n" } } headerBlock, body := content[:idx], content[idx+len(sep):] var kept []string for _, line := range strings.Split(headerBlock, "\n") { trimmed := strings.TrimRight(line, "\r") if strings.HasPrefix(strings.ToLower(strings.TrimSpace(trimmed)), "bcc:") { continue } kept = append(kept, trimmed) } return strings.Join(kept, "\r\n") + "\r\n\r\n" + body } // RelayEmailAsync mirrors email_relay.relay_email_async: TO/CC recipients are grouped // by domain and delivered in one shared SMTP transaction per domain; each BCC recipient // gets its own transaction. MX hosts are tried once each, in preference order, with // opportunistic STARTTLS. func (r *Relay) RelayEmailAsync(mailFrom string, rcptTos []string, content string, recipientTypes []string) []Result { if len(recipientTypes) != len(rcptTos) { recipientTypes = make([]string, len(rcptTos)) for i := range recipientTypes { recipientTypes[i] = "to" } } type group struct{ to, cc []string } domainGroups := map[string]*group{} var bccList []string for i, rcpt := range rcptTos { typ := recipientTypes[i] if typ == "bcc" { bccList = append(bccList, rcpt) continue } domain := domainOf(rcpt) g, ok := domainGroups[domain] if !ok { g = &group{} domainGroups[domain] = g } if typ == "cc" { g.cc = append(g.cc, rcpt) } else { g.to = append(g.to, rcpt) } } var results []Result prepared := prepareEmailForRecipient(content) for domain, g := range domainGroups { all := append(append([]string{}, g.to...), g.cc...) if len(all) == 0 { continue } status, serverResp, errCode, errMsg := r.deliverToDomain(domain, mailFrom, all, prepared) for _, rcpt := range g.to { results = append(results, Result{Recipient: rcpt, RecipientType: "to", Status: status, ErrorCode: errCode, ErrorMessage: errMsg, ServerResponse: serverResp}) } for _, rcpt := range g.cc { results = append(results, Result{Recipient: rcpt, RecipientType: "cc", Status: status, ErrorCode: errCode, ErrorMessage: errMsg, ServerResponse: serverResp}) } } for _, bcc := range bccList { status, serverResp, errCode, errMsg := r.deliverToDomain(domainOf(bcc), mailFrom, []string{bcc}, prepared) results = append(results, Result{Recipient: bcc, RecipientType: "bcc", Status: status, ErrorCode: errCode, ErrorMessage: errMsg, ServerResponse: serverResp}) } return results } func domainOf(address string) string { if i := strings.LastIndex(address, "@"); i >= 0 { return strings.ToLower(address[i+1:]) } return "" } // deliverToDomain resolves MX hosts for domain and tries each in preference order once, // mirroring the MX-iteration loop in relay_email_async. func (r *Relay) deliverToDomain(domain, mailFrom string, rcpts []string, content string) (status, serverResponse, errorCode, errorMessage string) { mxRecords, err := net.LookupMX(domain) if err != nil || len(mxRecords) == 0 { return "failed", "", "MX", fmt.Sprintf("MX lookup failed for %s: %v", domain, err) } var lastErr error for _, mx := range mxRecords { host := strings.TrimSuffix(mx.Host, ".") resp, err := r.trySend(host, mailFrom, rcpts, content) if err == nil { return "success", resp, "", "" } lastErr = err r.Logger.Warning("Relay to %s (%s) failed: %v", host, domain, err) } return "failed", "", "RELAY", fmt.Sprintf("%v", lastErr) } func (r *Relay) trySend(host, mailFrom string, rcpts []string, content string) (string, error) { conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(mxPort)), r.Timeout) if err != nil { return "", err } conn.SetDeadline(time.Now().Add(r.Timeout)) defer conn.Close() c, err := smtp.NewClient(conn, host) if err != nil { return "", err } defer c.Close() if err := c.Hello(r.Hostname); err != nil { return "", err } // Opportunistic STARTTLS: upgrade if offered, send in plaintext otherwise — // mirrors relay_email_async's "if starttls in extensions" check with no hard // requirement, and no strict certificate verification since arbitrary receiving // MTAs commonly present certs that don't chain cleanly (matches the Python code, // which never configures certificate verification for this opportunistic hop). if ok, _ := c.Extension("STARTTLS"); ok { tlsConfig := &tls.Config{ServerName: host, InsecureSkipVerify: true} if err := c.StartTLS(tlsConfig); err != nil { return "", err } } if err := c.Mail(mailFrom); err != nil { return "", err } for _, rcpt := range rcpts { if err := c.Rcpt(rcpt); err != nil { return "", err } } w, err := c.Data() if err != nil { return "", err } if _, err := w.Write([]byte(content)); err != nil { return "", err } if err := w.Close(); err != nil { return "", err } _ = c.Quit() return "250 OK", nil }