first commit
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/toolbox"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// LogEmail mirrors EmailRelay.log_email: computes the overall status from per-recipient
|
||||
// results (relayed/partial/failed) and persists the EmailLog + EmailRecipientLog rows.
|
||||
func (r *Relay) LogEmail(cfg *ini.File, peerIP, mailFrom, toAddress, ccAddresses, bccAddresses, subject, emailHeaders, messageBody, messageID, username string, dkimSigned bool, results []Result) (int64, error) {
|
||||
overall := overallStatus(results)
|
||||
|
||||
logID, err := r.DB.InsertEmailLog(db.EmailLog{
|
||||
MessageID: messageID,
|
||||
Timestamp: toolbox.GetCurrentTime(cfg),
|
||||
PeerIP: peerIP,
|
||||
MailFrom: mailFrom,
|
||||
ToAddress: toAddress,
|
||||
CcAddresses: ccAddresses,
|
||||
BccAddresses: bccAddresses,
|
||||
Subject: subject,
|
||||
EmailHeaders: emailHeaders,
|
||||
MessageBody: messageBody,
|
||||
Status: overall,
|
||||
DKIMSigned: dkimSigned,
|
||||
Username: username,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
for _, res := range results {
|
||||
recipientType := res.RecipientType
|
||||
if recipientType == "" {
|
||||
recipientType = "to"
|
||||
}
|
||||
if err := r.DB.InsertEmailRecipientLog(db.EmailRecipientLog{
|
||||
EmailLogID: logID,
|
||||
Recipient: res.Recipient,
|
||||
RecipientType: recipientType,
|
||||
Status: res.Status,
|
||||
ErrorCode: res.ErrorCode,
|
||||
ErrorMessage: res.ErrorMessage,
|
||||
ServerResponse: res.ServerResponse,
|
||||
}); err != nil {
|
||||
r.Logger.Error("Failed to log recipient %s: %v", res.Recipient, err)
|
||||
}
|
||||
}
|
||||
|
||||
return logID, nil
|
||||
}
|
||||
|
||||
func overallStatus(results []Result) string {
|
||||
if len(results) == 0 {
|
||||
return "failed"
|
||||
}
|
||||
success, failed := 0, 0
|
||||
for _, res := range results {
|
||||
if res.Status == "success" {
|
||||
success++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case success > 0 && failed > 0:
|
||||
return "partial"
|
||||
case success > 0:
|
||||
return "relayed"
|
||||
default:
|
||||
return "failed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user