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 smtputf8 bool
49}
50
51type analysis struct {
52 d delivery
53 accept bool
54 mailbox string
55 code int
56 secode string
57 userError bool
58 errmsg string
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.
67 headers string
68}
69
70const (
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.
87)
88
89func isListDomain(d delivery, ld dns.Domain) bool {
90 if d.m.MailFromValidated && ld.Name() == d.m.MailFromDomain {
91 return true
92 }
93 for _, r := range d.dkimResults {
94 if r.Status == dkim.StatusPass && r.Sig.Domain == ld {
95 return true
96 }
97 }
98 return false
99}
100
101func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d delivery) analysis {
102 var headers string
103
104 var reasonText []string
105 addReasonText := func(format string, args ...any) {
106 s := fmt.Sprintf(format, args...)
107 reasonText = append(reasonText, s)
108 }
109
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) {
115 now := time.Now()
116 defer func() {
117 log.Debugx("checking message and size delivery rates", retErr, slog.Duration("duration", time.Since(now)))
118 }()
119
120 checkCount := func(msg store.Message, window time.Duration, limit int) {
121 if retErr != nil {
122 return
123 }
124 q := bstore.QueryTx[store.Message](tx)
125 q.FilterNonzero(msg)
126 q.FilterGreater("Received", now.Add(-window))
127 q.FilterEqual("Expunged", false)
128 n, err := q.Count()
129 if err != nil {
130 retErr = err
131 return
132 }
133 if n >= limit {
134 rateError = true
135 retErr = fmt.Errorf("more than %d messages in past %s from your ip/network", limit, window)
136 }
137 }
138
139 checkSize := func(msg store.Message, window time.Duration, limit int64) {
140 if retErr != nil {
141 return
142 }
143 q := bstore.QueryTx[store.Message](tx)
144 q.FilterNonzero(msg)
145 q.FilterGreater("Received", now.Add(-window))
146 q.FilterEqual("Expunged", false)
147 size := d.m.Size
148 err := q.ForEach(func(v store.Message) error {
149 size += v.Size
150 return nil
151 })
152 if err != nil {
153 retErr = err
154 return
155 }
156 if size > limit {
157 rateError = true
158 retErr = fmt.Errorf("more than %d bytes in past %s from your ip/network", limit, window)
159 }
160 }
161
162 // todo future: make these configurable
163 // todo: should we have a limit for forwarded messages? they are stored with empty RemoteIPMasked*
164
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)
172
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)
180
181 return retErr
182 })
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}
193 }
194
195 mailbox := d.destination.Mailbox
196 if mailbox == "" {
197 mailbox = "Inbox"
198 }
199
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)
203 if rs != nil {
204 mailbox = rs.Mailbox
205 }
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
211 return analysis{
212 d: d,
213 accept: true,
214 mailbox: mailbox,
215 reason: reasonListAllow,
216 reasonText: reasonText,
217 dmarcOverrideReason: string(dmarcrpt.PolicyOverrideMailingList),
218 headers: headers,
219 }
220 }
221 }
222
223 var dmarcOverrideReason string
224
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 {
230 d.dmarcUse = false
231 d.m.IsForward = true
232 d.m.RemoteIPMasked1 = ""
233 d.m.RemoteIPMasked2 = ""
234 d.m.RemoteIPMasked3 = ""
235 d.m.OrigEHLODomain = d.m.EHLODomain
236 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)
243 }
244 }
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")
249 }
250
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)
257 if err != nil {
258 return fmt.Errorf("finding destination mailbox: %w", err)
259 }
260 if mb != nil {
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
270 } else {
271 log.Debug("mailbox not found in database", slog.String("mailbox", mailbox))
272 }
273 return nil
274 }
275
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 {
282 var mberr error
283 d.acc.WithRLock(func() {
284 mberr = d.acc.DB.Read(ctx, func(tx *bstore.Tx) error {
285 return assignMailbox(tx)
286 })
287 })
288 if mberr != nil {
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}
291 }
292 d.m.MailboxID = 0 // We plan to reject, no need to set intended MailboxID.
293 }
294
295 accept := false
296 if rs != nil && rs.AcceptRejectsToMailbox != "" {
297 accept = true
298 mailbox = rs.AcceptRejectsToMailbox
299 d.m.IsReject = true
300 // Don't draw attention, but don't go so far as to mark as junk.
301 d.m.Seen = true
302 log.Info("accepting reject to configured mailbox due to ruleset")
303 addReasonText("accepting reject to mailbox due to ruleset")
304 }
305 return analysis{d, accept, mailbox, code, secode, err == nil, errmsg, err, nil, nil, reason, reasonText, dmarcOverrideReason, headers}
306 }
307
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")
313 } else {
314 addReasonText("dmarc ok")
315 }
316 // todo: should we also reject messages that have a dmarc pass but an spf record "v=spf1 -all"? suggested by m3aawg best practices.
317
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 {
322 // Messages with DMARC aggregate reports must have a DMARC pass. ../rfc/7489:1866
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"
338 } else {
339 dmarcReport = report
340 }
341 }
342
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
349 // also allow msgFrom to be subdomain. ../rfc/8460:322
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)
351 }
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.
356 // ../rfc/8460:320
357 ok := false
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
364 // services.
365 // ../rfc/8460:326
366 if r.Status == dkim.StatusPass && matchesDomain(r.Sig.Domain) && r.Sig.Length < 0 && r.Record.ServiceAllowed("tlsrpt") {
367 ok = true
368 break
369 }
370 }
371
372 if !ok {
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"
378 } else {
379 var known bool
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 {
385 known = true
386 break
387 }
388 }
389 if !known {
390 log.Info("tls report without one of configured domains, ignoring")
391 headers += "X-Mox-TLSReport-Error: report for unknown domain\r\n"
392 } else {
393 report := reportJSON.Convert()
394 tlsReport = &report
395 }
396 }
397 }
398
399 // Determine if message is acceptable based on DMARC domain, DKIM identities, or
400 // host-based reputation.
401 var isjunk *bool
402 var conclusive bool
403 var method reputationMethod
404 var reason string
405 d.acc.WithRLock(func() {
406 err = d.acc.DB.Read(ctx, func(tx *bstore.Tx) error {
407 if err := assignMailbox(tx); err != nil {
408 return err
409 }
410
411 var text string
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 {
416 s += "junk, "
417 } else if isjunk != nil && !*isjunk {
418 s += "nonjunk, "
419 }
420 if conclusive {
421 s += "conclusive"
422 } else {
423 s += "inconclusive"
424 }
425 s += ", " + text + ")"
426 addReasonText("%s", s)
427 return err
428 })
429 })
430 if err != nil {
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)
434 }
435 log.Info("reputation analyzed",
436 slog.Bool("conclusive", conclusive),
437 slog.Any("isjunk", isjunk),
438 slog.String("method", string(method)))
439 if conclusive {
440 if !*isjunk {
441 return analysis{
442 d: d,
443 accept: true,
444 mailbox: mailbox,
445 dmarcReport: dmarcReport,
446 tlsReport: tlsReport,
447 reason: reason,
448 reasonText: reasonText,
449 dmarcOverrideReason: dmarcOverrideReason,
450 headers: headers,
451 }
452 }
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")
457 return analysis{
458 d: d,
459 accept: true,
460 mailbox: mailbox,
461 dmarcReport: dmarcReport,
462 tlsReport: tlsReport,
463 reason: reasonReporting,
464 reasonText: reasonText,
465 dmarcOverrideReason: dmarcOverrideReason,
466 headers: headers,
467 }
468 }
469 // If there was no previous message from sender or its domain, and we have an SPF
470 // (soft)fail, reject the message.
471 switch method {
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)
477 }
478 }
479
480 // Senders without reputation and without iprev pass, are likely spam.
481 var suspiciousIPrevFail bool
482 switch method {
483 case methodDKIMSPF, methodIP1, methodIP2, methodIP3, methodNone:
484 suspiciousIPrevFail = d.iprevStatus != iprev.StatusPass
485 }
486 if suspiciousIPrevFail {
487 addReasonText("suspicious iprev failure")
488 }
489
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)
494 }
495
496 var subjectpassKey string
497 conf, _ := d.acc.Conf()
498 if conf.SubjectPass.Period > 0 {
499 subjectpassKey, err = d.acc.Subjectpass(d.canonicalAddress)
500 if err != nil {
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)
504 }
505 err = subjectpass.Verify(log.Logger, d.dataFile, []byte(subjectpassKey), conf.SubjectPass.Period)
506 pass := err == nil
507 log.Infox("pass by subject token", err, slog.Bool("pass", pass))
508 if pass {
509 addReasonText("message has valid subjectpass token in subject")
510 return analysis{
511 d: d,
512 accept: true,
513 mailbox: mailbox,
514 reason: reasonSubjectpass,
515 reasonText: reasonText,
516 dmarcOverrideReason: dmarcOverrideReason,
517 headers: headers,
518 }
519 }
520 }
521
522 reason = reasonNoBadSignals
523 accept := true
524 var junkSubjectpass bool
525 f, jf, err := d.acc.OpenJunkFilter(ctx, log)
526 if err == nil {
527 defer func() {
528 err := f.Close()
529 log.Check(err, "closing junkfilter")
530 }()
531 contentProb, _, hams, spams, err := f.ClassifyMessageReader(ctx, store.FileMsgReader(d.m.MsgPrefix, d.dataFile), d.m.Size)
532 if err != nil {
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)
536 }
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...
540
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
547
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 == "" {
551 return true
552 }
553 for _, a := range l {
554 dom, err := dns.ParseDomain(a.Host)
555 if err != nil {
556 continue
557 }
558 lp, err := smtp.ParseLocalpart(a.User)
559 if err == nil && dom == d.smtpRcptTo.IPDomain.Domain && lp == d.smtpRcptTo.Localpart {
560 return true
561 }
562 }
563 return false
564 }
565
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 {
571 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 {
576 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.
585 threshold = 0.25
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)"
589 }
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))
596
597 s := "content: "
598 if accept {
599 s += "not junk"
600 } else {
601 s += "junk"
602 }
603 s += fmt.Sprintf(", spamscore %.2f, threshold %.2f%s", contentProb, threshold, thresholdRemark)
604 s += "(ham words: "
605 for i, w := range hams {
606 if i > 0 {
607 s += ", "
608 }
609 word := w.Word
610 if !d.smtputf8 && !isASCII(word) {
611 word = "(non-ascii)"
612 }
613 s += fmt.Sprintf("%s %.3f", word, w.Score)
614 }
615 s += "), (spam words: "
616 for i, w := range spams {
617 if i > 0 {
618 s += ", "
619 }
620 word := w.Word
621 if !d.smtputf8 && !isASCII(word) {
622 word = "(non-ascii)"
623 }
624 s += fmt.Sprintf("%s %.3f", word, w.Score)
625 }
626 s += ")"
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)
632 } else {
633 addReasonText("no junk filter configured")
634 }
635
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
638 // before.
639 var dnsblocklisted bool
640 if accept {
641 blocked := func(zone dns.Domain) bool {
642 dnsblctx, dnsblcancel := context.WithTimeout(ctx, 30*time.Second)
643 defer dnsblcancel()
644 if !checkDNSBLHealth(dnsblctx, log, resolver, zone) {
645 log.Info("dnsbl not healthy, skipping", slog.Any("zone", zone))
646 return false
647 }
648
649 status, expl, err := dnsbl.Lookup(dnsblctx, log.Logger, resolver, zone, net.ParseIP(d.m.RemoteIP))
650 dnsblcancel()
651 if status == dnsbl.StatusFail {
652 log.Info("rejecting due to listing in dnsbl", slog.Any("zone", zone), slog.String("explanation", expl))
653 return true
654 } else if err != nil {
655 log.Infox("dnsbl lookup", err, slog.Any("zone", zone), slog.Any("status", status))
656 }
657 return false
658 }
659
660 // Note: We don't check in parallel, we are in no hurry to accept possible spam.
661 for _, zone := range d.dnsBLs {
662 if blocked(zone) {
663 accept = false
664 dnsblocklisted = true
665 reason = reasonDNSBlocklisted
666 addReasonText("dnsbl: ip %s listed in dnsbl %s", d.m.RemoteIP, zone.XName(d.smtputf8))
667 break
668 }
669 }
670 if !dnsblocklisted && len(d.dnsBLs) > 0 {
671 addReasonText("remote ip not blocklisted")
672 }
673 }
674
675 if accept {
676 addReasonText("no known reputation and no bad signals")
677 return analysis{
678 d: d,
679 accept: true,
680 mailbox: mailbox,
681 reason: reasonNoBadSignals,
682 reasonText: reasonText,
683 dmarcOverrideReason: dmarcOverrideReason,
684 headers: headers,
685 }
686 }
687
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)
693 }
694
695 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", nil, reason)
696}
697
698func isASCII(s string) bool {
699 for _, b := range []byte(s) {
700 if b >= 0x80 {
701 return false
702 }
703 }
704 return true
705}
706