11 "golang.org/x/exp/slog"
13 "github.com/mjl-/bstore"
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"
37 msgTo []message.Address
38 msgCc []message.Address
42 dmarcResult dmarc.Result
43 dkimResults []dkim.Result
44 iprevStatus iprev.Status
54 err error // For our own logging, not sent to remote.
55 dmarcReport *dmarcrpt.Feedback // Validated DMARC aggregate report, not yet stored.
56 tlsReport *tlsrpt.Report // Validated TLS report, not yet stored.
57 reason string // If non-empty, reason for this decision. Can be one of reputationMethod and a few other tokens.
58 dmarcOverrideReason string // If set, one of dmarcrpt.PolicyOverride
59 // Additional headers to add during delivery. Used for reasons a message to a
60 // dmarc/tls reporting address isn't processed.
65 reasonListAllow = "list-allow"
66 reasonDMARCPolicy = "dmarc-policy"
67 reasonReputationError = "reputation-error"
68 reasonReporting = "reporting"
69 reasonSPFPolicy = "spf-policy"
70 reasonJunkClassifyError = "junk-classify-error"
71 reasonJunkFilterError = "junk-filter-error"
72 reasonGiveSubjectpass = "give-subjectpass"
73 reasonNoBadSignals = "no-bad-signals"
74 reasonJunkContent = "junk-content"
75 reasonJunkContentStrict = "junk-content-strict"
76 reasonDNSBlocklisted = "dns-blocklisted"
77 reasonSubjectpass = "subjectpass"
78 reasonSubjectpassError = "subjectpass-error"
79 reasonIPrev = "iprev" // No or mild junk reputation signals, and bad iprev.
82func isListDomain(d delivery, ld dns.Domain) bool {
83 if d.m.MailFromValidated && ld.Name() == d.m.MailFromDomain {
86 for _, r := range d.dkimResults {
87 if r.Status == dkim.StatusPass && r.Sig.Domain == ld {
94func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d delivery) analysis {
97 mailbox := d.rcptAcc.destination.Mailbox
102 // If destination mailbox has a mailing list domain (for SPF/DKIM) configured,
103 // check it for a pass.
104 rs := store.MessageRuleset(log, d.rcptAcc.destination, d.m, d.m.MsgPrefix, d.dataFile)
108 if rs != nil && !rs.ListAllowDNSDomain.IsZero() {
109 // todo: on temporary failures, reject temporarily?
110 if isListDomain(d, rs.ListAllowDNSDomain) {
111 d.m.IsMailingList = true
112 return analysis{accept: true, mailbox: mailbox, reason: reasonListAllow, dmarcOverrideReason: string(dmarcrpt.PolicyOverrideMailingList), headers: headers}
116 var dmarcOverrideReason string
118 // For forwarded messages, we have different junk analysis. We don't reject for
119 // failing DMARC, and we clear fields that could implicate the forwarding mail
120 // server during future classifications on incoming messages (the forwarding mail
121 // server isn't responsible for the message).
122 if rs != nil && rs.IsForward {
125 d.m.RemoteIPMasked1 = ""
126 d.m.RemoteIPMasked2 = ""
127 d.m.RemoteIPMasked3 = ""
128 d.m.OrigEHLODomain = d.m.EHLODomain
130 d.m.MailFromDomain = "" // Still available in MailFrom.
131 d.m.OrigDKIMDomains = d.m.DKIMDomains
132 dkimdoms := []string{}
133 for _, dom := range d.m.DKIMDomains {
134 if dom != rs.VerifiedDNSDomain.Name() {
135 dkimdoms = append(dkimdoms, dom)
138 d.m.DKIMDomains = dkimdoms
139 dmarcOverrideReason = string(dmarcrpt.PolicyOverrideForwarded)
140 log.Info("forwarded message, clearing identifying signals of forwarding mail server")
143 assignMailbox := func(tx *bstore.Tx) error {
144 // Set message MailboxID to which mail will be delivered. Reputation is
145 // per-mailbox. If referenced mailbox is not found (e.g. does not yet exist), we
146 // can still determine a reputation because we also base it on outgoing
147 // messages and those are account-global.
148 mb, err := d.acc.MailboxFind(tx, mailbox)
150 return fmt.Errorf("finding destination mailbox: %w", err)
153 // We want to deliver to mb.ID, but this message may be rejected and sent to the
154 // Rejects mailbox instead, with MailboxID overwritten. Record the ID in
155 // MailboxDestinedID too. If the message is later moved out of the Rejects mailbox,
156 // we'll adjust the MailboxOrigID so it gets taken into account during reputation
157 // calculating in future deliveries. If we end up delivering to the intended
158 // mailbox (i.e. not rejecting), MailboxDestinedID is cleared during delivery so we
159 // don't store it unnecessarily.
160 d.m.MailboxID = mb.ID
161 d.m.MailboxDestinedID = mb.ID
163 log.Debug("mailbox not found in database", slog.String("mailbox", mailbox))
168 reject := func(code int, secode string, errmsg string, err error, reason string) analysis {
169 // We may have set MailboxDestinedID below already while we had a transaction. If
170 // not, do it now. This makes it possible to use the per-mailbox reputation when a
171 // user moves the message out of the Rejects mailbox to the intended mailbox
172 // (typically Inbox).
173 if d.m.MailboxDestinedID == 0 {
175 d.acc.WithRLock(func() {
176 mberr = d.acc.DB.Read(ctx, func(tx *bstore.Tx) error {
177 return assignMailbox(tx)
181 return analysis{false, mailbox, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing", err, nil, nil, reasonReputationError, dmarcOverrideReason, headers}
183 d.m.MailboxID = 0 // We plan to reject, no need to set intended MailboxID.
187 if rs != nil && rs.AcceptRejectsToMailbox != "" {
189 mailbox = rs.AcceptRejectsToMailbox
191 // Don't draw attention, but don't go so far as to mark as junk.
193 log.Info("accepting reject to configured mailbox due to ruleset")
195 return analysis{accept, mailbox, code, secode, err == nil, errmsg, err, nil, nil, reason, dmarcOverrideReason, headers}
198 if d.dmarcUse && d.dmarcResult.Reject {
199 return reject(smtp.C550MailboxUnavail, smtp.SePol7MultiAuthFails26, "rejecting per dmarc policy", nil, reasonDMARCPolicy)
201 // todo: should we also reject messages that have a dmarc pass but an spf record "v=spf1 -all"? suggested by m3aawg best practices.
203 // If destination is the DMARC reporting mailbox, do additional checks and keep
204 // track of the report. We'll check reputation, defaulting to accept.
205 var dmarcReport *dmarcrpt.Feedback
206 if d.rcptAcc.destination.DMARCReports {
208 if d.dmarcResult.Status != dmarc.StatusPass {
209 log.Info("received dmarc aggregate report without dmarc pass, not processing as dmarc report")
210 headers += "X-Mox-DMARCReport-Error: no DMARC pass\r\n"
211 } else if report, err := dmarcrpt.ParseMessageReport(log.Logger, store.FileMsgReader(d.m.MsgPrefix, d.dataFile)); err != nil {
212 log.Infox("parsing dmarc aggregate report", err)
213 headers += "X-Mox-DMARCReport-Error: could not parse report\r\n"
214 } else if d, err := dns.ParseDomain(report.PolicyPublished.Domain); err != nil {
215 log.Infox("parsing domain in dmarc aggregate report", err)
216 headers += "X-Mox-DMARCReport-Error: could not parse domain in published policy\r\n"
217 } else if _, ok := mox.Conf.Domain(d); !ok {
218 log.Info("dmarc aggregate report for domain not configured, ignoring", slog.Any("domain", d))
219 headers += "X-Mox-DMARCReport-Error: published policy domain unrecognized\r\n"
220 } else if report.ReportMetadata.DateRange.End > time.Now().Unix()+60 {
221 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)))
222 headers += "X-Mox-DMARCReport-Error: report has end date in the future\r\n"
228 // Similar to DMARC reporting, we check for the required DKIM. We'll check
229 // reputation, defaulting to accept.
230 var tlsReport *tlsrpt.Report
231 if d.rcptAcc.destination.HostTLSReports || d.rcptAcc.destination.DomainTLSReports {
232 matchesDomain := func(sigDomain dns.Domain) bool {
233 // RFC seems to require exact DKIM domain match with submitt and message From, we
235 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)
237 // Valid DKIM signature for domain must be present. We take "valid" to assume
238 // "passing", not "syntactically valid". We also check for "tlsrpt" as service.
239 // This check is optional, but if anyone goes through the trouble to explicitly
240 // list allowed services, they would be surprised to see them ignored.
243 for _, r := range d.dkimResults {
244 // The record should have an allowed service "tlsrpt". The RFC mentions it as if
245 // the service must be specified explicitly, but the default allowed services for a
246 // DKIM record are "*", which includes "tlsrpt". Unless a DKIM record explicitly
247 // specifies services (e.g. s=email), a record will work for TLS reports. The DKIM
248 // records seen used for TLS reporting in the wild don't explicitly set "s" for
251 if r.Status == dkim.StatusPass && matchesDomain(r.Sig.Domain) && r.Sig.Length < 0 && r.Record.ServiceAllowed("tlsrpt") {
258 log.Info("received mail to tlsrpt without acceptable DKIM signature, not processing as tls report")
259 headers += "X-Mox-TLSReport-Error: no acceptable DKIM signature\r\n"
260 } else if reportJSON, err := tlsrpt.ParseMessage(log.Logger, store.FileMsgReader(d.m.MsgPrefix, d.dataFile)); err != nil {
261 log.Infox("parsing tls report", err)
262 headers += "X-Mox-TLSReport-Error: could not parse TLS report\r\n"
265 for _, p := range reportJSON.Policies {
266 log.Info("tlsrpt policy domain", slog.String("domain", p.Policy.Domain))
267 if d, err := dns.ParseDomain(p.Policy.Domain); err != nil {
268 log.Infox("parsing domain in tls report", err)
269 } else if _, ok := mox.Conf.Domain(d); ok || d == mox.Conf.Static.HostnameDomain {
275 log.Info("tls report without one of configured domains, ignoring")
276 headers += "X-Mox-TLSReport-Error: report for unknown domain\r\n"
278 report := reportJSON.Convert()
284 // Determine if message is acceptable based on DMARC domain, DKIM identities, or
285 // host-based reputation.
288 var method reputationMethod
291 d.acc.WithRLock(func() {
292 err = d.acc.DB.Read(ctx, func(tx *bstore.Tx) error {
293 if err := assignMailbox(tx); err != nil {
297 isjunk, conclusive, method, err = reputation(tx, log, d.m)
298 reason = string(method)
303 log.Infox("determining reputation", err, slog.Any("message", d.m))
304 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonReputationError)
306 log.Info("reputation analyzed",
307 slog.Bool("conclusive", conclusive),
308 slog.Any("isjunk", isjunk),
309 slog.String("method", string(method)))
312 return analysis{accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reason, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
314 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, string(method))
315 } else if dmarcReport != nil || tlsReport != nil {
316 log.Info("accepting message with dmarc aggregate report or tls report without reputation")
317 return analysis{accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reasonReporting, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
319 // If there was no previous message from sender or its domain, and we have an SPF
320 // (soft)fail, reject the message.
322 case methodDKIMSPF, methodIP1, methodIP2, methodIP3, methodNone:
323 switch d.m.MailFromValidation {
324 case store.ValidationFail, store.ValidationSoftfail:
325 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", nil, reasonSPFPolicy)
329 // Senders without reputation and without iprev pass, are likely spam.
330 var suspiciousIPrevFail bool
332 case methodDKIMSPF, methodIP1, methodIP2, methodIP3, methodNone:
333 suspiciousIPrevFail = d.iprevStatus != iprev.StatusPass
336 // With already a mild junk signal, an iprev fail on top is enough to reject.
337 if suspiciousIPrevFail && isjunk != nil && *isjunk {
338 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", nil, reasonIPrev)
341 var subjectpassKey string
342 conf, _ := d.acc.Conf()
343 if conf.SubjectPass.Period > 0 {
344 subjectpassKey, err = d.acc.Subjectpass(d.rcptAcc.canonicalAddress)
346 log.Errorx("get key for verifying subject token", err)
347 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonSubjectpassError)
349 err = subjectpass.Verify(log.Logger, d.dataFile, []byte(subjectpassKey), conf.SubjectPass.Period)
351 log.Infox("pass by subject token", err, slog.Bool("pass", pass))
353 return analysis{accept: true, mailbox: mailbox, reason: reasonSubjectpass, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
357 reason = reasonNoBadSignals
359 var junkSubjectpass bool
360 f, jf, err := d.acc.OpenJunkFilter(ctx, log)
364 log.Check(err, "closing junkfilter")
366 contentProb, _, _, _, err := f.ClassifyMessageReader(ctx, store.FileMsgReader(d.m.MsgPrefix, d.dataFile), d.m.Size)
368 log.Errorx("testing for spam", err)
369 return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonJunkClassifyError)
371 // 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?
372 // todo: if there aren't enough historic messages, we should just let messages in.
373 // 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...
375 // If we don't accept, we may still respond with a "subjectpass" hint below.
376 // We add some jitter to the threshold we use. So we don't act as too easy an
377 // oracle for words that are a strong indicator of haminess.
378 // todo: we should rate-limit uses of the junkfilter.
379 jitter := (jitterRand.Float64() - 0.5) / 10
380 threshold := jf.Threshold + jitter
382 rcptToMatch := func(l []message.Address) bool {
383 // 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
384 if d.rcptAcc.rcptTo.Localpart == "" {
387 for _, a := range l {
388 dom, err := dns.ParseDomain(a.Host)
392 if dom == d.rcptAcc.rcptTo.IPDomain.Domain && smtp.Localpart(a.User) == 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)