1package smtpserver
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "net"
8 "os"
9 "strings"
10 "time"
11
12 "github.com/mjl-/bstore"
13
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"
29)
30
31type delivery struct {
32 tls bool
33 m *store.Message
34 dataFile *os.File
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
39 acc *store.Account
40 msgTo []message.Address
41 msgCc []message.Address
42 msgFrom smtp.Address
43 dnsBLs []dns.Domain
44 dmarcUse bool
45 dmarcResult dmarc.Result
46 dkimResults []dkim.Result
47 iprevStatus iprev.Status
48}
49
50type analysis struct {
51 d delivery
52 accept bool
53 mailbox string
54 code int
55 secode string
56 userError bool
57 errmsg string
58 err error // For our own logging, not sent to remote.
59 dmarcReport *dmarcrpt.Feedback // Validated DMARC aggregate report, not yet stored.
60 tlsReport *tlsrpt.Report // Validated TLS report, not yet stored.
61 reason string // If non-empty, reason for this decision. Can be one of reputationMethod and a few other tokens.
62 dmarcOverrideReason string // If set, one of dmarcrpt.PolicyOverride
63 // Additional headers to add during delivery. Used for reasons a message to a
64 // dmarc/tls reporting address isn't processed.
65 headers string
66}
67
68const (
69 reasonListAllow = "list-allow"
70 reasonDMARCPolicy = "dmarc-policy"
71 reasonReputationError = "reputation-error"
72 reasonReporting = "reporting"
73 reasonSPFPolicy = "spf-policy"
74 reasonJunkClassifyError = "junk-classify-error"
75 reasonJunkFilterError = "junk-filter-error"
76 reasonGiveSubjectpass = "give-subjectpass"
77 reasonNoBadSignals = "no-bad-signals"
78 reasonJunkContent = "junk-content"
79 reasonJunkContentStrict = "junk-content-strict"
80 reasonDNSBlocklisted = "dns-blocklisted"
81 reasonSubjectpass = "subjectpass"
82 reasonSubjectpassError = "subjectpass-error"
83 reasonIPrev = "iprev" // No or mild junk reputation signals, and bad iprev.
84 reasonHighRate = "high-rate" // Too many messages, not added to rejects.
85)
86
87func isListDomain(d delivery, ld dns.Domain) bool {
88 if d.m.MailFromValidated && ld.Name() == d.m.MailFromDomain {
89 return true
90 }
91 for _, r := range d.dkimResults {
92 if r.Status == dkim.StatusPass && r.Sig.Domain == ld {
93 return true
94 }
95 }
96 return false
97}
98
99func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d delivery) analysis {
100 var headers string
101
102 // We don't want to let a single IP or network deliver too many messages to an
103 // account. They may fill up the mailbox, either with messages that have to be
104 // purged, or by filling the disk. We check both cases for IP's and networks.
105 var rateError bool // Whether returned error represents a rate error.
106 err := d.acc.DB.Read(ctx, func(tx *bstore.Tx) (retErr error) {
107 now := time.Now()
108 defer func() {
109 log.Debugx("checking message and size delivery rates", retErr, slog.Duration("duration", time.Since(now)))
110 }()
111
112 checkCount := func(msg store.Message, window time.Duration, limit int) {
113 if retErr != nil {
114 return
115 }
116 q := bstore.QueryTx[store.Message](tx)
117 q.FilterNonzero(msg)
118 q.FilterGreater("Received", now.Add(-window))
119 q.FilterEqual("Expunged", false)
120 n, err := q.Count()
121 if err != nil {
122 retErr = err
123 return
124 }
125 if n >= limit {
126 rateError = true
127 retErr = fmt.Errorf("more than %d messages in past %s from your ip/network", limit, window)
128 }
129 }
130
131 checkSize := func(msg store.Message, window time.Duration, limit int64) {
132 if retErr != nil {
133 return
134 }
135 q := bstore.QueryTx[store.Message](tx)
136 q.FilterNonzero(msg)
137 q.FilterGreater("Received", now.Add(-window))
138 q.FilterEqual("Expunged", false)
139 size := d.m.Size
140 err := q.ForEach(func(v store.Message) error {
141 size += v.Size
142 return nil
143 })
144 if err != nil {
145 retErr = err
146 return
147 }
148 if size > limit {
149 rateError = true
150 retErr = fmt.Errorf("more than %d bytes in past %s from your ip/network", limit, window)
151 }
152 }
153
154 // todo future: make these configurable
155 // todo: should we have a limit for forwarded messages? they are stored with empty RemoteIPMasked*
156
157 const day = 24 * time.Hour
158 checkCount(store.Message{RemoteIPMasked1: d.m.RemoteIPMasked1}, time.Minute, limitIPMasked1MessagesPerMinute)
159 checkCount(store.Message{RemoteIPMasked1: d.m.RemoteIPMasked1}, day, 20*500)
160 checkCount(store.Message{RemoteIPMasked2: d.m.RemoteIPMasked2}, time.Minute, 1500)
161 checkCount(store.Message{RemoteIPMasked2: d.m.RemoteIPMasked2}, day, 20*1500)
162 checkCount(store.Message{RemoteIPMasked3: d.m.RemoteIPMasked3}, time.Minute, 4500)
163 checkCount(store.Message{RemoteIPMasked3: d.m.RemoteIPMasked3}, day, 20*4500)
164
165 const MB = 1024 * 1024
166 checkSize(store.Message{RemoteIPMasked1: d.m.RemoteIPMasked1}, time.Minute, limitIPMasked1SizePerMinute)
167 checkSize(store.Message{RemoteIPMasked1: d.m.RemoteIPMasked1}, day, 3*1000*MB)
168 checkSize(store.Message{RemoteIPMasked2: d.m.RemoteIPMasked2}, time.Minute, 3000*MB)
169 checkSize(store.Message{RemoteIPMasked2: d.m.RemoteIPMasked2}, day, 3*3000*MB)
170 checkSize(store.Message{RemoteIPMasked3: d.m.RemoteIPMasked3}, time.Minute, 9000*MB)
171 checkSize(store.Message{RemoteIPMasked3: d.m.RemoteIPMasked3}, day, 3*9000*MB)
172
173 return retErr
174 })
175 if err != nil && !rateError {
176 log.Errorx("checking delivery rates", err)
177 metricDelivery.WithLabelValues("checkrates", "").Inc()
178 return analysis{d, false, "", smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing", err, nil, nil, reasonReputationError, "", headers}
179 } else if err != nil {
180 log.Debugx("refusing due to high delivery rate", err)
181 metricDelivery.WithLabelValues("highrate", "").Inc()
182 return analysis{d, false, "", smtp.C452StorageFull, smtp.SeMailbox2Full2, true, err.Error(), err, nil, nil, reasonHighRate, "", headers}
183 }
184
185 mailbox := d.destination.Mailbox
186 if mailbox == "" {
187 mailbox = "Inbox"
188 }
189
190 // If destination mailbox has a mailing list domain (for SPF/DKIM) configured,
191 // check it for a pass.
192 rs := store.MessageRuleset(log, d.destination, d.m, d.m.MsgPrefix, d.dataFile)
193 if rs != nil {
194 mailbox = rs.Mailbox
195 }
196 if rs != nil && !rs.ListAllowDNSDomain.IsZero() {
197 // todo: on temporary failures, reject temporarily?
198 if isListDomain(d, rs.ListAllowDNSDomain) {
199 d.m.IsMailingList = true
200 return analysis{d: d, accept: true, mailbox: mailbox, reason: reasonListAllow, dmarcOverrideReason: string(dmarcrpt.PolicyOverrideMailingList), headers: headers}
201 }
202 }
203
204 var dmarcOverrideReason string
205
206 // For forwarded messages, we have different junk analysis. We don't reject for
207 // failing DMARC, and we clear fields that could implicate the forwarding mail
208 // server during future classifications on incoming messages (the forwarding mail
209 // server isn't responsible for the message).
210 if rs != nil && rs.IsForward {
211 d.dmarcUse = false
212 d.m.IsForward = true
213 d.m.RemoteIPMasked1 = ""
214 d.m.RemoteIPMasked2 = ""
215 d.m.RemoteIPMasked3 = ""
216 d.m.OrigEHLODomain = d.m.EHLODomain
217 d.m.EHLODomain = ""
218 d.m.MailFromDomain = "" // Still available in MailFrom.
219 d.m.OrigDKIMDomains = d.m.DKIMDomains
220 dkimdoms := []string{}
221 for _, dom := range d.m.DKIMDomains {
222 if dom != rs.VerifiedDNSDomain.Name() {
223 dkimdoms = append(dkimdoms, dom)
224 }
225 }
226 d.m.DKIMDomains = dkimdoms
227 dmarcOverrideReason = string(dmarcrpt.PolicyOverrideForwarded)
228 log.Info("forwarded message, clearing identifying signals of forwarding mail server")
229 }
230
231 assignMailbox := func(tx *bstore.Tx) error {
232 // Set message MailboxID to which mail will be delivered. Reputation is
233 // per-mailbox. If referenced mailbox is not found (e.g. does not yet exist), we
234 // can still determine a reputation because we also base it on outgoing
235 // messages and those are account-global.
236 mb, err := d.acc.MailboxFind(tx, mailbox)
237 if err != nil {
238 return fmt.Errorf("finding destination mailbox: %w", err)
239 }
240 if mb != nil {
241 // We want to deliver to mb.ID, but this message may be rejected and sent to the
242 // Rejects mailbox instead, with MailboxID overwritten. Record the ID in
243 // MailboxDestinedID too. If the message is later moved out of the Rejects mailbox,
244 // we'll adjust the MailboxOrigID so it gets taken into account during reputation
245 // calculating in future deliveries. If we end up delivering to the intended
246 // mailbox (i.e. not rejecting), MailboxDestinedID is cleared during delivery so we
247 // don't store it unnecessarily.
248 d.m.MailboxID = mb.ID
249 d.m.MailboxDestinedID = mb.ID
250 } else {
251 log.Debug("mailbox not found in database", slog.String("mailbox", mailbox))
252 }
253 return nil
254 }
255
256 reject := func(code int, secode string, errmsg string, err error, reason string) analysis {
257 // We may have set MailboxDestinedID below already while we had a transaction. If
258 // not, do it now. This makes it possible to use the per-mailbox reputation when a
259 // user moves the message out of the Rejects mailbox to the intended mailbox
260 // (typically Inbox).
261 if d.m.MailboxDestinedID == 0 {
262 var mberr error
263 d.acc.WithRLock(func() {
264 mberr = d.acc.DB.Read(ctx, func(tx *bstore.Tx) error {
265 return assignMailbox(tx)
266 })
267 })
268 if mberr != nil {
269 return analysis{d, false, mailbox, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing", err, nil, nil, reasonReputationError, dmarcOverrideReason, headers}
270 }
271 d.m.MailboxID = 0 // We plan to reject, no need to set intended MailboxID.
272 }
273
274 accept := false
275 if rs != nil && rs.AcceptRejectsToMailbox != "" {
276 accept = true
277 mailbox = rs.AcceptRejectsToMailbox
278 d.m.IsReject = true
279 // Don't draw attention, but don't go so far as to mark as junk.
280 d.m.Seen = true
281 log.Info("accepting reject to configured mailbox due to ruleset")
282 }
283 return analysis{d, accept, mailbox, code, secode, err == nil, errmsg, err, nil, nil, reason, dmarcOverrideReason, headers}
284 }
285
286 if d.dmarcUse && d.dmarcResult.Reject {
287 return reject(smtp.C550MailboxUnavail, smtp.SePol7MultiAuthFails26, "rejecting per dmarc policy", nil, reasonDMARCPolicy)
288 }
289 // todo: should we also reject messages that have a dmarc pass but an spf record "v=spf1 -all"? suggested by m3aawg best practices.
290
291 // If destination is the DMARC reporting mailbox, do additional checks and keep
292 // track of the report. We'll check reputation, defaulting to accept.
293 var dmarcReport *dmarcrpt.Feedback
294 if d.destination.DMARCReports {
295 // Messages with DMARC aggregate reports must have a DMARC pass. ../rfc/7489:1866
296 if d.dmarcResult.Status != dmarc.StatusPass {
297 log.Info("received dmarc aggregate report without dmarc pass, not processing as dmarc report")
298 headers += "X-Mox-DMARCReport-Error: no DMARC pass\r\n"
299 } else if report, err := dmarcrpt.ParseMessageReport(log.Logger, store.FileMsgReader(d.m.MsgPrefix, d.dataFile)); err != nil {
300 log.Infox("parsing dmarc aggregate report", err)
301 headers += "X-Mox-DMARCReport-Error: could not parse report\r\n"
302 } else if d, err := dns.ParseDomain(report.PolicyPublished.Domain); err != nil {
303 log.Infox("parsing domain in dmarc aggregate report", err)
304 headers += "X-Mox-DMARCReport-Error: could not parse domain in published policy\r\n"
305 } else if _, ok := mox.Conf.Domain(d); !ok {
306 log.Info("dmarc aggregate report for domain not configured, ignoring", slog.Any("domain", d))
307 headers += "X-Mox-DMARCReport-Error: published policy domain unrecognized\r\n"
308 } else if report.ReportMetadata.DateRange.End > time.Now().Unix()+60 {
309 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)))
310 headers += "X-Mox-DMARCReport-Error: report has end date in the future\r\n"
311 } else {
312 dmarcReport = report
313 }
314 }
315
316 // Similar to DMARC reporting, we check for the required DKIM. We'll check
317 // reputation, defaulting to accept.
318 var tlsReport *tlsrpt.Report
319 if d.destination.HostTLSReports || d.destination.DomainTLSReports {
320 matchesDomain := func(sigDomain dns.Domain) bool {
321 // RFC seems to require exact DKIM domain match with submitt and message From, we
322 // also allow msgFrom to be subdomain. ../rfc/8460:322
323 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)
324 }
325 // Valid DKIM signature for domain must be present. We take "valid" to assume
326 // "passing", not "syntactically valid". We also check for "tlsrpt" as service.
327 // This check is optional, but if anyone goes through the trouble to explicitly
328 // list allowed services, they would be surprised to see them ignored.
329 // ../rfc/8460:320
330 ok := false
331 for _, r := range d.dkimResults {
332 // The record should have an allowed service "tlsrpt". The RFC mentions it as if
333 // the service must be specified explicitly, but the default allowed services for a
334 // DKIM record are "*", which includes "tlsrpt". Unless a DKIM record explicitly
335 // specifies services (e.g. s=email), a record will work for TLS reports. The DKIM
336 // records seen used for TLS reporting in the wild don't explicitly set "s" for
337 // services.
338 // ../rfc/8460:326
339 if r.Status == dkim.StatusPass && matchesDomain(r.Sig.Domain) && r.Sig.Length < 0 && r.Record.ServiceAllowed("tlsrpt") {
340 ok = true
341 break
342 }
343 }
344
345 if !ok {
346 log.Info("received mail to tlsrpt without acceptable DKIM signature, not processing as tls report")
347 headers += "X-Mox-TLSReport-Error: no acceptable DKIM signature\r\n"
348 } else if reportJSON, err := tlsrpt.ParseMessage(log.Logger, store.FileMsgReader(d.m.MsgPrefix, d.dataFile)); err != nil {
349 log.Infox("parsing tls report", err)
350 headers += "X-Mox-TLSReport-Error: could not parse TLS report\r\n"
351 } else {
352 var known bool
353 for _, p := range reportJSON.Policies {
354 log.Info("tlsrpt policy domain", slog.String("domain", p.Policy.Domain))
355 if d, err := dns.ParseDomain(p.Policy.Domain); err != nil {
356 log.Infox("parsing domain in tls report", err)
357 } else if _, ok := mox.Conf.Domain(d); ok || d == mox.Conf.Static.HostnameDomain {
358 known = true
359 break
360 }
361 }
362 if !known {
363 log.Info("tls report without one of configured domains, ignoring")
364 headers += "X-Mox-TLSReport-Error: report for unknown domain\r\n"
365 } else {
366 report := reportJSON.Convert()
367 tlsReport = &report
368 }
369 }
370 }
371
372 // Determine if message is acceptable based on DMARC domain, DKIM identities, or
373 // host-based reputation.
374 var isjunk *bool
375 var conclusive bool
376 var method reputationMethod
377 var reason string
378 d.acc.WithRLock(func() {
379 err = d.acc.DB.Read(ctx, func(tx *bstore.Tx) error {
380 if err := assignMailbox(tx); err != nil {
381 return err
382 }
383
384 isjunk, conclusive, method, err = reputation(tx, log, d.m)
385 reason = string(method)
386 return err
387 })
388 })
389 if err != nil {
390 log.Infox("determining reputation", err, slog.Any("message", d.m))
391 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonReputationError)
392 }
393 log.Info("reputation analyzed",
394 slog.Bool("conclusive", conclusive),
395 slog.Any("isjunk", isjunk),
396 slog.String("method", string(method)))
397 if conclusive {
398 if !*isjunk {
399 return analysis{d: d, accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reason, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
400 }
401 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, string(method))
402 } else if dmarcReport != nil || tlsReport != nil {
403 log.Info("accepting message with dmarc aggregate report or tls report without reputation")
404 return analysis{d: d, accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reasonReporting, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
405 }
406 // If there was no previous message from sender or its domain, and we have an SPF
407 // (soft)fail, reject the message.
408 switch method {
409 case methodDKIMSPF, methodIP1, methodIP2, methodIP3, methodNone:
410 switch d.m.MailFromValidation {
411 case store.ValidationFail, store.ValidationSoftfail:
412 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", nil, reasonSPFPolicy)
413 }
414 }
415
416 // Senders without reputation and without iprev pass, are likely spam.
417 var suspiciousIPrevFail bool
418 switch method {
419 case methodDKIMSPF, methodIP1, methodIP2, methodIP3, methodNone:
420 suspiciousIPrevFail = d.iprevStatus != iprev.StatusPass
421 }
422
423 // With already a mild junk signal, an iprev fail on top is enough to reject.
424 if suspiciousIPrevFail && isjunk != nil && *isjunk {
425 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", nil, reasonIPrev)
426 }
427
428 var subjectpassKey string
429 conf, _ := d.acc.Conf()
430 if conf.SubjectPass.Period > 0 {
431 subjectpassKey, err = d.acc.Subjectpass(d.canonicalAddress)
432 if err != nil {
433 log.Errorx("get key for verifying subject token", err)
434 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonSubjectpassError)
435 }
436 err = subjectpass.Verify(log.Logger, d.dataFile, []byte(subjectpassKey), conf.SubjectPass.Period)
437 pass := err == nil
438 log.Infox("pass by subject token", err, slog.Bool("pass", pass))
439 if pass {
440 return analysis{d: d, accept: true, mailbox: mailbox, reason: reasonSubjectpass, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
441 }
442 }
443
444 reason = reasonNoBadSignals
445 accept := true
446 var junkSubjectpass bool
447 f, jf, err := d.acc.OpenJunkFilter(ctx, log)
448 if err == nil {
449 defer func() {
450 err := f.Close()
451 log.Check(err, "closing junkfilter")
452 }()
453 contentProb, _, _, _, err := f.ClassifyMessageReader(ctx, store.FileMsgReader(d.m.MsgPrefix, d.dataFile), d.m.Size)
454 if err != nil {
455 log.Errorx("testing for spam", err)
456 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonJunkClassifyError)
457 }
458 // 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?
459 // todo: if there aren't enough historic messages, we should just let messages in.
460 // 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...
461
462 // If we don't accept, we may still respond with a "subjectpass" hint below.
463 // We add some jitter to the threshold we use. So we don't act as too easy an
464 // oracle for words that are a strong indicator of haminess.
465 // todo: we should rate-limit uses of the junkfilter.
466 jitter := (jitterRand.Float64() - 0.5) / 10
467 threshold := jf.Threshold + jitter
468
469 rcptToMatch := func(l []message.Address) bool {
470 // 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
471 if d.smtpRcptTo.Localpart == "" {
472 return true
473 }
474 for _, a := range l {
475 dom, err := dns.ParseDomain(a.Host)
476 if err != nil {
477 continue
478 }
479 lp, err := smtp.ParseLocalpart(a.User)
480 if err == nil && dom == d.smtpRcptTo.IPDomain.Domain && lp == d.smtpRcptTo.Localpart {
481 return true
482 }
483 }
484 return false
485 }
486
487 // 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.
488 // With an iprev fail, non-TLS connection or our address not in To/Cc header, we set a higher bar for content.
489 reason = reasonJunkContent
490 if suspiciousIPrevFail && threshold > 0.25 {
491 threshold = 0.25
492 log.Info("setting junk threshold due to iprev fail", slog.Float64("threshold", threshold))
493 reason = reasonJunkContentStrict
494 } else if !d.tls && threshold > 0.25 {
495 threshold = 0.25
496 log.Info("setting junk threshold due to plaintext smtp", slog.Float64("threshold", threshold))
497 reason = reasonJunkContentStrict
498 } else if (rs == nil || !rs.IsForward) && threshold > 0.25 && !rcptToMatch(d.msgTo) && !rcptToMatch(d.msgCc) {
499 // A common theme in junk messages is your recipient address not being in the To/Cc
500 // headers. We may be in Bcc, but that's unusual for first-time senders. Some
501 // providers (e.g. gmail) does not DKIM-sign Bcc headers, so junk messages can be
502 // sent with matching Bcc headers. We don't get here for known senders.
503 threshold = 0.25
504 log.Info("setting junk threshold due to smtp rcpt to and message to/cc address mismatch", slog.Float64("threshold", threshold))
505 reason = reasonJunkContentStrict
506 }
507 accept = contentProb <= threshold
508 junkSubjectpass = contentProb < threshold-0.2
509 log.Info("content analyzed",
510 slog.Bool("accept", accept),
511 slog.Float64("contentprob", contentProb),
512 slog.Bool("subjectpass", junkSubjectpass))
513 } else if err != store.ErrNoJunkFilter {
514 log.Errorx("open junkfilter", err)
515 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonJunkFilterError)
516 }
517
518 // If content looks good, we'll still look at DNS block lists for a reason to
519 // reject. We normally won't get here if we've communicated with this sender
520 // before.
521 var dnsblocklisted bool
522 if accept {
523 blocked := func(zone dns.Domain) bool {
524 dnsblctx, dnsblcancel := context.WithTimeout(ctx, 30*time.Second)
525 defer dnsblcancel()
526 if !checkDNSBLHealth(dnsblctx, log, resolver, zone) {
527 log.Info("dnsbl not healthy, skipping", slog.Any("zone", zone))
528 return false
529 }
530
531 status, expl, err := dnsbl.Lookup(dnsblctx, log.Logger, resolver, zone, net.ParseIP(d.m.RemoteIP))
532 dnsblcancel()
533 if status == dnsbl.StatusFail {
534 log.Info("rejecting due to listing in dnsbl", slog.Any("zone", zone), slog.String("explanation", expl))
535 return true
536 } else if err != nil {
537 log.Infox("dnsbl lookup", err, slog.Any("zone", zone), slog.Any("status", status))
538 }
539 return false
540 }
541
542 // Note: We don't check in parallel, we are in no hurry to accept possible spam.
543 for _, zone := range d.dnsBLs {
544 if blocked(zone) {
545 accept = false
546 dnsblocklisted = true
547 reason = reasonDNSBlocklisted
548 break
549 }
550 }
551 }
552
553 if accept {
554 return analysis{d: d, accept: true, mailbox: mailbox, reason: reasonNoBadSignals, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
555 }
556
557 if subjectpassKey != "" && d.dmarcResult.Status == dmarc.StatusPass && method == methodNone && (dnsblocklisted || junkSubjectpass) {
558 log.Info("permanent reject with subjectpass hint of moderately spammy email without reputation")
559 pass := subjectpass.Generate(log.Logger, d.msgFrom, []byte(subjectpassKey), time.Now())
560 return reject(smtp.C550MailboxUnavail, smtp.SePol7DeliveryUnauth1, subjectpass.Explanation+pass, nil, reasonGiveSubjectpass)
561 }
562
563 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", nil, reason)
564}
565