12 "github.com/mjl-/bstore"
14 "github.com/mjl-/mox/dkim"
15 "github.com/mjl-/mox/dmarc"
16 "github.com/mjl-/mox/dmarcrpt"
17 "github.com/mjl-/mox/dns"
18 "github.com/mjl-/mox/dnsbl"
19 "github.com/mjl-/mox/iprev"
20 "github.com/mjl-/mox/message"
21 "github.com/mjl-/mox/mlog"
22 "github.com/mjl-/mox/mox-"
23 "github.com/mjl-/mox/publicsuffix"
24 "github.com/mjl-/mox/smtp"
25 "github.com/mjl-/mox/store"
26 "github.com/mjl-/mox/subjectpass"
27 "github.com/mjl-/mox/tlsrpt"
36 msgTo []message.Address
37 msgCc []message.Address
41 dmarcResult dmarc.Result
42 dkimResults []dkim.Result
43 iprevStatus iprev.Status
53 err error // For our own logging, not sent to remote.
54 dmarcReport *dmarcrpt.Feedback // Validated DMARC aggregate report, not yet stored.
55 tlsReport *tlsrpt.Report // Validated TLS report, not yet stored.
56 reason string // If non-empty, reason for this decision. Can be one of reputationMethod and a few other tokens.
57 dmarcOverrideReason string // If set, one of dmarcrpt.PolicyOverride
58 // Additional headers to add during delivery. Used for reasons a message to a
59 // dmarc/tls reporting address isn't processed.
64 reasonListAllow = "list-allow"
65 reasonDMARCPolicy = "dmarc-policy"
66 reasonReputationError = "reputation-error"
67 reasonReporting = "reporting"
68 reasonSPFPolicy = "spf-policy"
69 reasonJunkClassifyError = "junk-classify-error"
70 reasonJunkFilterError = "junk-filter-error"
71 reasonGiveSubjectpass = "give-subjectpass"
72 reasonNoBadSignals = "no-bad-signals"
73 reasonJunkContent = "junk-content"
74 reasonJunkContentStrict = "junk-content-strict"
75 reasonDNSBlocklisted = "dns-blocklisted"
76 reasonSubjectpass = "subjectpass"
77 reasonSubjectpassError = "subjectpass-error"
78 reasonIPrev = "iprev" // No or mild junk reputation signals, and bad iprev.
81func isListDomain(d delivery, ld dns.Domain) bool {
82 if d.m.MailFromValidated && ld.Name() == d.m.MailFromDomain {
85 for _, r := range d.dkimResults {
86 if r.Status == dkim.StatusPass && r.Sig.Domain == ld {
93func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d delivery) analysis {
96 mailbox := d.rcptAcc.destination.Mailbox
101 // If destination mailbox has a mailing list domain (for SPF/DKIM) configured,
102 // check it for a pass.
103 rs := store.MessageRuleset(log, d.rcptAcc.destination, d.m, d.m.MsgPrefix, d.dataFile)
107 if rs != nil && !rs.ListAllowDNSDomain.IsZero() {
108 // todo: on temporary failures, reject temporarily?
109 if isListDomain(d, rs.ListAllowDNSDomain) {
110 d.m.IsMailingList = true
111 return analysis{accept: true, mailbox: mailbox, reason: reasonListAllow, dmarcOverrideReason: string(dmarcrpt.PolicyOverrideMailingList), headers: headers}
115 var dmarcOverrideReason string
117 // For forwarded messages, we have different junk analysis. We don't reject for
118 // failing DMARC, and we clear fields that could implicate the forwarding mail
119 // server during future classifications on incoming messages (the forwarding mail
120 // server isn't responsible for the message).
121 if rs != nil && rs.IsForward {
124 d.m.RemoteIPMasked1 = ""
125 d.m.RemoteIPMasked2 = ""
126 d.m.RemoteIPMasked3 = ""
127 d.m.OrigEHLODomain = d.m.EHLODomain
129 d.m.MailFromDomain = "" // Still available in MailFrom.
130 d.m.OrigDKIMDomains = d.m.DKIMDomains
131 dkimdoms := []string{}
132 for _, dom := range d.m.DKIMDomains {
133 if dom != rs.VerifiedDNSDomain.Name() {
134 dkimdoms = append(dkimdoms, dom)
137 d.m.DKIMDomains = dkimdoms
138 dmarcOverrideReason = string(dmarcrpt.PolicyOverrideForwarded)
139 log.Info("forwarded message, clearing identifying signals of forwarding mail server")
142 assignMailbox := func(tx *bstore.Tx) error {
143 // Set message MailboxID to which mail will be delivered. Reputation is
144 // per-mailbox. If referenced mailbox is not found (e.g. does not yet exist), we
145 // can still determine a reputation because we also base it on outgoing
146 // messages and those are account-global.
147 mb, err := d.acc.MailboxFind(tx, mailbox)
149 return fmt.Errorf("finding destination mailbox: %w", err)
152 // We want to deliver to mb.ID, but this message may be rejected and sent to the
153 // Rejects mailbox instead, with MailboxID overwritten. Record the ID in
154 // MailboxDestinedID too. If the message is later moved out of the Rejects mailbox,
155 // we'll adjust the MailboxOrigID so it gets taken into account during reputation
156 // calculating in future deliveries. If we end up delivering to the intended
157 // mailbox (i.e. not rejecting), MailboxDestinedID is cleared during delivery so we
158 // don't store it unnecessarily.
159 d.m.MailboxID = mb.ID
160 d.m.MailboxDestinedID = mb.ID
162 log.Debug("mailbox not found in database", slog.String("mailbox", mailbox))
167 reject := func(code int, secode string, errmsg string, err error, reason string) analysis {
168 // We may have set MailboxDestinedID below already while we had a transaction. If
169 // not, do it now. This makes it possible to use the per-mailbox reputation when a
170 // user moves the message out of the Rejects mailbox to the intended mailbox
171 // (typically Inbox).
172 if d.m.MailboxDestinedID == 0 {
174 d.acc.WithRLock(func() {
175 mberr = d.acc.DB.Read(ctx, func(tx *bstore.Tx) error {
176 return assignMailbox(tx)
180 return analysis{false, mailbox, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing", err, nil, nil, reasonReputationError, dmarcOverrideReason, headers}
182 d.m.MailboxID = 0 // We plan to reject, no need to set intended MailboxID.
186 if rs != nil && rs.AcceptRejectsToMailbox != "" {
188 mailbox = rs.AcceptRejectsToMailbox
190 // Don't draw attention, but don't go so far as to mark as junk.
192 log.Info("accepting reject to configured mailbox due to ruleset")
194 return analysis{accept, mailbox, code, secode, err == nil, errmsg, err, nil, nil, reason, dmarcOverrideReason, headers}
197 if d.dmarcUse && d.dmarcResult.Reject {
198 return reject(smtp.C550MailboxUnavail, smtp.SePol7MultiAuthFails26, "rejecting per dmarc policy", nil, reasonDMARCPolicy)
200 // todo: should we also reject messages that have a dmarc pass but an spf record "v=spf1 -all"? suggested by m3aawg best practices.
202 // If destination is the DMARC reporting mailbox, do additional checks and keep
203 // track of the report. We'll check reputation, defaulting to accept.
204 var dmarcReport *dmarcrpt.Feedback
205 if d.rcptAcc.destination.DMARCReports {
207 if d.dmarcResult.Status != dmarc.StatusPass {
208 log.Info("received dmarc aggregate report without dmarc pass, not processing as dmarc report")
209 headers += "X-Mox-DMARCReport-Error: no DMARC pass\r\n"
210 } else if report, err := dmarcrpt.ParseMessageReport(log.Logger, store.FileMsgReader(d.m.MsgPrefix, d.dataFile)); err != nil {
211 log.Infox("parsing dmarc aggregate report", err)
212 headers += "X-Mox-DMARCReport-Error: could not parse report\r\n"
213 } else if d, err := dns.ParseDomain(report.PolicyPublished.Domain); err != nil {
214 log.Infox("parsing domain in dmarc aggregate report", err)
215 headers += "X-Mox-DMARCReport-Error: could not parse domain in published policy\r\n"
216 } else if _, ok := mox.Conf.Domain(d); !ok {
217 log.Info("dmarc aggregate report for domain not configured, ignoring", slog.Any("domain", d))
218 headers += "X-Mox-DMARCReport-Error: published policy domain unrecognized\r\n"
219 } else if report.ReportMetadata.DateRange.End > time.Now().Unix()+60 {
220 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)))
221 headers += "X-Mox-DMARCReport-Error: report has end date in the future\r\n"
227 // Similar to DMARC reporting, we check for the required DKIM. We'll check
228 // reputation, defaulting to accept.
229 var tlsReport *tlsrpt.Report
230 if d.rcptAcc.destination.HostTLSReports || d.rcptAcc.destination.DomainTLSReports {
231 matchesDomain := func(sigDomain dns.Domain) bool {
232 // RFC seems to require exact DKIM domain match with submitt and message From, we
234 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)
236 // Valid DKIM signature for domain must be present. We take "valid" to assume
237 // "passing", not "syntactically valid". We also check for "tlsrpt" as service.
238 // This check is optional, but if anyone goes through the trouble to explicitly
239 // list allowed services, they would be surprised to see them ignored.
242 for _, r := range d.dkimResults {
243 // The record should have an allowed service "tlsrpt". The RFC mentions it as if
244 // the service must be specified explicitly, but the default allowed services for a
245 // DKIM record are "*", which includes "tlsrpt". Unless a DKIM record explicitly
246 // specifies services (e.g. s=email), a record will work for TLS reports. The DKIM
247 // records seen used for TLS reporting in the wild don't explicitly set "s" for
250 if r.Status == dkim.StatusPass && matchesDomain(r.Sig.Domain) && r.Sig.Length < 0 && r.Record.ServiceAllowed("tlsrpt") {
257 log.Info("received mail to tlsrpt without acceptable DKIM signature, not processing as tls report")
258 headers += "X-Mox-TLSReport-Error: no acceptable DKIM signature\r\n"
259 } else if reportJSON, err := tlsrpt.ParseMessage(log.Logger, store.FileMsgReader(d.m.MsgPrefix, d.dataFile)); err != nil {
260 log.Infox("parsing tls report", err)
261 headers += "X-Mox-TLSReport-Error: could not parse TLS report\r\n"
264 for _, p := range reportJSON.Policies {
265 log.Info("tlsrpt policy domain", slog.String("domain", p.Policy.Domain))
266 if d, err := dns.ParseDomain(p.Policy.Domain); err != nil {
267 log.Infox("parsing domain in tls report", err)
268 } else if _, ok := mox.Conf.Domain(d); ok || d == mox.Conf.Static.HostnameDomain {
274 log.Info("tls report without one of configured domains, ignoring")
275 headers += "X-Mox-TLSReport-Error: report for unknown domain\r\n"
277 report := reportJSON.Convert()
283 // Determine if message is acceptable based on DMARC domain, DKIM identities, or
284 // host-based reputation.
287 var method reputationMethod
290 d.acc.WithRLock(func() {
291 err = d.acc.DB.Read(ctx, func(tx *bstore.Tx) error {
292 if err := assignMailbox(tx); err != nil {
296 isjunk, conclusive, method, err = reputation(tx, log, d.m)
297 reason = string(method)
302 log.Infox("determining reputation", err, slog.Any("message", d.m))
303 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonReputationError)
305 log.Info("reputation analyzed",
306 slog.Bool("conclusive", conclusive),
307 slog.Any("isjunk", isjunk),
308 slog.String("method", string(method)))
311 return analysis{accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reason, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
313 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, string(method))
314 } else if dmarcReport != nil || tlsReport != nil {
315 log.Info("accepting message with dmarc aggregate report or tls report without reputation")
316 return analysis{accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reasonReporting, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
318 // If there was no previous message from sender or its domain, and we have an SPF
319 // (soft)fail, reject the message.
321 case methodDKIMSPF, methodIP1, methodIP2, methodIP3, methodNone:
322 switch d.m.MailFromValidation {
323 case store.ValidationFail, store.ValidationSoftfail:
324 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", nil, reasonSPFPolicy)
328 // Senders without reputation and without iprev pass, are likely spam.
329 var suspiciousIPrevFail bool
331 case methodDKIMSPF, methodIP1, methodIP2, methodIP3, methodNone:
332 suspiciousIPrevFail = d.iprevStatus != iprev.StatusPass
335 // With already a mild junk signal, an iprev fail on top is enough to reject.
336 if suspiciousIPrevFail && isjunk != nil && *isjunk {
337 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", nil, reasonIPrev)
340 var subjectpassKey string
341 conf, _ := d.acc.Conf()
342 if conf.SubjectPass.Period > 0 {
343 subjectpassKey, err = d.acc.Subjectpass(d.rcptAcc.canonicalAddress)
345 log.Errorx("get key for verifying subject token", err)
346 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonSubjectpassError)
348 err = subjectpass.Verify(log.Logger, d.dataFile, []byte(subjectpassKey), conf.SubjectPass.Period)
350 log.Infox("pass by subject token", err, slog.Bool("pass", pass))
352 return analysis{accept: true, mailbox: mailbox, reason: reasonSubjectpass, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
356 reason = reasonNoBadSignals
358 var junkSubjectpass bool
359 f, jf, err := d.acc.OpenJunkFilter(ctx, log)
363 log.Check(err, "closing junkfilter")
365 contentProb, _, _, _, err := f.ClassifyMessageReader(ctx, store.FileMsgReader(d.m.MsgPrefix, d.dataFile), d.m.Size)
367 log.Errorx("testing for spam", err)
368 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonJunkClassifyError)
370 // 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?
371 // todo: if there aren't enough historic messages, we should just let messages in.
372 // 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...
374 // If we don't accept, we may still respond with a "subjectpass" hint below.
375 // We add some jitter to the threshold we use. So we don't act as too easy an
376 // oracle for words that are a strong indicator of haminess.
377 // todo: we should rate-limit uses of the junkfilter.
378 jitter := (jitterRand.Float64() - 0.5) / 10
379 threshold := jf.Threshold + jitter
381 rcptToMatch := func(l []message.Address) bool {
382 // 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
383 if d.rcptAcc.rcptTo.Localpart == "" {
386 for _, a := range l {
387 dom, err := dns.ParseDomain(a.Host)
391 lp, err := smtp.ParseLocalpart(a.User)
392 if err == nil && dom == d.rcptAcc.rcptTo.IPDomain.Domain && lp == d.rcptAcc.rcptTo.Localpart {
399 // 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.
400 // With an iprev fail, non-TLS connection or our address not in To/Cc header, we set a higher bar for content.
401 reason = reasonJunkContent
402 if suspiciousIPrevFail && threshold > 0.25 {
404 log.Info("setting junk threshold due to iprev fail", slog.Float64("threshold", threshold))
405 reason = reasonJunkContentStrict
406 } else if !d.tls && threshold > 0.25 {
408 log.Info("setting junk threshold due to plaintext smtp", slog.Float64("threshold", threshold))
409 reason = reasonJunkContentStrict
410 } else if (rs == nil || !rs.IsForward) && threshold > 0.25 && !rcptToMatch(d.msgTo) && !rcptToMatch(d.msgCc) {
411 // A common theme in junk messages is your recipient address not being in the To/Cc
412 // headers. We may be in Bcc, but that's unusual for first-time senders. Some
413 // providers (e.g. gmail) does not DKIM-sign Bcc headers, so junk messages can be
414 // sent with matching Bcc headers. We don't get here for known senders.
416 log.Info("setting junk threshold due to smtp rcpt to and message to/cc address mismatch", slog.Float64("threshold", threshold))
417 reason = reasonJunkContentStrict
419 accept = contentProb <= threshold
420 junkSubjectpass = contentProb < threshold-0.2
421 log.Info("content analyzed",
422 slog.Bool("accept", accept),
423 slog.Float64("contentprob", contentProb),
424 slog.Bool("subjectpass", junkSubjectpass))
425 } else if err != store.ErrNoJunkFilter {
426 log.Errorx("open junkfilter", err)
427 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonJunkFilterError)
430 // If content looks good, we'll still look at DNS block lists for a reason to
431 // reject. We normally won't get here if we've communicated with this sender
433 var dnsblocklisted bool
435 blocked := func(zone dns.Domain) bool {
436 dnsblctx, dnsblcancel := context.WithTimeout(ctx, 30*time.Second)
438 if !checkDNSBLHealth(dnsblctx, log, resolver, zone) {
439 log.Info("dnsbl not healthy, skipping", slog.Any("zone", zone))
443 status, expl, err := dnsbl.Lookup(dnsblctx, log.Logger, resolver, zone, net.ParseIP(d.m.RemoteIP))
445 if status == dnsbl.StatusFail {
446 log.Info("rejecting due to listing in dnsbl", slog.Any("zone", zone), slog.String("explanation", expl))
448 } else if err != nil {
449 log.Infox("dnsbl lookup", err, slog.Any("zone", zone), slog.Any("status", status))
454 // Note: We don't check in parallel, we are in no hurry to accept possible spam.
455 for _, zone := range d.dnsBLs {
458 dnsblocklisted = true
459 reason = reasonDNSBlocklisted
466 return analysis{accept: true, mailbox: mailbox, reason: reasonNoBadSignals, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
469 if subjectpassKey != "" && d.dmarcResult.Status == dmarc.StatusPass && method == methodNone && (dnsblocklisted || junkSubjectpass) {
470 log.Info("permanent reject with subjectpass hint of moderately spammy email without reputation")
471 pass := subjectpass.Generate(log.Logger, d.msgFrom, []byte(subjectpassKey), time.Now())
472 return reject(smtp.C550MailboxUnavail, smtp.SePol7DeliveryUnauth1, subjectpass.Explanation+pass, nil, reasonGiveSubjectpass)
475 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", nil, reason)