12 "github.com/mjl-/bstore"
14 "github.com/mjl-/mox/config"
15 "github.com/mjl-/mox/dkim"
16 "github.com/mjl-/mox/dmarc"
17 "github.com/mjl-/mox/dmarcrpt"
18 "github.com/mjl-/mox/dns"
19 "github.com/mjl-/mox/dnsbl"
20 "github.com/mjl-/mox/iprev"
21 "github.com/mjl-/mox/message"
22 "github.com/mjl-/mox/mlog"
23 "github.com/mjl-/mox/mox-"
24 "github.com/mjl-/mox/publicsuffix"
25 "github.com/mjl-/mox/smtp"
26 "github.com/mjl-/mox/store"
27 "github.com/mjl-/mox/subjectpass"
28 "github.com/mjl-/mox/tlsrpt"
35 smtpRcptTo smtp.Path // As used in SMTP, possibly address of alias.
36 deliverTo smtp.Path // To deliver to, either smtpRcptTo or an alias member address.
37 destination config.Destination
38 canonicalAddress string
40 msgTo []message.Address
41 msgCc []message.Address
45 dmarcResult dmarc.Result
46 dkimResults []dkim.Result
47 iprevStatus iprev.Status
59 err error // For our own logging, not sent to remote.
60 dmarcReport *dmarcrpt.Feedback // Validated DMARC aggregate report, not yet stored.
61 tlsReport *tlsrpt.Report // Validated TLS report, not yet stored.
62 reason string // If non-empty, reason for this decision. Values from reputationMethod and reason* below.
63 reasonText []string // Additional details for reason, human-readable, added to X-Mox-Reason header.
64 dmarcOverrideReason string // If set, one of dmarcrpt.PolicyOverride
65 // Additional headers to add during delivery. Used for reasons a message to a
66 // dmarc/tls reporting address isn't processed.
71 reasonListAllow = "list-allow"
72 reasonDMARCPolicy = "dmarc-policy"
73 reasonReputationError = "reputation-error"
74 reasonReporting = "reporting"
75 reasonSPFPolicy = "spf-policy"
76 reasonJunkClassifyError = "junk-classify-error"
77 reasonJunkFilterError = "junk-filter-error"
78 reasonGiveSubjectpass = "give-subjectpass"
79 reasonNoBadSignals = "no-bad-signals"
80 reasonJunkContent = "junk-content"
81 reasonJunkContentStrict = "junk-content-strict"
82 reasonDNSBlocklisted = "dns-blocklisted"
83 reasonSubjectpass = "subjectpass"
84 reasonSubjectpassError = "subjectpass-error"
85 reasonIPrev = "iprev" // No or mild junk reputation signals, and bad iprev.
86 reasonHighRate = "high-rate" // Too many messages, not added to rejects.
89func isListDomain(d delivery, ld dns.Domain) bool {
90 if d.m.MailFromValidated && ld.Name() == d.m.MailFromDomain {
93 for _, r := range d.dkimResults {
94 if r.Status == dkim.StatusPass && r.Sig.Domain == ld {
101func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d delivery) analysis {
104 var reasonText []string
105 addReasonText := func(format string, args ...any) {
106 s := fmt.Sprintf(format, args...)
107 reasonText = append(reasonText, s)
110 // We don't want to let a single IP or network deliver too many messages to an
111 // account. They may fill up the mailbox, either with messages that have to be
112 // purged, or by filling the disk. We check both cases for IP's and networks.
113 var rateError bool // Whether returned error represents a rate error.
114 err := d.acc.DB.Read(ctx, func(tx *bstore.Tx) (retErr error) {
117 log.Debugx("checking message and size delivery rates", retErr, slog.Duration("duration", time.Since(now)))
120 checkCount := func(msg store.Message, window time.Duration, limit int) {
124 q := bstore.QueryTx[store.Message](tx)
126 q.FilterGreater("Received", now.Add(-window))
127 q.FilterEqual("Expunged", false)
135 retErr = fmt.Errorf("more than %d messages in past %s from your ip/network", limit, window)
139 checkSize := func(msg store.Message, window time.Duration, limit int64) {
143 q := bstore.QueryTx[store.Message](tx)
145 q.FilterGreater("Received", now.Add(-window))
146 q.FilterEqual("Expunged", false)
148 err := q.ForEach(func(v store.Message) error {
158 retErr = fmt.Errorf("more than %d bytes in past %s from your ip/network", limit, window)
162 // todo future: make these configurable
163 // todo: should we have a limit for forwarded messages? they are stored with empty RemoteIPMasked*
165 const day = 24 * time.Hour
166 checkCount(store.Message{RemoteIPMasked1: d.m.RemoteIPMasked1}, time.Minute, limitIPMasked1MessagesPerMinute)
167 checkCount(store.Message{RemoteIPMasked1: d.m.RemoteIPMasked1}, day, 20*500)
168 checkCount(store.Message{RemoteIPMasked2: d.m.RemoteIPMasked2}, time.Minute, 1500)
169 checkCount(store.Message{RemoteIPMasked2: d.m.RemoteIPMasked2}, day, 20*1500)
170 checkCount(store.Message{RemoteIPMasked3: d.m.RemoteIPMasked3}, time.Minute, 4500)
171 checkCount(store.Message{RemoteIPMasked3: d.m.RemoteIPMasked3}, day, 20*4500)
173 const MB = 1024 * 1024
174 checkSize(store.Message{RemoteIPMasked1: d.m.RemoteIPMasked1}, time.Minute, limitIPMasked1SizePerMinute)
175 checkSize(store.Message{RemoteIPMasked1: d.m.RemoteIPMasked1}, day, 3*1000*MB)
176 checkSize(store.Message{RemoteIPMasked2: d.m.RemoteIPMasked2}, time.Minute, 3000*MB)
177 checkSize(store.Message{RemoteIPMasked2: d.m.RemoteIPMasked2}, day, 3*3000*MB)
178 checkSize(store.Message{RemoteIPMasked3: d.m.RemoteIPMasked3}, time.Minute, 9000*MB)
179 checkSize(store.Message{RemoteIPMasked3: d.m.RemoteIPMasked3}, day, 3*9000*MB)
183 if err != nil && !rateError {
184 log.Errorx("checking delivery rates", err)
185 metricDelivery.WithLabelValues("checkrates", "").Inc()
186 addReasonText("checking delivery rates: %v", err)
187 return analysis{d, false, "", smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing", err, nil, nil, reasonReputationError, reasonText, "", headers}
188 } else if err != nil {
189 log.Debugx("refusing due to high delivery rate", err)
190 metricDelivery.WithLabelValues("highrate", "").Inc()
191 addReasonText("high delivery rate")
192 return analysis{d, false, "", smtp.C452StorageFull, smtp.SeMailbox2Full2, true, err.Error(), err, nil, nil, reasonHighRate, reasonText, "", headers}
195 mailbox := d.destination.Mailbox
200 // If destination mailbox has a mailing list domain (for SPF/DKIM) configured,
201 // check it for a pass.
202 rs := store.MessageRuleset(log, d.destination, d.m, d.m.MsgPrefix, d.dataFile)
206 if rs != nil && !rs.ListAllowDNSDomain.IsZero() {
207 // todo: on temporary failures, reject temporarily?
208 if isListDomain(d, rs.ListAllowDNSDomain) {
209 addReasonText("validated message from a configured mailing list")
210 d.m.IsMailingList = true
215 reason: reasonListAllow,
216 reasonText: reasonText,
217 dmarcOverrideReason: string(dmarcrpt.PolicyOverrideMailingList),
223 var dmarcOverrideReason string
225 // For forwarded messages, we have different junk analysis. We don't reject for
226 // failing DMARC, and we clear fields that could implicate the forwarding mail
227 // server during future classifications on incoming messages (the forwarding mail
228 // server isn't responsible for the message).
229 if rs != nil && rs.IsForward {
232 d.m.RemoteIPMasked1 = ""
233 d.m.RemoteIPMasked2 = ""
234 d.m.RemoteIPMasked3 = ""
235 d.m.OrigEHLODomain = d.m.EHLODomain
237 d.m.MailFromDomain = "" // Still available in MailFrom.
238 d.m.OrigDKIMDomains = d.m.DKIMDomains
239 dkimdoms := []string{}
240 for _, dom := range d.m.DKIMDomains {
241 if dom != rs.VerifiedDNSDomain.Name() {
242 dkimdoms = append(dkimdoms, dom)
245 d.m.DKIMDomains = dkimdoms
246 dmarcOverrideReason = string(dmarcrpt.PolicyOverrideForwarded)
247 log.Info("forwarded message, clearing identifying signals of forwarding mail server")
248 addReasonText("ruleset indicates forwarded message")
251 assignMailbox := func(tx *bstore.Tx) error {
252 // Set message MailboxID to which mail will be delivered. Reputation is
253 // per-mailbox. If referenced mailbox is not found (e.g. does not yet exist), we
254 // can still determine a reputation because we also base it on outgoing
255 // messages and those are account-global.
256 mb, err := d.acc.MailboxFind(tx, mailbox)
258 return fmt.Errorf("finding destination mailbox: %w", err)
261 // We want to deliver to mb.ID, but this message may be rejected and sent to the
262 // Rejects mailbox instead, with MailboxID overwritten. Record the ID in
263 // MailboxDestinedID too. If the message is later moved out of the Rejects mailbox,
264 // we'll adjust the MailboxOrigID so it gets taken into account during reputation
265 // calculating in future deliveries. If we end up delivering to the intended
266 // mailbox (i.e. not rejecting), MailboxDestinedID is cleared during delivery so we
267 // don't store it unnecessarily.
268 d.m.MailboxID = mb.ID
269 d.m.MailboxDestinedID = mb.ID
271 log.Debug("mailbox not found in database", slog.String("mailbox", mailbox))
276 reject := func(code int, secode string, errmsg string, err error, reason string) analysis {
277 // We may have set MailboxDestinedID below already while we had a transaction. If
278 // not, do it now. This makes it possible to use the per-mailbox reputation when a
279 // user moves the message out of the Rejects mailbox to the intended mailbox
280 // (typically Inbox).
281 if d.m.MailboxDestinedID == 0 {
283 d.acc.WithRLock(func() {
284 mberr = d.acc.DB.Read(ctx, func(tx *bstore.Tx) error {
285 return assignMailbox(tx)
289 addReasonText("error setting original destination mailbox for rejected message: %v", mberr)
290 return analysis{d, false, mailbox, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing", err, nil, nil, reasonReputationError, reasonText, dmarcOverrideReason, headers}
292 d.m.MailboxID = 0 // We plan to reject, no need to set intended MailboxID.
296 if rs != nil && rs.AcceptRejectsToMailbox != "" {
298 mailbox = rs.AcceptRejectsToMailbox
300 // Don't draw attention, but don't go so far as to mark as junk.
302 log.Info("accepting reject to configured mailbox due to ruleset")
303 addReasonText("accepting reject to mailbox due to ruleset")
305 return analysis{d, accept, mailbox, code, secode, err == nil, errmsg, err, nil, nil, reason, reasonText, dmarcOverrideReason, headers}
308 if d.dmarcUse && d.dmarcResult.Reject {
309 addReasonText("message does not pass domain dmarc policy which asks to reject")
310 return reject(smtp.C550MailboxUnavail, smtp.SePol7MultiAuthFails26, "rejecting per dmarc policy", nil, reasonDMARCPolicy)
311 } else if !d.dmarcUse {
312 addReasonText("not using any dmarc result")
314 addReasonText("dmarc ok")
316 // todo: should we also reject messages that have a dmarc pass but an spf record "v=spf1 -all"? suggested by m3aawg best practices.
318 // If destination is the DMARC reporting mailbox, do additional checks and keep
319 // track of the report. We'll check reputation, defaulting to accept.
320 var dmarcReport *dmarcrpt.Feedback
321 if d.destination.DMARCReports {
323 if d.dmarcResult.Status != dmarc.StatusPass {
324 log.Info("received dmarc aggregate report without dmarc pass, not processing as dmarc report")
325 headers += "X-Mox-DMARCReport-Error: no DMARC pass\r\n"
326 } else if report, err := dmarcrpt.ParseMessageReport(log.Logger, store.FileMsgReader(d.m.MsgPrefix, d.dataFile)); err != nil {
327 log.Infox("parsing dmarc aggregate report", err)
328 headers += "X-Mox-DMARCReport-Error: could not parse report\r\n"
329 } else if d, err := dns.ParseDomain(report.PolicyPublished.Domain); err != nil {
330 log.Infox("parsing domain in dmarc aggregate report", err)
331 headers += "X-Mox-DMARCReport-Error: could not parse domain in published policy\r\n"
332 } else if _, ok := mox.Conf.Domain(d); !ok {
333 log.Info("dmarc aggregate report for domain not configured, ignoring", slog.Any("domain", d))
334 headers += "X-Mox-DMARCReport-Error: published policy domain unrecognized\r\n"
335 } else if report.ReportMetadata.DateRange.End > time.Now().Unix()+60 {
336 log.Info("dmarc aggregate report with end date in the future, ignoring", slog.Any("domain", d), slog.Time("end", time.Unix(report.ReportMetadata.DateRange.End, 0)))
337 headers += "X-Mox-DMARCReport-Error: report has end date in the future\r\n"
343 // Similar to DMARC reporting, we check for the required DKIM. We'll check
344 // reputation, defaulting to accept.
345 var tlsReport *tlsrpt.Report
346 if d.destination.HostTLSReports || d.destination.DomainTLSReports {
347 matchesDomain := func(sigDomain dns.Domain) bool {
348 // RFC seems to require exact DKIM domain match with submitt and message From, we
350 return sigDomain == d.msgFrom.Domain || strings.HasSuffix(d.msgFrom.Domain.ASCII, "."+sigDomain.ASCII) && publicsuffix.Lookup(ctx, log.Logger, d.msgFrom.Domain) == publicsuffix.Lookup(ctx, log.Logger, sigDomain)
352 // Valid DKIM signature for domain must be present. We take "valid" to assume
353 // "passing", not "syntactically valid". We also check for "tlsrpt" as service.
354 // This check is optional, but if anyone goes through the trouble to explicitly
355 // list allowed services, they would be surprised to see them ignored.
358 for _, r := range d.dkimResults {
359 // The record should have an allowed service "tlsrpt". The RFC mentions it as if
360 // the service must be specified explicitly, but the default allowed services for a
361 // DKIM record are "*", which includes "tlsrpt". Unless a DKIM record explicitly
362 // specifies services (e.g. s=email), a record will work for TLS reports. The DKIM
363 // records seen used for TLS reporting in the wild don't explicitly set "s" for
366 if r.Status == dkim.StatusPass && matchesDomain(r.Sig.Domain) && r.Sig.Length < 0 && r.Record.ServiceAllowed("tlsrpt") {
373 log.Info("received mail to tlsrpt without acceptable DKIM signature, not processing as tls report")
374 headers += "X-Mox-TLSReport-Error: no acceptable DKIM signature\r\n"
375 } else if reportJSON, err := tlsrpt.ParseMessage(log.Logger, store.FileMsgReader(d.m.MsgPrefix, d.dataFile)); err != nil {
376 log.Infox("parsing tls report", err)
377 headers += "X-Mox-TLSReport-Error: could not parse TLS report\r\n"
380 for _, p := range reportJSON.Policies {
381 log.Info("tlsrpt policy domain", slog.String("domain", p.Policy.Domain))
382 if d, err := dns.ParseDomain(p.Policy.Domain); err != nil {
383 log.Infox("parsing domain in tls report", err)
384 } else if _, ok := mox.Conf.Domain(d); ok || d == mox.Conf.Static.HostnameDomain {
390 log.Info("tls report without one of configured domains, ignoring")
391 headers += "X-Mox-TLSReport-Error: report for unknown domain\r\n"
393 report := reportJSON.Convert()
399 // Determine if message is acceptable based on DMARC domain, DKIM identities, or
400 // host-based reputation.
403 var method reputationMethod
405 d.acc.WithRLock(func() {
406 err = d.acc.DB.Read(ctx, func(tx *bstore.Tx) error {
407 if err := assignMailbox(tx); err != nil {
412 isjunk, conclusive, method, text, err = reputation(tx, log, d.m, d.smtputf8)
413 reason = string(method)
414 s := "address/dkim/spf/ip-based reputation ("
415 if isjunk != nil && *isjunk {
417 } else if isjunk != nil && !*isjunk {
425 s += ", " + text + ")"
426 addReasonText("%s", s)
431 log.Infox("determining reputation", err, slog.Any("message", d.m))
432 addReasonText("determining reputation: %v", err)
433 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonReputationError)
435 log.Info("reputation analyzed",
436 slog.Bool("conclusive", conclusive),
437 slog.Any("isjunk", isjunk),
438 slog.String("method", string(method)))
445 dmarcReport: dmarcReport,
446 tlsReport: tlsReport,
448 reasonText: reasonText,
449 dmarcOverrideReason: dmarcOverrideReason,
453 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, string(method))
454 } else if dmarcReport != nil || tlsReport != nil {
455 log.Info("accepting message with dmarc aggregate report or tls report without reputation")
456 addReasonText("message inconclusive reputation but with dmarc or tls report")
461 dmarcReport: dmarcReport,
462 tlsReport: tlsReport,
463 reason: reasonReporting,
464 reasonText: reasonText,
465 dmarcOverrideReason: dmarcOverrideReason,
469 // If there was no previous message from sender or its domain, and we have an SPF
470 // (soft)fail, reject the message.
472 case methodDKIMSPF, methodIP1, methodIP2, methodIP3, methodNone:
473 switch d.m.MailFromValidation {
474 case store.ValidationFail, store.ValidationSoftfail:
475 addReasonText("no previous message from sender domain and spf result is (soft)fail")
476 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", nil, reasonSPFPolicy)
480 // Senders without reputation and without iprev pass, are likely spam.
481 var suspiciousIPrevFail bool
483 case methodDKIMSPF, methodIP1, methodIP2, methodIP3, methodNone:
484 suspiciousIPrevFail = d.iprevStatus != iprev.StatusPass
486 if suspiciousIPrevFail {
487 addReasonText("suspicious iprev failure")
490 // With already a mild junk signal, an iprev fail on top is enough to reject.
491 if suspiciousIPrevFail && isjunk != nil && *isjunk {
492 addReasonText("message has a mild junk signal and mismatching reverse ip")
493 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", nil, reasonIPrev)
496 var subjectpassKey string
497 conf, _ := d.acc.Conf()
498 if conf.SubjectPass.Period > 0 {
499 subjectpassKey, err = d.acc.Subjectpass(d.canonicalAddress)
501 log.Errorx("get key for verifying subject token", err)
502 addReasonText("subject pass error: %v", err)
503 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonSubjectpassError)
505 err = subjectpass.Verify(log.Logger, d.dataFile, []byte(subjectpassKey), conf.SubjectPass.Period)
507 log.Infox("pass by subject token", err, slog.Bool("pass", pass))
509 addReasonText("message has valid subjectpass token in subject")
514 reason: reasonSubjectpass,
515 reasonText: reasonText,
516 dmarcOverrideReason: dmarcOverrideReason,
522 reason = reasonNoBadSignals
524 var junkSubjectpass bool
525 f, jf, err := d.acc.OpenJunkFilter(ctx, log)
529 log.Check(err, "closing junkfilter")
531 contentProb, _, hams, spams, err := f.ClassifyMessageReader(ctx, store.FileMsgReader(d.m.MsgPrefix, d.dataFile), d.m.Size)
533 log.Errorx("testing for spam", err)
534 addReasonText("classify message error: %v", err)
535 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonJunkClassifyError)
537 // todo: if isjunk is not nil (i.e. there was inconclusive reputation), use it in the probability calculation. give reputation a score of 0.25 or .75 perhaps?
538 // todo: if there aren't enough historic messages, we should just let messages in.
539 // todo: we could require nham and nspam to be above a certain number when there were plenty of words in the message, and in the database. can indicate a spammer is misspelling words. however, it can also mean a message in a different language/script...
541 // If we don't accept, we may still respond with a "subjectpass" hint below.
542 // We add some jitter to the threshold we use. So we don't act as too easy an
543 // oracle for words that are a strong indicator of haminess.
544 // todo: we should rate-limit uses of the junkfilter.
545 jitter := (jitterRand.Float64() - 0.5) / 10
546 threshold := jf.Threshold + jitter
548 rcptToMatch := func(l []message.Address) bool {
549 // todo: we use Go's net/mail to parse message header addresses. it does not allow empty quoted strings (contrary to spec), leaving To empty. so we don't verify To address for that unusual case for now.
../rfc/5322:961 ../rfc/5322:743
550 if d.smtpRcptTo.Localpart == "" {
553 for _, a := range l {
554 dom, err := dns.ParseDomain(a.Host)
558 lp, err := smtp.ParseLocalpart(a.User)
559 if err == nil && dom == d.smtpRcptTo.IPDomain.Domain && lp == d.smtpRcptTo.Localpart {
566 // todo: some of these checks should also apply for reputation-based analysis with a weak signal, e.g. verified dkim/spf signal from new domain.
567 // With an iprev fail, non-TLS connection or our address not in To/Cc header, we set a higher bar for content.
568 reason = reasonJunkContent
569 var thresholdRemark string
570 if suspiciousIPrevFail && threshold > 0.25 {
572 log.Info("setting junk threshold due to iprev fail", slog.Float64("threshold", threshold))
573 reason = reasonJunkContentStrict
574 thresholdRemark = " (stricter due to reverse ip mismatch)"
575 } else if !d.tls && threshold > 0.25 {
577 log.Info("setting junk threshold due to plaintext smtp", slog.Float64("threshold", threshold))
578 reason = reasonJunkContentStrict
579 thresholdRemark = " (stricter due to missing tls)"
580 } else if (rs == nil || !rs.IsForward) && threshold > 0.25 && !rcptToMatch(d.msgTo) && !rcptToMatch(d.msgCc) {
581 // A common theme in junk messages is your recipient address not being in the To/Cc
582 // headers. We may be in Bcc, but that's unusual for first-time senders. Some
583 // providers (e.g. gmail) does not DKIM-sign Bcc headers, so junk messages can be
584 // sent with matching Bcc headers. We don't get here for known senders.
586 log.Info("setting junk threshold due to smtp rcpt to and message to/cc address mismatch", slog.Float64("threshold", threshold))
587 reason = reasonJunkContentStrict
588 thresholdRemark = " (stricter due to recipient address not in to/cc header)"
590 accept = contentProb <= threshold
591 junkSubjectpass = contentProb < threshold-0.2
592 log.Info("content analyzed",
593 slog.Bool("accept", accept),
594 slog.Float64("contentprob", contentProb),
595 slog.Bool("subjectpass", junkSubjectpass))
603 s += fmt.Sprintf(", spamscore %.2f, threshold %.2f%s", contentProb, threshold, thresholdRemark)
605 for i, w := range hams {
610 if !d.smtputf8 && !isASCII(word) {
613 s += fmt.Sprintf("%s %.3f", word, w.Score)
615 s += "), (spam words: "
616 for i, w := range spams {
621 if !d.smtputf8 && !isASCII(word) {
624 s += fmt.Sprintf("%s %.3f", word, w.Score)
627 addReasonText("%s", s)
628 } else if err != store.ErrNoJunkFilter {
629 log.Errorx("open junkfilter", err)
630 addReasonText("open junkfilter: %v", err)
631 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonJunkFilterError)
633 addReasonText("no junk filter configured")
636 // If content looks good, we'll still look at DNS block lists for a reason to
637 // reject. We normally won't get here if we've communicated with this sender
639 var dnsblocklisted bool
641 blocked := func(zone dns.Domain) bool {
642 dnsblctx, dnsblcancel := context.WithTimeout(ctx, 30*time.Second)
644 if !checkDNSBLHealth(dnsblctx, log, resolver, zone) {
645 log.Info("dnsbl not healthy, skipping", slog.Any("zone", zone))
649 status, expl, err := dnsbl.Lookup(dnsblctx, log.Logger, resolver, zone, net.ParseIP(d.m.RemoteIP))
651 if status == dnsbl.StatusFail {
652 log.Info("rejecting due to listing in dnsbl", slog.Any("zone", zone), slog.String("explanation", expl))
654 } else if err != nil {
655 log.Infox("dnsbl lookup", err, slog.Any("zone", zone), slog.Any("status", status))
660 // Note: We don't check in parallel, we are in no hurry to accept possible spam.
661 for _, zone := range d.dnsBLs {
664 dnsblocklisted = true
665 reason = reasonDNSBlocklisted
666 addReasonText("dnsbl: ip %s listed in dnsbl %s", d.m.RemoteIP, zone.XName(d.smtputf8))
670 if !dnsblocklisted && len(d.dnsBLs) > 0 {
671 addReasonText("remote ip not blocklisted")
676 addReasonText("no known reputation and no bad signals")
681 reason: reasonNoBadSignals,
682 reasonText: reasonText,
683 dmarcOverrideReason: dmarcOverrideReason,
688 if subjectpassKey != "" && d.dmarcResult.Status == dmarc.StatusPass && method == methodNone && (dnsblocklisted || junkSubjectpass) {
689 log.Info("permanent reject with subjectpass hint of moderately spammy email without reputation")
690 pass := subjectpass.Generate(log.Logger, d.msgFrom, []byte(subjectpassKey), time.Now())
691 addReasonText("reject with request to try again with subjectpass token in subject")
692 return reject(smtp.C550MailboxUnavail, smtp.SePol7DeliveryUnauth1, subjectpass.Explanation+pass, nil, reasonGiveSubjectpass)
695 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", nil, reason)
698func isASCII(s string) bool {
699 for _, b := range []byte(s) {