298 lines
11 KiB
Go
298 lines
11 KiB
Go
// 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"
|
|
"crypto/x509"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/smtp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/ini.v1"
|
|
"mailgoserver/internal/db"
|
|
"mailgoserver/internal/mailstore"
|
|
"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
|
|
// Quarantined is true for a local delivery that landed in Junk rather than INBOX.
|
|
// Only ever set by smtpserver's local-delivery path (always false for outbound
|
|
// relay results) — it's the signal Data() uses to keep this message's body in the
|
|
// admin log despite content-logging otherwise being off by default, so a spam/
|
|
// malicious report can actually be reviewed.
|
|
Quarantined bool
|
|
}
|
|
|
|
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), and as
|
|
// the domain part of SendBounce's mailer-daemon@ From address.
|
|
Hostname string
|
|
Logger *toolbox.Logger
|
|
|
|
// Mailstore backs SendBounce's local-delivery shortcut (straight into a bounce
|
|
// recipient's own INBOX when they're a mailbox this server hosts, no SMTP
|
|
// round-trip needed). Set by main.go once mailstore.New has run — relay.New runs
|
|
// before that, so this is assigned afterward rather than threaded through the
|
|
// constructor. Nil-safe: SendBounce falls back to relaying out when unset.
|
|
Mailstore *mailstore.Store
|
|
|
|
// port overrides mxPort for tests only (a real MX is always 25) — lets a test spin
|
|
// up a local TLS-capable SMTP stand-in on an ephemeral port and point trySend at it
|
|
// directly, unreachable via net.LookupMX. Zero (the normal, non-test case) means
|
|
// "use mxPort".
|
|
port int
|
|
// rootCAs overrides the system trust store for tests only — lets a test present a
|
|
// cert signed by a throwaway test CA and exercise the *real* verified-first-try
|
|
// path (not just the InsecureSkipVerify fallback) without needing a
|
|
// system-trusted cert. nil (the normal, non-test case) means "use the system pool".
|
|
rootCAs *x509.CertPool
|
|
// mtaSTSCheck overrides mtaSTSEnforced for tests only — lets a test simulate a
|
|
// domain publishing (or not publishing) an MTA-STS enforce policy without a real
|
|
// outbound HTTPS call. nil (the normal, non-test case) means "use the real
|
|
// mtaSTSEnforced, which does a live HTTPS lookup."
|
|
mtaSTSCheck func(domain string) bool
|
|
}
|
|
|
|
func (r *Relay) targetPort() int {
|
|
if r.port != 0 {
|
|
return r.port
|
|
}
|
|
return mxPort
|
|
}
|
|
|
|
// 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, domain, 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)
|
|
}
|
|
|
|
// errStartTLSVerifyFailed wraps a STARTTLS failure that happened during real
|
|
// certificate verification specifically (as opposed to e.g. the server rejecting the
|
|
// STARTTLS command itself) — trySend uses this to decide whether a fresh,
|
|
// unverified-fallback attempt is worth making.
|
|
var errStartTLSVerifyFailed = errors.New("starttls certificate verification failed")
|
|
|
|
// trySend delivers once over a fresh connection, verifying the receiving MTA's
|
|
// certificate. If that specifically fails at the STARTTLS handshake, it retries once
|
|
// more on a brand-new connection with verification skipped — a failed TLS handshake
|
|
// leaves the original connection unusable, so this can't be a retry on the same conn.
|
|
// Falling back (rather than hard-failing) matches real-world opportunistic-STARTTLS
|
|
// behavior: plenty of legitimate small mail servers present certs that don't chain
|
|
// cleanly, and bouncing over that with no compensating control would hurt
|
|
// deliverability for no real security gain. The fallback still logs clearly, so a
|
|
// domain that's actually being MITM'd leaves a trail — unless domain itself publishes
|
|
// an MTA-STS policy in "enforce" mode (see mtasts.go), in which case it explicitly
|
|
// opted into strict behavior and gets a real failure instead of the silent downgrade.
|
|
func (r *Relay) trySend(host, domain, mailFrom string, rcpts []string, content string) (string, error) {
|
|
resp, err := r.trySendOnce(host, mailFrom, rcpts, content, false)
|
|
if err != nil && errors.Is(err, errStartTLSVerifyFailed) {
|
|
checkEnforced := mtaSTSEnforced
|
|
if r.mtaSTSCheck != nil {
|
|
checkEnforced = r.mtaSTSCheck
|
|
}
|
|
if checkEnforced(domain) {
|
|
r.Logger.Warning("STARTTLS certificate verification failed for %s, and %s publishes an MTA-STS enforce policy — not falling back to an unverified connection: %v", host, domain, err)
|
|
return resp, err
|
|
}
|
|
r.Logger.Warning("STARTTLS certificate verification failed for %s, retrying delivery over an unverified (still encrypted) connection: %v", host, err)
|
|
resp, err = r.trySendOnce(host, mailFrom, rcpts, content, true)
|
|
if err == nil {
|
|
r.Logger.Warning("Delivered to %s over an unverified TLS connection after verified STARTTLS failed", host)
|
|
}
|
|
}
|
|
return resp, err
|
|
}
|
|
|
|
func (r *Relay) trySendOnce(host, mailFrom string, rcpts []string, content string, skipVerify bool) (string, error) {
|
|
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(r.targetPort())), 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. Certificate verification is real (ServerName set, no blanket
|
|
// InsecureSkipVerify) unless this is the unverified-fallback attempt — see
|
|
// trySend above for why/when that happens.
|
|
if ok, _ := c.Extension("STARTTLS"); ok {
|
|
tlsConfig := &tls.Config{ServerName: host, InsecureSkipVerify: skipVerify, RootCAs: r.rootCAs}
|
|
if err := c.StartTLS(tlsConfig); err != nil {
|
|
if !skipVerify {
|
|
return "", fmt.Errorf("%w: %v", errStartTLSVerifyFailed, err)
|
|
}
|
|
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
|
|
}
|