Files
mailgoserver/internal/smtpserver/route.go
T
2026-08-22 06:45:05 +01:00

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
}