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
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.
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.
87func isListDomain(d delivery, ld dns.Domain) bool {
88 if d.m.MailFromValidated && ld.Name() == d.m.MailFromDomain {
91 for _, r := range d.dkimResults {
92 if r.Status == dkim.StatusPass && r.Sig.Domain == ld {
99func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d delivery) analysis {
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) {
109 log.Debugx("checking message and size delivery rates", retErr, slog.Duration("duration", time.Since(now)))
112 checkCount := func(msg store.Message, window time.Duration, limit int) {
116 q := bstore.QueryTx[store.Message](tx)
118 q.FilterGreater("Received", now.Add(-window))
119 q.FilterEqual("Expunged", false)
127 retErr = fmt.Errorf("more than %d messages in past %s from your ip/network", limit, window)
131 checkSize := func(msg store.Message, window time.Duration, limit int64) {
135 q := bstore.QueryTx[store.Message](tx)
137 q.FilterGreater("Received", now.Add(-window))
138 q.FilterEqual("Expunged", false)
140 err := q.ForEach(func(v store.Message) error {
150 retErr = fmt.Errorf("more than %d bytes in past %s from your ip/network", limit, window)
154 // todo future: make these configurable
155 // todo: should we have a limit for forwarded messages? they are stored with empty RemoteIPMasked*
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)
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)
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}
185 mailbox := d.destination.Mailbox
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)
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}
204 var dmarcOverrideReason string
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 {
213 d.m.RemoteIPMasked1 = ""
214 d.m.RemoteIPMasked2 = ""
215 d.m.RemoteIPMasked3 = ""
216 d.m.OrigEHLODomain = 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)
226 d.m.DKIMDomains = dkimdoms
227 dmarcOverrideReason = string(dmarcrpt.PolicyOverrideForwarded)
228 log.Info("forwarded message, clearing identifying signals of forwarding mail server")
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)
238 return fmt.Errorf("finding destination mailbox: %w", err)
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
251 log.Debug("mailbox not found in database", slog.String("mailbox", mailbox))
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 {
263 d.acc.WithRLock(func() {
264 mberr = d.acc.DB.Read(ctx, func(tx *bstore.Tx) error {
265 return assignMailbox(tx)
269 return analysis{d, false, mailbox, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing", err, nil, nil, reasonReputationError, dmarcOverrideReason, headers}
271 d.m.MailboxID = 0 // We plan to reject, no need to set intended MailboxID.
275 if rs != nil && rs.AcceptRejectsToMailbox != "" {
277 mailbox = rs.AcceptRejectsToMailbox
279 // Don't draw attention, but don't go so far as to mark as junk.
281 log.Info("accepting reject to configured mailbox due to ruleset")
283 return analysis{d, accept, mailbox, code, secode, err == nil, errmsg, err, nil, nil, reason, dmarcOverrideReason, headers}
286 if d.dmarcUse && d.dmarcResult.Reject {
287 return reject(smtp.C550MailboxUnavail, smtp.SePol7MultiAuthFails26, "rejecting per dmarc policy", nil, reasonDMARCPolicy)
289 // todo: should we also reject messages that have a dmarc pass but an spf record "v=spf1 -all"? suggested by m3aawg best practices.
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 {
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"
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
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)
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.
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
339 if r.Status == dkim.StatusPass && matchesDomain(r.Sig.Domain) && r.Sig.Length < 0 && r.Record.ServiceAllowed("tlsrpt") {
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"
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 {
363 log.Info("tls report without one of configured domains, ignoring")
364 headers += "X-Mox-TLSReport-Error: report for unknown domain\r\n"
366 report := reportJSON.Convert()
372 // Determine if message is acceptable based on DMARC domain, DKIM identities, or
373 // host-based reputation.
376 var method reputationMethod
378 d.acc.WithRLock(func() {
379 err = d.acc.DB.Read(ctx, func(tx *bstore.Tx) error {
380 if err := assignMailbox(tx); err != nil {
384 isjunk, conclusive, method, err = reputation(tx, log, d.m)
385 reason = string(method)
390 log.Infox("determining reputation", err, slog.Any("message", d.m))
391 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonReputationError)
393 log.Info("reputation analyzed",
394 slog.Bool("conclusive", conclusive),
395 slog.Any("isjunk", isjunk),
396 slog.String("method", string(method)))
399 return analysis{d: d, accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reason, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
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}
406 // If there was no previous message from sender or its domain, and we have an SPF
407 // (soft)fail, reject the message.
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)
416 // Senders without reputation and without iprev pass, are likely spam.
417 var suspiciousIPrevFail bool
419 case methodDKIMSPF, methodIP1, methodIP2, methodIP3, methodNone:
420 suspiciousIPrevFail = d.iprevStatus != iprev.StatusPass
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)
428 var subjectpassKey string
429 conf, _ := d.acc.Conf()
430 if conf.SubjectPass.Period > 0 {
431 subjectpassKey, err = d.acc.Subjectpass(d.canonicalAddress)
433 log.Errorx("get key for verifying subject token", err)
434 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonSubjectpassError)
436 err = subjectpass.Verify(log.Logger, d.dataFile, []byte(subjectpassKey), conf.SubjectPass.Period)
438 log.Infox("pass by subject token", err, slog.Bool("pass", pass))
440 return analysis{d: d, accept: true, mailbox: mailbox, reason: reasonSubjectpass, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
444 reason = reasonNoBadSignals
446 var junkSubjectpass bool
447 f, jf, err := d.acc.OpenJunkFilter(ctx, log)
451 log.Check(err, "closing junkfilter")
453 contentProb, _, _, _, err := f.ClassifyMessageReader(ctx, store.FileMsgReader(d.m.MsgPrefix, d.dataFile), d.m.Size)
455 log.Errorx("testing for spam", err)
456 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonJunkClassifyError)
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...
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
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 == "" {
474 for _, a := range l {
475 dom, err := dns.ParseDomain(a.Host)
479 lp, err := smtp.ParseLocalpart(a.User)
480 if err == nil && dom == d.smtpRcptTo.IPDomain.Domain && lp == d.smtpRcptTo.Localpart {
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 {
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 {
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.
504 log.Info("setting junk threshold due to smtp rcpt to and message to/cc address mismatch", slog.Float64("threshold", threshold))
505 reason = reasonJunkContentStrict
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)
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
521 var dnsblocklisted bool
523 blocked := func(zone dns.Domain) bool {
524 dnsblctx, dnsblcancel := context.WithTimeout(ctx, 30*time.Second)
526 if !checkDNSBLHealth(dnsblctx, log, resolver, zone) {
527 log.Info("dnsbl not healthy, skipping", slog.Any("zone", zone))
531 status, expl, err := dnsbl.Lookup(dnsblctx, log.Logger, resolver, zone, net.ParseIP(d.m.RemoteIP))
533 if status == dnsbl.StatusFail {
534 log.Info("rejecting due to listing in dnsbl", slog.Any("zone", zone), slog.String("explanation", expl))
536 } else if err != nil {
537 log.Infox("dnsbl lookup", err, slog.Any("zone", zone), slog.Any("status", status))
542 // Note: We don't check in parallel, we are in no hurry to accept possible spam.
543 for _, zone := range d.dnsBLs {
546 dnsblocklisted = true
547 reason = reasonDNSBlocklisted
554 return analysis{d: d, accept: true, mailbox: mailbox, reason: reasonNoBadSignals, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
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)
563 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", nil, reason)