566 lines
24 KiB
Go
566 lines
24 KiB
Go
package smtpserver
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"github.com/microcosm-cc/bluemonday"
|
|
|
|
"mailgoserver/internal/db"
|
|
"mailgoserver/internal/dkim"
|
|
"mailgoserver/internal/mailstore"
|
|
"mailgoserver/internal/mailview"
|
|
"mailgoserver/internal/relay"
|
|
)
|
|
|
|
// ErrVirusDetected marks RouteAndDeliver's virus-scan rejection — the message was
|
|
// never logged or delivered to anyone (distinguished from a per-recipient delivery
|
|
// failure, which is still "accepted" and reported via the returned results instead).
|
|
// Wrapped with the scanner's detected signature; a caller formats its own
|
|
// protocol-specific rejection message from err.Error() (e.g. Session.Data prefixes
|
|
// "Message rejected: ").
|
|
var ErrVirusDetected = errors.New("virus detected")
|
|
|
|
// RouteAndDeliver signs, classifies every recipient (across both localRcpts and
|
|
// relayRcpts) as to/cc/bcc from the message's own headers, delivers to localRcpts
|
|
// (spam/DKIM/DMARC checks + filter rules + attachment storage opt-in), enqueues
|
|
// relayRcpts onto the outbound relay queue, sends a bounce for any partial failure,
|
|
// and writes the email log row — the shared tail of Session.Data (SMTP DATA) and
|
|
// JMAP EmailSubmission/set (internal/jmap), so both entry points route through
|
|
// exactly one implementation of "accept a fully-formed message and get it where it's
|
|
// going," rather than duplicating DKIM/DMARC/rspamd/filter-rule logic that must stay
|
|
// in sync.
|
|
//
|
|
// The caller has already decided which envelope recipients are local vs relay (SMTP
|
|
// already knows this from Rcpt() time; JMAP submission resolves it itself via
|
|
// Mailstore.ResolveRecipient) — RouteAndDeliver only needs the split, not how it was
|
|
// derived. peerIP/username are used only for logging/attribution and the
|
|
// attachment-storage opt-in check; a caller with no real TCP peer (JMAP submission)
|
|
// passes peerIP "".
|
|
//
|
|
// err is non-nil only for a whole-message rejection (currently just
|
|
// ErrVirusDetected) — a per-recipient delivery failure is never an error return, it's
|
|
// reported via results/allSucceeded/anySucceeded instead, matching how a real MTA
|
|
// splits a multi-recipient transaction's outcome.
|
|
func (b *Backend) RouteAndDeliver(mailFrom string, localRcpts, relayRcpts []string, raw []byte, peerIP, username string) (logID int64, results []relay.Result, allSucceeded, anySucceeded bool, err error) {
|
|
// unsafe.String views content directly over raw's own backing array instead of a
|
|
// real copy — see the original comment on this in Session.Data's history: matters
|
|
// on the small-RAM hosts this server targets, and raw is never mutated below, only
|
|
// read (via parseMessage's own bytes.NewReader).
|
|
content := unsafe.String(unsafe.SliceData(raw), len(raw))
|
|
|
|
messageID := extractMessageID(content, b.HeloHostname)
|
|
senderDomain := domainOfAddr(mailFrom)
|
|
|
|
var customHeaders [][2]string
|
|
if senderDomain != "" {
|
|
customHeaders, _ = b.DKIM.GetActiveCustomHeaders(senderDomain)
|
|
}
|
|
customHeaders = append(customHeaders,
|
|
[2]string{"X-Originating-IP", "[" + peerIP + "]"},
|
|
[2]string{"X-Mailer", "NetBro Mail Server 1.0"},
|
|
[2]string{"X-Priority", "3"},
|
|
)
|
|
|
|
allRcpts := make([]string, 0, len(localRcpts)+len(relayRcpts))
|
|
allRcpts = append(allRcpts, localRcpts...)
|
|
allRcpts = append(allRcpts, relayRcpts...)
|
|
rebuilt := ensureRequiredHeaders(content, messageID, allRcpts, mailFrom, customHeaders)
|
|
|
|
signedContent := rebuilt
|
|
dkimSigned := false
|
|
if senderDomain != "" {
|
|
signedContent = b.DKIM.Sign(rebuilt, senderDomain)
|
|
dkimSigned = signedContent != rebuilt
|
|
}
|
|
|
|
// Virus scanning runs once per message (unlike rspamd's per-recipient-domain
|
|
// concept below in deliverLocally) — a virus is present or not regardless of who
|
|
// it's addressed to, so one scan covers both the relay and local-delivery paths
|
|
// that split further down. Hard-rejects the whole transaction on a positive
|
|
// match; fails OPEN on a scanner error/unreachable clamd (never blocks mail on a
|
|
// scanner outage) and is off entirely unless explicitly enabled.
|
|
if b.Cfg.Section("Mailstore").Key("virus_scan_enabled").MustBool(false) {
|
|
addr := b.Cfg.Section("Mailstore").Key("clamd_address").MustString("127.0.0.1:3310")
|
|
if infected, signature, scanErr := mailstore.ScanVirus(addr, []byte(signedContent)); scanErr != nil {
|
|
b.Logger.Error("virus scan unreachable/errored, delivering normally: %v", scanErr)
|
|
} else if infected {
|
|
b.Logger.Warning("rejected infected message from %s (%s)", mailFrom, signature)
|
|
return 0, nil, false, false, fmt.Errorf("%w (%s)", ErrVirusDetected, signature)
|
|
}
|
|
}
|
|
|
|
rebuiltHeaders := existingHeaders(rebuilt)
|
|
toHeader := rebuiltHeaders["to"]
|
|
ccHeader := rebuiltHeaders["cc"]
|
|
subject := rebuiltHeaders["subject"]
|
|
// The message's own From: header, not the bare envelope address — used only for
|
|
// what's cached/displayed, never for delivery/auth decisions, which stay on
|
|
// mailFrom throughout. Falls back to the envelope address if missing/empty.
|
|
fromHeader := rebuiltHeaders["from"]
|
|
if fromHeader == "" {
|
|
fromHeader = mailFrom
|
|
}
|
|
|
|
// Attachment storage: only if the authenticated sender or whitelisted IP opted in.
|
|
storeMessage := false
|
|
if sender, _ := b.DB.GetSenderByEmail(mailFrom); sender != nil && sender.StoreMessageContent {
|
|
storeMessage = true
|
|
} else if wl, _ := b.DB.GetWhitelistedIP(peerIP, senderDomain); wl != nil && wl.StoreMessageContent {
|
|
storeMessage = true
|
|
}
|
|
|
|
// wantAttachments=storeMessage: decoding every attachment fully into memory is
|
|
// only useful when they're about to be written to disk below.
|
|
parsed, parseErr := parseMessage(raw, storeMessage)
|
|
|
|
type savedAttachment struct {
|
|
Filename, ContentType, FilePath string
|
|
Size int64
|
|
}
|
|
var toSave []savedAttachment
|
|
if storeMessage && parseErr == nil && len(parsed.Attachments) > 0 {
|
|
usernameOrIP := username
|
|
if usernameOrIP == "" && peerIP != "" {
|
|
usernameOrIP = sanitizePathSegment(peerIP, ":")
|
|
} else {
|
|
usernameOrIP = sanitizePathSegment(usernameOrIP, "/\\")
|
|
}
|
|
storagePath := attachmentStoragePath(b.AttachmentsBasePath, senderDomain, usernameOrIP, time.Now())
|
|
if err := os.MkdirAll(storagePath, 0o755); err == nil {
|
|
prefix := cleanMessageIDPrefix(messageID)
|
|
for _, a := range parsed.Attachments {
|
|
filename := prefix + "_" + sanitizeAttachmentFilename(a.Filename)
|
|
fullPath := filepath.Join(storagePath, filename)
|
|
if err := os.WriteFile(fullPath, a.Data, 0o644); err == nil {
|
|
toSave = append(toSave, savedAttachment{Filename: a.Filename, ContentType: a.ContentType, FilePath: fullPath, Size: int64(len(a.Data))})
|
|
} else {
|
|
b.Logger.Error("Failed to write attachment %s: %v", filename, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Classify each recipient as to/cc/bcc by presence in the To/Cc headers —
|
|
// anything not literally present in either is inferred BCC. Local and relay
|
|
// recipients are classified separately (both against the same headers) since the
|
|
// caller already split them; the classification itself doesn't care which group
|
|
// a recipient is in.
|
|
toList := parseAddressList(toHeader)
|
|
ccList := parseAddressList(ccHeader)
|
|
classify := func(rcpts []string) []string {
|
|
types := make([]string, len(rcpts))
|
|
for i, rcpt := range rcpts {
|
|
lower := strings.ToLower(rcpt)
|
|
switch {
|
|
case containsStr(toList, lower):
|
|
types[i] = "to"
|
|
case containsStr(ccList, lower):
|
|
types[i] = "cc"
|
|
default:
|
|
types[i] = "bcc"
|
|
}
|
|
}
|
|
return types
|
|
}
|
|
localTypes := classify(localRcpts)
|
|
relayTypes := classify(relayRcpts)
|
|
|
|
// Relay recipients are never delivered inline here — that would block the
|
|
// caller's response on however long the recipient domain's MX takes to answer.
|
|
// Instead a "queued" placeholder Result is recorded now and the real attempt
|
|
// happens later via EnqueueForDelivery (below, once logID exists) + the
|
|
// background worker in internal/relay/queue.go.
|
|
if len(relayRcpts) > 0 {
|
|
if limited, rlErr := b.domainSendRateLimited(senderDomain); rlErr != nil {
|
|
b.Logger.Error("send-rate-limit check for domain %s: %v", senderDomain, rlErr)
|
|
for i, rcpt := range relayRcpts {
|
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: relayTypes[i], Status: "queued"})
|
|
}
|
|
} else if limited {
|
|
for i, rcpt := range relayRcpts {
|
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: relayTypes[i], Status: "failed", ErrorCode: "450", ErrorMessage: "Sending rate limit exceeded for domain " + senderDomain + ", try again later"})
|
|
}
|
|
relayRcpts, relayTypes = nil, nil
|
|
} else {
|
|
for i, rcpt := range relayRcpts {
|
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: relayTypes[i], Status: "queued"})
|
|
}
|
|
}
|
|
}
|
|
if len(localRcpts) > 0 {
|
|
results = append(results, b.deliverLocally(mailFrom, peerIP, localRcpts, localTypes, signedContent, messageID, subject, fromHeader)...)
|
|
}
|
|
|
|
// "queued" is neither a known success nor a known failure yet — the worker
|
|
// resolves it later (and bounces then, on genuine final failure). Only count
|
|
// actual failures here so the immediate bounce-on-partial-failure block below
|
|
// doesn't fire for a message that's simply still in flight.
|
|
var failed []relay.Result
|
|
for _, res := range results {
|
|
if res.Status != "success" && res.Status != "queued" {
|
|
failed = append(failed, res)
|
|
}
|
|
}
|
|
allSucceeded = len(results) > 0 && len(failed) == 0
|
|
anySucceeded = len(results) > len(failed)
|
|
|
|
// A single response can't express "delivered to some recipients, not others" —
|
|
// rejecting the whole transaction here would make a connecting SMTP server's own
|
|
// retry logic re-deliver to the recipients that already succeeded. So: accept
|
|
// (the caller's job) and bounce the failed subset back to the sender instead,
|
|
// exactly like a real MTA splitting a multi-recipient transaction's outcome.
|
|
// Never sent for a *total* failure (the caller rejects outright instead) or for a
|
|
// null-sender message or a currently-blacklisted peer, so a delivery failure
|
|
// never becomes a free "yes, that mailbox doesn't exist" oracle for abuse.
|
|
if len(failed) > 0 && anySucceeded && mailFrom != "" {
|
|
if blacklisted, _ := b.DB.IsIPBlacklisted(peerIP); !blacklisted {
|
|
if sbErr := b.Relay.SendBounce(mailFrom, subject, messageID, failed); sbErr != nil {
|
|
b.Logger.Error("send bounce to %s: %v", mailFrom, sbErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
var emailHeaders string
|
|
if parseErr == nil {
|
|
emailHeaders = strings.Join(parsed.HeaderLines, "\n")
|
|
}
|
|
|
|
// Privacy default: only headers (and Subject) go into the admin-visible log,
|
|
// never the message itself — unless this sender/IP explicitly opted in via
|
|
// "Store Full Message Content" (storeMessage above), or the message was
|
|
// quarantined to Junk for at least one recipient, in which case an admin
|
|
// genuinely needs to see it to judge a spam/abuse report.
|
|
storeContent := storeMessage
|
|
if !storeContent {
|
|
for _, res := range results {
|
|
if res.Quarantined {
|
|
storeContent = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
loggedBody := ""
|
|
if storeContent {
|
|
loggedBody = signedContent
|
|
}
|
|
|
|
logID, logErr := b.Relay.LogEmail(b.Cfg, peerIP, mailFrom, toHeader, ccHeader, "", subject, emailHeaders, loggedBody, messageID, username, dkimSigned, results)
|
|
if logErr != nil {
|
|
b.Logger.Error("Failed to log email: %v", logErr)
|
|
} else {
|
|
for _, a := range toSave {
|
|
if aErr := b.DB.InsertEmailAttachment(db.EmailAttachment{
|
|
EmailLogID: logID, Filename: a.Filename, ContentType: a.ContentType, FilePath: a.FilePath, Size: a.Size,
|
|
}); aErr != nil {
|
|
b.Logger.Error("Failed to record attachment %s: %v", a.Filename, aErr)
|
|
}
|
|
}
|
|
if len(relayRcpts) > 0 {
|
|
if eErr := b.Relay.EnqueueForDelivery(logID, mailFrom, relayRcpts, relayTypes, signedContent); eErr != nil {
|
|
b.Logger.Error("Failed to enqueue relay delivery: %v", eErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
return logID, results, allSucceeded, anySucceeded, nil
|
|
}
|
|
|
|
// deliverLocally runs the inbound DKIM/SPF/DMARC/spam checks once for the message
|
|
// (they don't vary per recipient) and stores it into each resolved local mailbox,
|
|
// producing one relay.Result per recipient so it can be merged into the same
|
|
// LogEmail/allSucceeded logic as relay results. Each recipient is resolved fresh via
|
|
// Mailstore.ResolveRecipient — the same resolution Session.Rcpt() itself already runs
|
|
// at RCPT time — rather than a pre-built cache, since RouteAndDeliver's callers may
|
|
// not have one (a JMAP submission has no RCPT phase at all); not a hot path that
|
|
// needs the micro-optimization of avoiding one extra DB query per recipient.
|
|
func (b *Backend) deliverLocally(mailFrom, peerIP string, rcpts, types []string, signedContent, messageID, subject, fromDisplay string) []relay.Result {
|
|
senderDomain := domainOfAddr(mailFrom)
|
|
dkimPass := senderDomain != "" && dkim.VerifyInbound(signedContent, senderDomain)
|
|
spfPass := mailstore.CheckSPF(mailFrom, peerIP)
|
|
heuristicScore := mailstore.SpamScore(peerIP, map[string]string{"subject": subject}, dkimPass, spfPass)
|
|
rejectScore := b.Cfg.Section("Mailstore").Key("spam_reject_score").MustInt(5)
|
|
rspamdEnabled := b.Cfg.Section("Rspamd").Key("enabled").MustBool(false)
|
|
rspamdURL := b.Cfg.Section("Rspamd").Key("url").MustString("http://127.0.0.1:11333")
|
|
rspamdRejectScore := b.Cfg.Section("Rspamd").Key("reject_score").MustInt(15)
|
|
|
|
// Parsed once for every local recipient (not per-recipient — same message body
|
|
// for all of them) so filter rules can match on body text / attachment presence
|
|
// without every mailbox needing its own parse pass.
|
|
bodyText, hasAttachment := "", "no"
|
|
if parsedForRules, err := mailview.Parse(strings.NewReader(signedContent)); err == nil {
|
|
bodyText = parsedForRules.TextBody
|
|
if bodyText == "" && parsedForRules.HTMLBody != "" {
|
|
bodyText = bluemonday.StrictPolicy().Sanitize(parsedForRules.HTMLBody)
|
|
}
|
|
if len(parsedForRules.Attachments) > 0 {
|
|
hasAttachment = "yes"
|
|
}
|
|
}
|
|
|
|
enforceDKIM := b.Cfg.Section("Mailstore").Key("enforce_dkim").MustBool(true)
|
|
enforceSPF := b.Cfg.Section("Mailstore").Key("enforce_spf").MustBool(true)
|
|
enforceDMARC := b.Cfg.Section("Mailstore").Key("enforce_dmarc").MustBool(true)
|
|
|
|
// DMARC ties DKIM/SPF together via alignment to the visible From: header's
|
|
// domain — a materially different check from dkimPass/spfPass above (which align
|
|
// to the envelope's mailFrom domain).
|
|
fromHeaderAddrs := parseAddressList(fromDisplay)
|
|
fromHeaderDomain := ""
|
|
if len(fromHeaderAddrs) > 0 {
|
|
fromHeaderDomain = domainOfAddr(fromHeaderAddrs[0])
|
|
}
|
|
dmarcFailPolicy := "" // "", "quarantine", or "reject"
|
|
if enforceDMARC && fromHeaderDomain != "" {
|
|
if pol := mailstore.LookupDMARCPolicy(fromHeaderDomain); pol != nil {
|
|
orgDomain := mailstore.OrganizationalDomain(fromHeaderDomain)
|
|
dkimAligned := dkim.VerifyInbound(signedContent, fromHeaderDomain)
|
|
spfAligned := spfPass && mailstore.OrganizationalDomain(senderDomain) == orgDomain
|
|
if !dkimAligned && !spfAligned {
|
|
dmarcFailPolicy = pol.EffectivePolicy(fromHeaderDomain, orgDomain)
|
|
}
|
|
}
|
|
}
|
|
|
|
// rspamd is checked at most once per message and reused for every local
|
|
// recipient — safe for this deployment (no reliance on rspamd's per-recipient
|
|
// personalization); content and mailFrom are identical for every recipient
|
|
// regardless.
|
|
rspamdChecked := false
|
|
var rspamdScore float64
|
|
var rspamdAction string
|
|
var rspamdOK bool
|
|
checkRspamdOnce := func() (float64, string, bool) {
|
|
if !rspamdChecked {
|
|
if score, action, err := mailstore.CheckRspamd(rspamdURL, []byte(signedContent), mailFrom, rcpts[0]); err == nil {
|
|
rspamdScore, rspamdAction, rspamdOK = score, action, true
|
|
}
|
|
rspamdChecked = true
|
|
}
|
|
return rspamdScore, rspamdAction, rspamdOK
|
|
}
|
|
|
|
results := make([]relay.Result, 0, len(rcpts))
|
|
for i, rcpt := range rcpts {
|
|
mbox, resolveErr := b.Mailstore.ResolveRecipient(rcpt)
|
|
if resolveErr != nil || mbox == nil {
|
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "550", ErrorMessage: "No such mailbox"})
|
|
continue
|
|
}
|
|
folder := "INBOX"
|
|
markRead := false
|
|
spamGated := false
|
|
var tags []string
|
|
|
|
// An explicit per-mailbox "allow" entry scoped to "all" bypasses every check
|
|
// below entirely. A narrower scope (spf/dkim/spam) only suppresses that one
|
|
// check.
|
|
scope, hasAllow, _ := b.DB.AllowScope(mbox.ID, mailFrom)
|
|
if hasAllow && scope == "all" {
|
|
// unchanged existing behavior: full bypass, no scoring, no tagging
|
|
} else if junked, _ := b.DB.IsJunked(mbox.ID, mailFrom); junked {
|
|
// The mailbox owner's own Blocklist also bypasses scoring entirely,
|
|
// straight to Junk — a *soft* quarantine (still delivered, just hidden),
|
|
// unlike the admin's separate hard-reject block list (checked at RCPT
|
|
// time, a different tool for a different job).
|
|
folder = "Junk"
|
|
spamGated = true
|
|
} else {
|
|
suppressDKIM := hasAllow && scope == "dkim"
|
|
suppressSPF := hasAllow && scope == "spf"
|
|
suppressSpam := hasAllow && scope == "spam"
|
|
suppressDMARC := hasAllow && scope == "dmarc"
|
|
|
|
if enforceDKIM && !dkimPass && !suppressDKIM {
|
|
tags = append(tags, "Failed DKIM")
|
|
}
|
|
if enforceSPF && !spfPass && !suppressSPF {
|
|
tags = append(tags, "Failed SPF")
|
|
}
|
|
|
|
hardReject := false
|
|
if !suppressDMARC {
|
|
switch dmarcFailPolicy {
|
|
case "reject":
|
|
tags = append(tags, "Failed DMARC")
|
|
hardReject = true
|
|
case "quarantine":
|
|
tags = append(tags, "Failed DMARC")
|
|
}
|
|
}
|
|
if !suppressSpam {
|
|
quarantine := heuristicScore >= rejectScore
|
|
if rspamdEnabled {
|
|
if score, rAction, ok := checkRspamdOnce(); ok {
|
|
// rspamd's own "reject" action is a considered policy
|
|
// decision worth still hard-rejecting to avoid backscatter; a
|
|
// bare score threshold hit is quarantined instead, so a false
|
|
// positive is recoverable from Junk rather than silently
|
|
// bounced with no trace.
|
|
if rAction == "reject" {
|
|
hardReject = true
|
|
} else if score >= float64(rspamdRejectScore) {
|
|
quarantine = true
|
|
}
|
|
}
|
|
}
|
|
if quarantine {
|
|
tags = append(tags, "SPAM")
|
|
}
|
|
}
|
|
if hardReject {
|
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "550", ErrorMessage: "Message rejected as spam"})
|
|
continue
|
|
}
|
|
if len(tags) > 0 {
|
|
folder = "Junk"
|
|
spamGated = true
|
|
}
|
|
}
|
|
|
|
// A tagged message's per-recipient copy gets its Subject prepended with why
|
|
// it was flagged — both in the stored raw content and the cached subject.
|
|
// This does invalidate that copy's own DKIM signature, harmless since
|
|
// spamGated is always true whenever tags are non-empty, and a tagged copy is
|
|
// never relayed/forwarded, only locally stored/read.
|
|
recipientContent := signedContent
|
|
recipientSubject := subject
|
|
if len(tags) > 0 {
|
|
tag := strings.Join(tags, ", ")
|
|
recipientContent = prependSubjectTag(signedContent, tag)
|
|
recipientSubject = "***" + tag + "***"
|
|
if subject != "" {
|
|
recipientSubject += " " + subject
|
|
}
|
|
}
|
|
|
|
// Filter rules organize legitimate mail the recipient already trusts arriving
|
|
// in their INBOX — a quarantined message skips them entirely.
|
|
if !spamGated {
|
|
// Persistent mailbox-level forwarding, distinct from and independent of a
|
|
// filter rule's own "forward" action below.
|
|
if mbox.ForwardTo != nil && *mbox.ForwardTo != "" {
|
|
forwardTo, mailboxEmail, keepCopy := *mbox.ForwardTo, mbox.Email, mbox.ForwardKeepCopy
|
|
b.Relay.RelayEmailAsyncBounded(mailboxEmail, []string{forwardTo}, signedContent, []string{"to"}, func(res []relay.Result) {
|
|
if len(res) > 0 && res[0].Status != "success" {
|
|
b.Logger.Error("mailbox forwarding: delivery to %s failed: %s", forwardTo, res[0].ErrorMessage)
|
|
}
|
|
})
|
|
if !keepCopy {
|
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Forwarded to " + forwardTo + ", not kept locally"})
|
|
continue
|
|
}
|
|
}
|
|
|
|
action, err := b.Mailstore.ApplyRules(mbox.ID, map[string]string{
|
|
"from": mailFrom, "to": rcpt, "subject": subject,
|
|
"body": bodyText, "has_attachment": hasAttachment, "recipient_type": types[i],
|
|
})
|
|
if err != nil {
|
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "450", ErrorMessage: err.Error()})
|
|
continue
|
|
}
|
|
if action.ForwardTo != "" {
|
|
// Fire-and-forget: forwarding is a side effect layered on top of this
|
|
// recipient's own local delivery, not a substitute for it.
|
|
forwardTo, mailboxEmail := action.ForwardTo, mbox.Email
|
|
b.Relay.RelayEmailAsyncBounded(mailboxEmail, []string{forwardTo}, signedContent, []string{"to"}, func(res []relay.Result) {
|
|
if len(res) > 0 && res[0].Status != "success" {
|
|
b.Logger.Error("forward rule: delivery to %s failed: %s", forwardTo, res[0].ErrorMessage)
|
|
}
|
|
})
|
|
if !action.KeepCopy {
|
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Forwarded to " + forwardTo + ", not kept locally"})
|
|
continue
|
|
}
|
|
}
|
|
if action.Drop {
|
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Discarded by filter rule"})
|
|
continue
|
|
}
|
|
if action.AutoReply {
|
|
b.sendAutoReply(mailFrom, mbox, action.AutoReplySubject, action.AutoReplyBody, messageID)
|
|
}
|
|
if action.Folder != "" {
|
|
folder = action.Folder
|
|
}
|
|
markRead = action.MarkRead
|
|
}
|
|
|
|
uid, err := b.Mailstore.StoreMessage(mbox.ID, folder, []byte(recipientContent), messageID, fromDisplay, recipientSubject)
|
|
if err != nil {
|
|
errCode, errMsg := "450", err.Error()
|
|
if err == mailstore.ErrQuotaExceeded {
|
|
errCode, errMsg = "552", "Mailbox quota exceeded"
|
|
}
|
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: errCode, ErrorMessage: errMsg})
|
|
continue
|
|
}
|
|
if markRead {
|
|
if err := b.DB.SetMessageFlags(mbox.ID, uid, `\Seen`); err != nil {
|
|
b.Logger.Error("mark_read rule failed to set flag for message %d: %v", uid, err)
|
|
}
|
|
}
|
|
b.Notify.Publish(mbox.ID, folder)
|
|
b.Notify.PublishAccountWide(mbox.ID)
|
|
serverResponse := "Delivered to local mailbox"
|
|
if spamGated {
|
|
serverResponse = "Quarantined to Junk folder"
|
|
}
|
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: serverResponse, Quarantined: spamGated})
|
|
}
|
|
return results
|
|
}
|
|
|
|
// sendAutoReply fires a vacation-responder reply to mailFrom, fire-and-forget (a slow/
|
|
// unreachable target must never delay the caller), after two loop/storm-prevention
|
|
// checks: never reply to a null-sender message (a bounce/DSN), and never reply to the
|
|
// same sender more than once per rolling 24h (esrv_mailbox_autoreply_log).
|
|
func (b *Backend) sendAutoReply(mailFrom string, mbox *db.Mailbox, subject, body, inReplyTo string) {
|
|
if mailFrom == "" {
|
|
return
|
|
}
|
|
if recent, err := b.DB.HasRecentAutoReply(mbox.ID, mailFrom); err != nil || recent {
|
|
return
|
|
}
|
|
if subject == "" {
|
|
subject = "Automatic reply"
|
|
}
|
|
mailboxEmail, replyTo := mbox.Email, mailFrom
|
|
raw := buildAutoReplyMessage(b.HeloHostname, mailboxEmail, replyTo, subject, body, inReplyTo)
|
|
b.Relay.RelayEmailAsyncBounded(mailboxEmail, []string{replyTo}, raw, []string{"to"}, func(res []relay.Result) {
|
|
if len(res) > 0 && res[0].Status != "success" {
|
|
b.Logger.Error("auto-reply to %s failed: %s", replyTo, res[0].ErrorMessage)
|
|
}
|
|
})
|
|
if err := b.DB.RecordAutoReply(mbox.ID, replyTo); err != nil {
|
|
b.Logger.Error("record auto-reply to %s: %v", replyTo, err)
|
|
}
|
|
}
|
|
|
|
// domainSendRateLimited reports whether domain has hit its own admin-configured
|
|
// outbound send-rate cap (esrv_domains.send_rate_limit_per_hour) within the last
|
|
// rolling hour. Unconfigured (nil limit) or an unrecognized/empty domain never limits.
|
|
func (b *Backend) domainSendRateLimited(domain string) (bool, error) {
|
|
if domain == "" {
|
|
return false, nil
|
|
}
|
|
dom, err := b.DB.GetDomainByName(domain)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if dom == nil || dom.SendRateLimitPerHour == nil {
|
|
return false, nil
|
|
}
|
|
count, err := b.DB.CountRecentSendsForDomain(domain, time.Now().Add(-time.Hour))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return count >= *dom.SendRateLimitPerHour, nil
|
|
}
|