mirror of
https://github.com/mjl-/mox.git
synced 2025-07-12 17:44:35 +03:00
mox!
This commit is contained in:
42
smtpserver/alignment.go
Normal file
42
smtpserver/alignment.go
Normal file
@ -0,0 +1,42 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mjl-/mox/dkim"
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/publicsuffix"
|
||||
"github.com/mjl-/mox/spf"
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
// Alignment compares the msgFromDomain with the dkim and spf results, and returns
|
||||
// a validation, one of: Strict, Relaxed, None.
|
||||
func alignment(ctx context.Context, msgFromDomain dns.Domain, dkimResults []dkim.Result, spfStatus spf.Status, spfIdentity *dns.Domain) store.Validation {
|
||||
var strict, relaxed bool
|
||||
msgFromOrgDomain := publicsuffix.Lookup(ctx, msgFromDomain)
|
||||
|
||||
// todo: should take temperror and permerror into account.
|
||||
for _, dr := range dkimResults {
|
||||
if dr.Status != dkim.StatusPass || dr.Sig == nil {
|
||||
continue
|
||||
}
|
||||
if dr.Sig.Domain == msgFromDomain {
|
||||
strict = true
|
||||
break
|
||||
} else {
|
||||
relaxed = relaxed || msgFromOrgDomain == publicsuffix.Lookup(ctx, dr.Sig.Domain)
|
||||
}
|
||||
}
|
||||
if !strict && spfStatus == spf.StatusPass {
|
||||
strict = msgFromDomain == *spfIdentity
|
||||
relaxed = relaxed || msgFromOrgDomain == publicsuffix.Lookup(ctx, *spfIdentity)
|
||||
}
|
||||
if strict {
|
||||
return store.ValidationStrict
|
||||
}
|
||||
if relaxed {
|
||||
return store.ValidationRelaxed
|
||||
}
|
||||
return store.ValidationNone
|
||||
}
|
327
smtpserver/analyze.go
Normal file
327
smtpserver/analyze.go
Normal file
@ -0,0 +1,327 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mjl-/bstore"
|
||||
|
||||
"github.com/mjl-/mox/dkim"
|
||||
"github.com/mjl-/mox/dmarc"
|
||||
"github.com/mjl-/mox/dmarcrpt"
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/dnsbl"
|
||||
"github.com/mjl-/mox/iprev"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
"github.com/mjl-/mox/mox-"
|
||||
"github.com/mjl-/mox/smtp"
|
||||
"github.com/mjl-/mox/store"
|
||||
"github.com/mjl-/mox/subjectpass"
|
||||
"github.com/mjl-/mox/tlsrpt"
|
||||
)
|
||||
|
||||
type delivery struct {
|
||||
m *store.Message
|
||||
dataFile *os.File
|
||||
rcptAcc rcptAccount
|
||||
acc *store.Account
|
||||
msgFrom smtp.Address
|
||||
dnsBLs []dns.Domain
|
||||
dmarcUse bool
|
||||
dmarcResult dmarc.Result
|
||||
dkimResults []dkim.Result
|
||||
iprevStatus iprev.Status
|
||||
}
|
||||
|
||||
type analysis struct {
|
||||
accept bool
|
||||
code int
|
||||
secode string
|
||||
userError bool
|
||||
errmsg string
|
||||
err error // For our own logging, not sent to remote.
|
||||
dmarcReport *dmarcrpt.Feedback // Validated dmarc aggregate report, not yet stored.
|
||||
tlsReport *tlsrpt.Report // Validated TLS report, not yet stored.
|
||||
reason string // If non-empty, reason for this decision. Can be one of reputationMethod and a few other tokens.
|
||||
}
|
||||
|
||||
const (
|
||||
reasonListAllow = "list-allow"
|
||||
reasonDMARCPolicy = "dmarc-policy"
|
||||
reasonReputationError = "reputation-error"
|
||||
reasonReporting = "reporting"
|
||||
reasonSPFPolicy = "spf-policy"
|
||||
reasonJunkClassifyError = "junk-classify-error"
|
||||
reasonJunkFilterError = "junk-filter-error"
|
||||
reasonGiveSubjectpass = "give-subjectpass"
|
||||
reasonNoBadSignals = "no-bad-signals"
|
||||
reasonJunkContent = "junk-content"
|
||||
reasonJunkContentStrict = "junk-content-strict"
|
||||
reasonDNSBlocklisted = "dns-blocklisted"
|
||||
reasonSubjectpass = "subjectpass"
|
||||
reasonSubjectpassError = "subjectpass-error"
|
||||
reasonIPrev = "iprev" // No or mil junk reputation signals, and bad iprev.
|
||||
)
|
||||
|
||||
func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delivery) analysis {
|
||||
reject := func(code int, secode string, errmsg string, err error, reason string) analysis {
|
||||
return analysis{false, code, secode, err == nil, errmsg, err, nil, nil, reason}
|
||||
}
|
||||
|
||||
// If destination mailbox has a mailing list domain (for SPF/DKIM) configured,
|
||||
// check it for a pass.
|
||||
// todo: should use this evaluation for final delivery as well
|
||||
rs := store.MessageRuleset(log, d.rcptAcc.destination, d.m, d.m.MsgPrefix, d.dataFile)
|
||||
if rs != nil && !rs.ListAllowDNSDomain.IsZero() {
|
||||
ld := rs.ListAllowDNSDomain
|
||||
// todo: on temporary failures, reject temporarily?
|
||||
if d.m.MailFromValidated && ld.Name() == d.m.MailFromDomain {
|
||||
return analysis{accept: true, reason: reasonListAllow}
|
||||
}
|
||||
for _, r := range d.dkimResults {
|
||||
if r.Status == dkim.StatusPass && r.Sig.Domain == ld {
|
||||
return analysis{accept: true, reason: reasonListAllow}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if d.dmarcUse && d.dmarcResult.Reject {
|
||||
return reject(smtp.C550MailboxUnavail, smtp.SePol7MultiAuthFails26, "rejecting per dmarc policy", nil, reasonDMARCPolicy)
|
||||
}
|
||||
// todo: should we also reject messages that have a dmarc pass but an spf record "v=spf1 -all"? suggested by m3aawg best practices.
|
||||
|
||||
// If destination is the DMARC reporting mailbox, do additional checks and keep
|
||||
// track of the report. We'll check reputation, defaulting to accept.
|
||||
var dmarcReport *dmarcrpt.Feedback
|
||||
if d.rcptAcc.destination.DMARCReports {
|
||||
// Messages with DMARC aggregate reports must have a dmarc pass. ../rfc/7489:1866
|
||||
if d.dmarcResult.Status != dmarc.StatusPass {
|
||||
log.Info("received DMARC report without DMARC pass, not processing as DMARC report")
|
||||
} else if report, err := dmarcrpt.ParseMessageReport(store.FileMsgReader(d.m.MsgPrefix, d.dataFile)); err != nil {
|
||||
log.Infox("parsing dmarc report", err)
|
||||
} else if d, err := dns.ParseDomain(report.PolicyPublished.Domain); err != nil {
|
||||
log.Infox("parsing domain in dmarc report", err)
|
||||
} else if _, ok := mox.Conf.Domain(d); !ok {
|
||||
log.Info("dmarc report for domain not configured, ignoring", mlog.Field("domain", d))
|
||||
} else if report.ReportMetadata.DateRange.End > time.Now().Unix()+60 {
|
||||
log.Info("dmarc report with end date in the future, ignoring", mlog.Field("domain", d), mlog.Field("end", time.Unix(report.ReportMetadata.DateRange.End, 0)))
|
||||
} else {
|
||||
dmarcReport = report
|
||||
}
|
||||
}
|
||||
|
||||
// Similar to DMARC reporting, we check for the required DKIM. We'll check
|
||||
// reputation, defaulting to accept.
|
||||
var tlsReport *tlsrpt.Report
|
||||
if d.rcptAcc.destination.TLSReports {
|
||||
// Valid DKIM signature for domain must be present. We take "valid" to assume
|
||||
// "passing", not "syntactically valid". We also check for "tlsrpt" as service.
|
||||
// This check is optional, but if anyone goes through the trouble to explicitly
|
||||
// list allowed services, they would be surprised to see them ignored.
|
||||
// ../rfc/8460:320
|
||||
ok := false
|
||||
for _, r := range d.dkimResults {
|
||||
if r.Status == dkim.StatusPass && r.Sig.Domain == d.msgFrom.Domain && r.Sig.Length < 0 && r.Record.ServiceAllowed("tlsrpt") {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
log.Info("received mail to TLSRPT without acceptable DKIM signature, not processing as TLSRPT")
|
||||
} else if report, err := tlsrpt.ParseMessage(store.FileMsgReader(d.m.MsgPrefix, d.dataFile)); err != nil {
|
||||
log.Infox("parsing TLSRPT report", err)
|
||||
} else {
|
||||
var known bool
|
||||
for _, p := range report.Policies {
|
||||
log.Info("tlsrpt policy domain", mlog.Field("domain", p.Policy.Domain))
|
||||
if d, err := dns.ParseDomain(p.Policy.Domain); err != nil {
|
||||
log.Infox("parsing domain in TLSRPT report", err)
|
||||
} else if _, ok := mox.Conf.Domain(d); ok {
|
||||
known = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !known {
|
||||
log.Info("TLSRPT report without one of configured domains, ignoring")
|
||||
} else {
|
||||
tlsReport = report
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if message is acceptable based on DMARC domain, DKIM identities, or
|
||||
// host-based reputation.
|
||||
var isjunk *bool
|
||||
var conclusive bool
|
||||
var method reputationMethod
|
||||
var reason string
|
||||
var err error
|
||||
d.acc.WithRLock(func() {
|
||||
err = d.acc.DB.Read(func(tx *bstore.Tx) error {
|
||||
// Set message MailboxID to which mail will be delivered. Reputation is
|
||||
// per-mailbox. If referenced mailbox is not found (e.g. does not yet exist), we
|
||||
// can still use determine a reputation because we also base it on outgoing
|
||||
// messages and those account-global.
|
||||
mailbox := d.rcptAcc.destination.Mailbox
|
||||
if mailbox == "" {
|
||||
mailbox = "Inbox"
|
||||
}
|
||||
if rs != nil {
|
||||
mailbox = rs.Mailbox
|
||||
}
|
||||
mb := d.acc.MailboxFindX(tx, mailbox)
|
||||
if mb != nil {
|
||||
d.m.MailboxID = mb.ID
|
||||
} else {
|
||||
log.Debug("mailbox not found in database", mlog.Field("mailbox", mailbox))
|
||||
}
|
||||
|
||||
var err error
|
||||
isjunk, conclusive, method, err = reputation(tx, log, d.m)
|
||||
reason = string(method)
|
||||
return err
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
log.Infox("determining reputation", err, mlog.Field("message", d.m))
|
||||
return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonReputationError)
|
||||
}
|
||||
log.Info("reputation analyzed", mlog.Field("conclusive", conclusive), mlog.Field("isjunk", isjunk), mlog.Field("method", string(method)))
|
||||
if conclusive {
|
||||
if !*isjunk {
|
||||
return analysis{accept: true, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reason}
|
||||
}
|
||||
return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, string(method))
|
||||
} else if dmarcReport != nil || tlsReport != nil {
|
||||
log.Info("accepting dmarc reporting or tlsrpt message without reputation")
|
||||
return analysis{accept: true, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reasonReporting}
|
||||
}
|
||||
// If there was no previous message from sender or its domain, and we have an SPF
|
||||
// (soft)fail, reject the message.
|
||||
switch method {
|
||||
case methodDKIMSPF, methodIP1, methodIP2, methodIP3, methodNone:
|
||||
switch d.m.MailFromValidation {
|
||||
case store.ValidationFail, store.ValidationSoftfail:
|
||||
return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", nil, reasonSPFPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
// Senders without reputation and without iprev pass, are likely spam.
|
||||
var suspiciousIPrevFail bool
|
||||
switch method {
|
||||
case methodDKIMSPF, methodIP1, methodIP2, methodIP3, methodNone:
|
||||
suspiciousIPrevFail = d.iprevStatus != iprev.StatusPass
|
||||
}
|
||||
|
||||
// With already a mild junk signal, an iprev fail on top is enough to reject.
|
||||
if suspiciousIPrevFail && isjunk != nil && *isjunk {
|
||||
return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", nil, reasonIPrev)
|
||||
}
|
||||
|
||||
var subjectpassKey string
|
||||
conf, _ := d.acc.Conf()
|
||||
if conf.SubjectPass.Period > 0 {
|
||||
subjectpassKey, err = d.acc.Subjectpass(d.rcptAcc.canonicalAddress)
|
||||
if err != nil {
|
||||
log.Errorx("get key for verifying subject token", err)
|
||||
return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonSubjectpassError)
|
||||
}
|
||||
err = subjectpass.Verify(d.dataFile, []byte(subjectpassKey), conf.SubjectPass.Period)
|
||||
pass := err == nil
|
||||
log.Infox("pass by subject token", err, mlog.Field("pass", pass))
|
||||
if pass {
|
||||
return analysis{accept: true, reason: reasonSubjectpass}
|
||||
}
|
||||
}
|
||||
|
||||
reason = reasonNoBadSignals
|
||||
accept := true
|
||||
var junkSubjectpass bool
|
||||
f, jf, err := d.acc.OpenJunkFilter(log)
|
||||
if err == nil {
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
log.Errorx("closing junkfilter", err)
|
||||
}
|
||||
}()
|
||||
contentProb, _, _, _, err := f.ClassifyMessageReader(store.FileMsgReader(d.m.MsgPrefix, d.dataFile), d.m.Size)
|
||||
if err != nil {
|
||||
log.Errorx("testing for spam", err)
|
||||
return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonJunkClassifyError)
|
||||
}
|
||||
// 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?
|
||||
// todo: if there aren't enough historic messages, we should just let messages in.
|
||||
// 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...
|
||||
|
||||
// If we don't accept, we may still respond with a "subjectpass" hint below.
|
||||
// We add some jitter to the threshold we use. So we don't act as too easy an
|
||||
// oracle for words that are a strong indicator of haminess.
|
||||
// todo: we should rate-limit uses of the junkfilter.
|
||||
jitter := (jitterRand.Float64() - 0.5) / 10
|
||||
threshold := jf.Threshold + jitter
|
||||
|
||||
// With an iprev fail, we set a higher bar for content.
|
||||
reason = reasonJunkContent
|
||||
if suspiciousIPrevFail && threshold > 0.25 {
|
||||
threshold = 0.25
|
||||
log.Info("setting junk threshold due to iprev fail", mlog.Field("threshold", 0.25))
|
||||
reason = reasonJunkContentStrict
|
||||
}
|
||||
accept = contentProb <= threshold
|
||||
junkSubjectpass = contentProb < threshold-0.2
|
||||
log.Info("content analyzed", mlog.Field("accept", accept), mlog.Field("contentProb", contentProb), mlog.Field("subjectpass", junkSubjectpass))
|
||||
} else if err != store.ErrNoJunkFilter {
|
||||
log.Errorx("open junkfilter", err)
|
||||
return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonJunkFilterError)
|
||||
}
|
||||
|
||||
// If content looks good, we'll still look at DNS block lists for a reason to
|
||||
// reject. We normally won't get here if we've communicated with this sender
|
||||
// before.
|
||||
var dnsblocklisted bool
|
||||
if accept {
|
||||
blocked := func(zone dns.Domain) bool {
|
||||
dnsblctx, dnsblcancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer dnsblcancel()
|
||||
if !checkDNSBLHealth(dnsblctx, resolver, zone) {
|
||||
log.Info("dnsbl not healthy, skipping", mlog.Field("zone", zone))
|
||||
return false
|
||||
}
|
||||
|
||||
status, expl, err := dnsbl.Lookup(dnsblctx, resolver, zone, net.ParseIP(d.m.RemoteIP))
|
||||
dnsblcancel()
|
||||
if status == dnsbl.StatusFail {
|
||||
log.Info("rejecting due to listing in dnsbl", mlog.Field("zone", zone), mlog.Field("explanation", expl))
|
||||
return true
|
||||
} else if err != nil {
|
||||
log.Infox("dnsbl lookup", err, mlog.Field("zone", zone), mlog.Field("status", status))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Note: We don't check in parallel, we are in no hurry to accept possible spam.
|
||||
for _, zone := range d.dnsBLs {
|
||||
if blocked(zone) {
|
||||
accept = false
|
||||
dnsblocklisted = true
|
||||
reason = reasonDNSBlocklisted
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if accept {
|
||||
return analysis{accept: true, reason: reasonNoBadSignals}
|
||||
}
|
||||
|
||||
if subjectpassKey != "" && d.dmarcResult.Status == dmarc.StatusPass && method == methodNone && (dnsblocklisted || junkSubjectpass) {
|
||||
log.Info("permanent reject with subjectpass hint of moderately spammy email without reputation")
|
||||
pass := subjectpass.Generate(d.msgFrom, []byte(subjectpassKey), time.Now())
|
||||
return reject(smtp.C550MailboxUnavail, smtp.SePol7DeliveryUnauth1, subjectpass.Explanation+pass, nil, reasonGiveSubjectpass)
|
||||
}
|
||||
|
||||
return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", nil, reason)
|
||||
}
|
116
smtpserver/authresults.go
Normal file
116
smtpserver/authresults.go
Normal file
@ -0,0 +1,116 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mjl-/mox/message"
|
||||
)
|
||||
|
||||
// ../rfc/8601:577
|
||||
|
||||
// Authentication-Results header, see RFC 8601.
|
||||
type AuthResults struct {
|
||||
Hostname string
|
||||
Comment string // If not empty, header comment without "()", added after Hostname.
|
||||
Methods []AuthMethod
|
||||
}
|
||||
|
||||
// ../rfc/8601:598
|
||||
|
||||
// AuthMethod is a result for one authentication method.
|
||||
//
|
||||
// Example encoding in the header: "spf=pass smtp.mailfrom=example.net".
|
||||
type AuthMethod struct {
|
||||
// E.g. "dkim", "spf", "iprev", "auth".
|
||||
Method string
|
||||
Result string // Each method has a set of known values, e.g. "pass", "temperror", etc.
|
||||
Comment string // Optional, message header comment.
|
||||
Reason string // Optional.
|
||||
Props []AuthProp
|
||||
}
|
||||
|
||||
// ../rfc/8601:606
|
||||
|
||||
// AuthProp describes properties for an authentication method.
|
||||
// Each method has a set of known properties.
|
||||
// Encoded in the header as "type.property=value", e.g. "smtp.mailfrom=example.net"
|
||||
// for spf.
|
||||
type AuthProp struct {
|
||||
// Valid values maintained at https://www.iana.org/assignments/email-auth/email-auth.xhtml
|
||||
Type string
|
||||
Property string
|
||||
Value string
|
||||
// Whether value is address-like (localpart@domain, or domain). Or another value,
|
||||
// which is subject to escaping.
|
||||
IsAddrLike bool
|
||||
Comment string // If not empty, header comment withtout "()", added after Value.
|
||||
}
|
||||
|
||||
// todo future: we could store fields as dns.Domain, and when we encode as non-ascii also add the ascii version as a comment.
|
||||
|
||||
// Header returns an Authentication-Results header, possibly spanning multiple
|
||||
// lines, always ending in crlf.
|
||||
func (h AuthResults) Header() string {
|
||||
// Escaping of values: ../rfc/8601:684 ../rfc/2045:661
|
||||
|
||||
optComment := func(s string) string {
|
||||
if s != "" {
|
||||
return " (" + s + ")"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
w := &message.HeaderWriter{}
|
||||
w.Add("", "Authentication-Results:"+optComment(h.Comment)+" "+value(h.Hostname)+";")
|
||||
for i, m := range h.Methods {
|
||||
tokens := []string{}
|
||||
addf := func(format string, args ...any) {
|
||||
s := fmt.Sprintf(format, args...)
|
||||
tokens = append(tokens, s)
|
||||
}
|
||||
addf("%s=%s", m.Method, m.Result)
|
||||
if m.Comment != "" && (m.Reason != "" || len(m.Props) > 0) {
|
||||
addf("(%s)", m.Comment)
|
||||
}
|
||||
if m.Reason != "" {
|
||||
addf("reason=%s", value(m.Reason))
|
||||
}
|
||||
for _, p := range m.Props {
|
||||
v := p.Value
|
||||
if !p.IsAddrLike {
|
||||
v = value(v)
|
||||
}
|
||||
addf("%s.%s=%s%s", p.Type, p.Property, v, optComment(p.Comment))
|
||||
}
|
||||
for j, t := range tokens {
|
||||
if j == len(tokens)-1 && i < len(h.Methods)-1 {
|
||||
t += ";"
|
||||
}
|
||||
w.Add(" ", t)
|
||||
}
|
||||
}
|
||||
return w.String()
|
||||
}
|
||||
|
||||
func value(s string) string {
|
||||
quote := s == ""
|
||||
for _, c := range s {
|
||||
// utf-8 does not have to be quoted. ../rfc/6532:242
|
||||
if c == '"' || c == '\\' || c <= ' ' || c == 0x7f {
|
||||
quote = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !quote {
|
||||
return s
|
||||
}
|
||||
r := `"`
|
||||
for _, c := range s {
|
||||
if c == '"' || c == '\\' {
|
||||
r += "\\"
|
||||
}
|
||||
r += string(c)
|
||||
}
|
||||
r += `"`
|
||||
return r
|
||||
}
|
26
smtpserver/authresults_test.go
Normal file
26
smtpserver/authresults_test.go
Normal file
@ -0,0 +1,26 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
)
|
||||
|
||||
func TestAuthResults(t *testing.T) {
|
||||
dom, err := dns.ParseDomain("møx.example")
|
||||
if err != nil {
|
||||
t.Fatalf("parsing domain: %v", err)
|
||||
}
|
||||
authRes := AuthResults{
|
||||
Hostname: dom.XName(true),
|
||||
Comment: dom.ASCIIExtra(true),
|
||||
Methods: []AuthMethod{
|
||||
{"dkim", "pass", "", "", []AuthProp{{"header", "d", dom.XName(true), true, dom.ASCIIExtra(true)}}},
|
||||
},
|
||||
}
|
||||
s := authRes.Header()
|
||||
const exp = "Authentication-Results: (xn--mx-lka.example) møx.example; dkim=pass\r\n\theader.d=møx.example (xn--mx-lka.example)\r\n"
|
||||
if s != exp {
|
||||
t.Fatalf("got %q, expected %q", s, exp)
|
||||
}
|
||||
}
|
36
smtpserver/dnsbl.go
Normal file
36
smtpserver/dnsbl.go
Normal file
@ -0,0 +1,36 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/dnsbl"
|
||||
)
|
||||
|
||||
var dnsblHealth = struct {
|
||||
sync.Mutex
|
||||
zones map[dns.Domain]dnsblStatus
|
||||
}{
|
||||
zones: map[dns.Domain]dnsblStatus{},
|
||||
}
|
||||
|
||||
type dnsblStatus struct {
|
||||
last time.Time
|
||||
err error // nil, dnsbl.ErrDNS or other
|
||||
}
|
||||
|
||||
// checkDNSBLHealth checks healthiness of DNSBL "zone", keeping the result cached for 4 hours.
|
||||
func checkDNSBLHealth(ctx context.Context, resolver dns.Resolver, zone dns.Domain) (rok bool) {
|
||||
dnsblHealth.Lock()
|
||||
defer dnsblHealth.Unlock()
|
||||
status, ok := dnsblHealth.zones[zone]
|
||||
if !ok || time.Since(status.last) > 4*time.Hour {
|
||||
status.err = dnsbl.CheckHealth(ctx, resolver, zone)
|
||||
status.last = time.Now()
|
||||
dnsblHealth.zones[zone] = status
|
||||
}
|
||||
return status.err == nil || errors.Is(status.err, dnsbl.ErrDNS)
|
||||
}
|
56
smtpserver/dsn.go
Normal file
56
smtpserver/dsn.go
Normal file
@ -0,0 +1,56 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/mjl-/mox/dsn"
|
||||
"github.com/mjl-/mox/queue"
|
||||
"github.com/mjl-/mox/smtp"
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
// compose dsn message and add it to the queue for delivery to rcptTo.
|
||||
func queueDSN(c *conn, rcptTo smtp.Path, m dsn.Message) error {
|
||||
buf, err := m.Compose(c.log, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var bufUTF8 []byte
|
||||
if c.smtputf8 {
|
||||
bufUTF8, err = m.Compose(c.log, true)
|
||||
if err != nil {
|
||||
c.log.Errorx("composing dsn with utf-8 for incoming delivery for unknown user, continuing with ascii-only dsn", err)
|
||||
}
|
||||
}
|
||||
|
||||
f, err := store.CreateMessageTemp("smtp-dsn")
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating temp file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if f != nil {
|
||||
if err := os.Remove(f.Name()); err != nil {
|
||||
c.log.Errorx("removing temporary dsn message file", err)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}()
|
||||
if _, err := f.Write([]byte(buf)); err != nil {
|
||||
return fmt.Errorf("writing dsn file: %w", err)
|
||||
}
|
||||
|
||||
// Queue DSN with null reverse path so failures to deliver will eventually drop the
|
||||
// message instead of causing delivery loops.
|
||||
// ../rfc/3464:433
|
||||
const has8bit = false
|
||||
const smtputf8 = false
|
||||
if err := queue.Add(c.log, "", smtp.Path{}, rcptTo, has8bit, smtputf8, int64(len(buf)), nil, f, bufUTF8, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
c.log.Errorx("closing dsn file", err)
|
||||
}
|
||||
f = nil
|
||||
return nil
|
||||
}
|
36
smtpserver/error.go
Normal file
36
smtpserver/error.go
Normal file
@ -0,0 +1,36 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mjl-/mox/smtp"
|
||||
)
|
||||
|
||||
func xcheckf(err error, format string, args ...any) {
|
||||
if err != nil {
|
||||
panic(smtpError{smtp.C451LocalErr, smtp.SeSys3Other0, fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), err), true, false})
|
||||
}
|
||||
}
|
||||
|
||||
type smtpError struct {
|
||||
code int
|
||||
secode string
|
||||
err error
|
||||
printStack bool
|
||||
userError bool // If this is an error on the user side, which causes logging at a lower level.
|
||||
}
|
||||
|
||||
func (e smtpError) Error() string { return e.err.Error() }
|
||||
func (e smtpError) Unwrap() error { return e.err }
|
||||
|
||||
func xsmtpErrorf(code int, secode string, userError bool, format string, args ...any) {
|
||||
panic(smtpError{code, secode, fmt.Errorf(format, args...), false, userError})
|
||||
}
|
||||
|
||||
func xsmtpServerErrorf(codes codes, format string, args ...any) {
|
||||
xsmtpErrorf(codes.code, codes.secode, false, format, args...)
|
||||
}
|
||||
|
||||
func xsmtpUserErrorf(code int, secode string, format string, args ...any) {
|
||||
xsmtpErrorf(code, secode, true, format, args...)
|
||||
}
|
113
smtpserver/fuzz_test.go
Normal file
113
smtpserver/fuzz_test.go
Normal file
@ -0,0 +1,113 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/mox-"
|
||||
"github.com/mjl-/mox/queue"
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
// Fuzz the server. For each fuzz string, we set up servers in various connection states, and write the string as command.
|
||||
func FuzzServer(f *testing.F) {
|
||||
f.Add("HELO remote")
|
||||
f.Add("EHLO remote")
|
||||
f.Add("AUTH PLAIN")
|
||||
f.Add("MAIL FROM:<remote@remote>")
|
||||
f.Add("RCPT TO:<local@mox.example>")
|
||||
f.Add("DATA")
|
||||
f.Add(".")
|
||||
f.Add("RSET")
|
||||
f.Add("VRFY x")
|
||||
f.Add("EXPN x")
|
||||
f.Add("HELP")
|
||||
f.Add("NOOP")
|
||||
f.Add("QUIT")
|
||||
|
||||
mox.Context = context.Background()
|
||||
mox.ConfigStaticPath = "../testdata/smtp/mox.conf"
|
||||
mox.MustLoadConfig()
|
||||
dataDir := mox.ConfigDirPath(mox.Conf.Static.DataDir)
|
||||
os.RemoveAll(dataDir)
|
||||
acc, err := store.OpenAccount("mjl")
|
||||
if err != nil {
|
||||
f.Fatalf("open account: %v", err)
|
||||
}
|
||||
defer acc.Close()
|
||||
err = acc.SetPassword("testtest")
|
||||
if err != nil {
|
||||
f.Fatalf("set password: %v", err)
|
||||
}
|
||||
done := store.Switchboard()
|
||||
defer close(done)
|
||||
err = queue.Init()
|
||||
if err != nil {
|
||||
f.Fatalf("queue init: %v", err)
|
||||
}
|
||||
defer queue.Shutdown()
|
||||
|
||||
comm := store.RegisterComm(acc)
|
||||
defer comm.Unregister()
|
||||
|
||||
var cid int64 = 1
|
||||
|
||||
var fl *os.File
|
||||
if false {
|
||||
fl, err = os.Create("fuzz.log")
|
||||
if err != nil {
|
||||
f.Fatalf("fuzz log")
|
||||
}
|
||||
defer fl.Close()
|
||||
}
|
||||
flog := func(err error, msg string) {
|
||||
if fl != nil && err != nil {
|
||||
fmt.Fprintf(fl, "%s: %v\n", msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
run := func(cmds []string) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer serverConn.Close()
|
||||
defer clientConn.Close()
|
||||
|
||||
go func() {
|
||||
err := clientConn.SetDeadline(time.Now().Add(time.Second))
|
||||
flog(err, "set client deadline")
|
||||
_, err = clientConn.Read(make([]byte, 1024))
|
||||
flog(err, "read ehlo")
|
||||
for _, cmd := range cmds {
|
||||
_, err = clientConn.Write([]byte(cmd + "\r\n"))
|
||||
flog(err, "write command")
|
||||
_, err = clientConn.Read(make([]byte, 1024))
|
||||
flog(err, "read response")
|
||||
}
|
||||
_, err = clientConn.Write([]byte(s + "\r\n"))
|
||||
flog(err, "write test command")
|
||||
_, err = clientConn.Read(make([]byte, 1024))
|
||||
flog(err, "read test response")
|
||||
clientConn.Close()
|
||||
serverConn.Close()
|
||||
}()
|
||||
|
||||
resolver := dns.MockResolver{}
|
||||
const submission = false
|
||||
err := serverConn.SetDeadline(time.Now().Add(time.Second))
|
||||
flog(err, "set server deadline")
|
||||
serve("test", cid, dns.Domain{ASCII: "mox.example"}, nil, serverConn, resolver, submission, false, 100<<10, false, false, nil)
|
||||
cid++
|
||||
}
|
||||
|
||||
run([]string{})
|
||||
run([]string{"EHLO remote"})
|
||||
run([]string{"EHLO remote", "MAIL FROM:<remote@example.org>"})
|
||||
run([]string{"EHLO remote", "MAIL FROM:<remote@example.org>", "RCPT TO:<mjl@mox.example>"})
|
||||
// todo: submission with login
|
||||
})
|
||||
}
|
25
smtpserver/limitwriter.go
Normal file
25
smtpserver/limitwriter.go
Normal file
@ -0,0 +1,25 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
var errMessageTooLarge = errors.New("maximum message size exceeded")
|
||||
|
||||
type limitWriter struct {
|
||||
maxSize int64
|
||||
w io.Writer
|
||||
written int64
|
||||
}
|
||||
|
||||
func (w *limitWriter) Write(buf []byte) (int, error) {
|
||||
if w.written+int64(len(buf)) > w.maxSize {
|
||||
return 0, errMessageTooLarge
|
||||
}
|
||||
n, err := w.w.Write(buf)
|
||||
if n > 0 {
|
||||
w.written += int64(n)
|
||||
}
|
||||
return n, err
|
||||
}
|
37
smtpserver/mx.go
Normal file
37
smtpserver/mx.go
Normal file
@ -0,0 +1,37 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
)
|
||||
|
||||
// checks if domain can accept email.
|
||||
// i.e. if it has no null mx record, regular mx records or resolve to an address.
|
||||
func checkMXRecords(ctx context.Context, resolver dns.Resolver, d dns.Domain) (bool, error) {
|
||||
// Note: LookupMX can return an error and still return records.
|
||||
mx, err := resolver.LookupMX(ctx, d.ASCII+".")
|
||||
if err == nil && len(mx) == 1 && mx[0].Host == "." {
|
||||
// Null MX record, explicit signal that remote does not accept email.
|
||||
return false, nil
|
||||
}
|
||||
// Treat all errors that are not "no mx record" as temporary. E.g. timeout, malformed record, remote server error.
|
||||
if err != nil && !dns.IsNotFound(err) {
|
||||
return false, err
|
||||
}
|
||||
if len(mx) == 0 {
|
||||
mx = []*net.MX{{Host: d.ASCII + "."}}
|
||||
}
|
||||
var lastErr error
|
||||
for _, x := range mx {
|
||||
ips, err := resolver.LookupIPAddr(ctx, x.Host)
|
||||
if len(ips) > 0 {
|
||||
return true, nil
|
||||
}
|
||||
if err != nil && !dns.IsNotFound(err) {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
return false, lastErr
|
||||
}
|
447
smtpserver/parse.go
Normal file
447
smtpserver/parse.go
Normal file
@ -0,0 +1,447 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/smtp"
|
||||
)
|
||||
|
||||
// Parser holds the original string and string with ascii a-z upper-cased for easy
|
||||
// case-insensitive parsing.
|
||||
type parser struct {
|
||||
orig string
|
||||
upper string
|
||||
o int // Offset into orig/upper.
|
||||
smtputf8 bool // Whether SMTPUTF8 extension is enabled, making IDNA domains and utf8 localparts valid.
|
||||
conn *conn
|
||||
utf8LocalpartCode int // If non-zero, error for utf-8 localpart when smtputf8 not enabled.
|
||||
}
|
||||
|
||||
// toUpper upper cases bytes that are a-z. strings.ToUpper does too much. and
|
||||
// would replace invalid bytes with unicode replacement characters, which would
|
||||
// break our requirement that offsets into the original and upper case strings
|
||||
// point to the same character.
|
||||
func toUpper(s string) string {
|
||||
r := []byte(s)
|
||||
for i, c := range r {
|
||||
if c >= 'a' && c <= 'z' {
|
||||
r[i] = c - 0x20
|
||||
}
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
|
||||
func newParser(s string, smtputf8 bool, conn *conn) *parser {
|
||||
return &parser{orig: s, upper: toUpper(s), smtputf8: smtputf8, conn: conn}
|
||||
}
|
||||
|
||||
func (p *parser) xerrorf(format string, args ...any) {
|
||||
// ../rfc/5321:2377
|
||||
xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5Syntax2, "%s (remaining: %q)", fmt.Sprintf(format, args...), p.orig[p.o:])
|
||||
}
|
||||
|
||||
func (p *parser) xutf8localparterrorf() {
|
||||
code := p.utf8LocalpartCode
|
||||
if code == 0 {
|
||||
code = smtp.C550MailboxUnavail
|
||||
}
|
||||
// ../rfc/6531:466
|
||||
xsmtpUserErrorf(code, smtp.SeMsg6NonASCIIAddrNotPermitted7, "non-ascii address not permitted without smtputf8")
|
||||
}
|
||||
|
||||
func (p *parser) empty() bool {
|
||||
return p.o == len(p.orig)
|
||||
}
|
||||
|
||||
// note: use xend() for check for end of line with remaining white space, to be used by commands.
|
||||
func (p *parser) xempty() {
|
||||
if p.o != len(p.orig) {
|
||||
p.xerrorf("expected end of line")
|
||||
}
|
||||
}
|
||||
|
||||
// check we are at the end of a command.
|
||||
func (p *parser) xend() {
|
||||
// For submission, we are strict.
|
||||
if p.conn.submission {
|
||||
p.xempty()
|
||||
}
|
||||
// Otherwise we allow trailing white space. ../rfc/5321:1758
|
||||
rem := p.remainder()
|
||||
for _, c := range rem {
|
||||
if c != ' ' && c != '\t' {
|
||||
p.xerrorf("trailing data, not white space: %q", rem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) hasPrefix(s string) bool {
|
||||
return strings.HasPrefix(p.upper[p.o:], s)
|
||||
}
|
||||
|
||||
func (p *parser) take(s string) bool {
|
||||
if p.hasPrefix(s) {
|
||||
p.o += len(s)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *parser) xtake(s string) {
|
||||
if !p.take(s) {
|
||||
p.xerrorf("expected %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) space() bool {
|
||||
return p.take(" ")
|
||||
}
|
||||
|
||||
func (p *parser) xspace() {
|
||||
p.xtake(" ")
|
||||
}
|
||||
|
||||
func (p *parser) xtaken(n int) string {
|
||||
r := p.orig[p.o : p.o+n]
|
||||
p.o += n
|
||||
return r
|
||||
}
|
||||
|
||||
func (p *parser) remainder() string {
|
||||
r := p.orig[p.o:]
|
||||
p.o = len(p.orig)
|
||||
return r
|
||||
}
|
||||
|
||||
func (p *parser) peekchar() rune {
|
||||
for _, c := range p.upper[p.o:] {
|
||||
return c
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (p *parser) takefn1(what string, fn func(c rune, i int) bool) string {
|
||||
if p.empty() {
|
||||
p.xerrorf("need at least one char for %s", what)
|
||||
}
|
||||
for i, c := range p.upper[p.o:] {
|
||||
if !fn(c, i) {
|
||||
if i == 0 {
|
||||
p.xerrorf("expected at least one char for %s", what)
|
||||
}
|
||||
return p.xtaken(i)
|
||||
}
|
||||
}
|
||||
return p.remainder()
|
||||
}
|
||||
|
||||
func (p *parser) takefn(fn func(c rune, i int) bool) string {
|
||||
for i, c := range p.upper[p.o:] {
|
||||
if !fn(c, i) {
|
||||
return p.xtaken(i)
|
||||
}
|
||||
}
|
||||
return p.remainder()
|
||||
}
|
||||
|
||||
// xrawReversePath returns the raw string between the <>'s. We cannot parse it
|
||||
// immediately, because if this is an IDNA (internationalization) address, we would
|
||||
// only see the SMTPUTF8 indicator after having parsed the reverse path here. So we
|
||||
// parse the raw data here, and validate it after having seen all parameters.
|
||||
// ../rfc/5321:2260
|
||||
func (p *parser) xrawReversePath() string {
|
||||
p.xtake("<")
|
||||
s := p.takefn(func(c rune, i int) bool {
|
||||
return c != '>'
|
||||
})
|
||||
p.xtake(">")
|
||||
return s
|
||||
}
|
||||
|
||||
// xbareReversePath parses a reverse-path without <>, as returned by
|
||||
// xrawReversePath. It takes smtputf8 into account.
|
||||
// ../rfc/5321:2260
|
||||
func (p *parser) xbareReversePath() smtp.Path {
|
||||
if p.empty() {
|
||||
return smtp.Path{}
|
||||
}
|
||||
// ../rfc/6531:468
|
||||
p.utf8LocalpartCode = smtp.C550MailboxUnavail
|
||||
defer func() {
|
||||
p.utf8LocalpartCode = 0
|
||||
}()
|
||||
return p.xbarePath()
|
||||
}
|
||||
|
||||
func (p *parser) xforwardPath() smtp.Path {
|
||||
// ../rfc/6531:466
|
||||
p.utf8LocalpartCode = smtp.C553BadMailbox
|
||||
defer func() {
|
||||
p.utf8LocalpartCode = 0
|
||||
}()
|
||||
return p.xpath()
|
||||
}
|
||||
|
||||
// ../rfc/5321:2264
|
||||
func (p *parser) xpath() smtp.Path {
|
||||
o := p.o
|
||||
p.xtake("<")
|
||||
r := p.xbarePath()
|
||||
p.xtake(">")
|
||||
if p.o-o > 256 {
|
||||
// ../rfc/5321:3495
|
||||
p.xerrorf("path longer than 256 octets")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (p *parser) xbarePath() smtp.Path {
|
||||
// We parse but ignore any source routing.
|
||||
// ../rfc/5321:1081 ../rfc/5321:1430 ../rfc/5321:1925
|
||||
if p.take("@") {
|
||||
p.xdomain()
|
||||
for p.take(",") {
|
||||
p.xtake("@")
|
||||
p.xdomain()
|
||||
}
|
||||
p.xtake(":")
|
||||
}
|
||||
return p.xmailbox()
|
||||
}
|
||||
|
||||
// ../rfc/5321:2291
|
||||
func (p *parser) xdomain() dns.Domain {
|
||||
s := p.xsubdomain()
|
||||
for p.take(".") {
|
||||
s += "." + p.xsubdomain()
|
||||
}
|
||||
d, err := dns.ParseDomain(s)
|
||||
if err != nil {
|
||||
p.xerrorf("parsing domain name %q: %s", s, err)
|
||||
}
|
||||
if len(s) > 255 {
|
||||
// ../rfc/5321:3491
|
||||
p.xerrorf("domain longer than 255 octets")
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ../rfc/5321:2303
|
||||
// ../rfc/5321:2303 ../rfc/6531:411
|
||||
func (p *parser) xsubdomain() string {
|
||||
return p.takefn1("subdomain", func(c rune, i int) bool {
|
||||
return c >= '0' && c <= '9' || c >= 'A' && c <= 'Z' || i > 0 && c == '-' || c > 0x7f && p.smtputf8
|
||||
})
|
||||
}
|
||||
|
||||
// ../rfc/5321:2314
|
||||
func (p *parser) xmailbox() smtp.Path {
|
||||
localpart := p.xlocalpart()
|
||||
p.xtake("@")
|
||||
return smtp.Path{Localpart: localpart, IPDomain: p.xipdomain()}
|
||||
}
|
||||
|
||||
// ../rfc/5321:2307
|
||||
func (p *parser) xldhstr() string {
|
||||
return p.takefn1("ldh-str", func(c rune, i int) bool {
|
||||
return c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || i == 0 && c == '-'
|
||||
})
|
||||
}
|
||||
|
||||
// parse address-literal or domain.
|
||||
func (p *parser) xipdomain() dns.IPDomain {
|
||||
// ../rfc/5321:2309
|
||||
// ../rfc/5321:2397
|
||||
if p.take("[") {
|
||||
c := p.peekchar()
|
||||
var ipv6 bool
|
||||
if !(c >= '0' && c <= '9') {
|
||||
addrlit := p.xldhstr()
|
||||
p.xtake(":")
|
||||
if !strings.EqualFold(addrlit, "IPv6") {
|
||||
p.xerrorf("unrecognized address literal %q", addrlit)
|
||||
}
|
||||
ipv6 = true
|
||||
}
|
||||
ipaddr := p.takefn1("address literal", func(c rune, i int) bool {
|
||||
return c != ']'
|
||||
})
|
||||
p.take("]")
|
||||
ip := net.ParseIP(ipaddr)
|
||||
if ip == nil {
|
||||
p.xerrorf("invalid ip in address: %q", ipaddr)
|
||||
}
|
||||
isv4 := ip.To4() != nil
|
||||
if ipv6 && isv4 {
|
||||
p.xerrorf("ip is not ipv6")
|
||||
} else if !ipv6 && !isv4 {
|
||||
p.xerrorf("ip is not ipv4")
|
||||
}
|
||||
return dns.IPDomain{IP: ip}
|
||||
}
|
||||
return dns.IPDomain{Domain: p.xdomain()}
|
||||
}
|
||||
|
||||
// todo: reduce duplication between implementations: ../smtp/address.go:/xlocalpart ../dkim/parser.go:/xlocalpart ../smtpserver/parse.go:/xlocalpart
|
||||
func (p *parser) xlocalpart() smtp.Localpart {
|
||||
// ../rfc/5321:2316
|
||||
var s string
|
||||
if p.hasPrefix(`"`) {
|
||||
s = p.xquotedString(true)
|
||||
} else {
|
||||
s = p.xatom(true)
|
||||
for p.take(".") {
|
||||
s += "." + p.xatom(true)
|
||||
}
|
||||
}
|
||||
// todo: have a strict parser that only allows the actual max of 64 bytes. some services have large localparts because of generated (bounce) addresses.
|
||||
if len(s) > 128 {
|
||||
// ../rfc/5321:3486
|
||||
p.xerrorf("localpart longer than 64 octets")
|
||||
}
|
||||
return smtp.Localpart(s)
|
||||
}
|
||||
|
||||
// ../rfc/5321:2324
|
||||
func (p *parser) xquotedString(islocalpart bool) string {
|
||||
var s string
|
||||
var esc bool
|
||||
for {
|
||||
c := p.xchar()
|
||||
if esc {
|
||||
if c >= ' ' && c < 0x7f {
|
||||
s += string(c)
|
||||
esc = false
|
||||
continue
|
||||
}
|
||||
p.xerrorf("invalid localpart, bad escaped char %c", c)
|
||||
}
|
||||
if c == '\\' {
|
||||
esc = true
|
||||
continue
|
||||
}
|
||||
if c == '"' {
|
||||
return s
|
||||
}
|
||||
// ../rfc/5321:2332 ../rfc/6531:419
|
||||
if islocalpart && c > 0x7f && !p.smtputf8 {
|
||||
p.xutf8localparterrorf()
|
||||
}
|
||||
if c >= ' ' && c < 0x7f && c != '\\' && c != '"' || (c > 0x7f && p.smtputf8) {
|
||||
s += string(c)
|
||||
continue
|
||||
}
|
||||
p.xerrorf("invalid localpart, invalid character %c", c)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) xchar() rune {
|
||||
// We are careful to track invalid utf-8 properly.
|
||||
if p.empty() {
|
||||
p.xerrorf("need another character")
|
||||
}
|
||||
var r rune
|
||||
var o int
|
||||
for i, c := range p.orig[p.o:] {
|
||||
if i > 0 {
|
||||
o = i
|
||||
break
|
||||
}
|
||||
r = c
|
||||
}
|
||||
if o == 0 {
|
||||
p.o = len(p.orig)
|
||||
} else {
|
||||
p.o += o
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ../rfc/5321:2320 ../rfc/6531:414
|
||||
func (p *parser) xatom(islocalpart bool) string {
|
||||
return p.takefn1("atom", func(c rune, i int) bool {
|
||||
switch c {
|
||||
case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?', '^', '_', '`', '{', '|', '}', '~':
|
||||
return true
|
||||
}
|
||||
if islocalpart && c > 0x7f && !p.smtputf8 {
|
||||
p.xutf8localparterrorf()
|
||||
}
|
||||
return c >= '0' && c <= '9' || c >= 'A' && c <= 'Z' || (c > 0x7f && p.smtputf8)
|
||||
})
|
||||
}
|
||||
|
||||
// ../rfc/5321:2338
|
||||
func (p *parser) xstring() string {
|
||||
if p.peekchar() == '"' {
|
||||
return p.xquotedString(false)
|
||||
}
|
||||
return p.xatom(false)
|
||||
}
|
||||
|
||||
// ../rfc/5321:2279
|
||||
func (p *parser) xparamKeyword() string {
|
||||
return p.takefn1("parameter keyword", func(c rune, i int) bool {
|
||||
return c >= '0' && c <= '9' || c >= 'A' && c <= 'Z' || (i > 0 && c == '-')
|
||||
})
|
||||
}
|
||||
|
||||
// ../rfc/5321:2281 ../rfc/6531:422
|
||||
func (p *parser) xparamValue() string {
|
||||
return p.takefn1("parameter value", func(c rune, i int) bool {
|
||||
return c > ' ' && c < 0x7f && c != '=' || (c > 0x7f && p.smtputf8)
|
||||
})
|
||||
}
|
||||
|
||||
// for smtp parameters that take a numeric parameter with specified number of
|
||||
// digits, eg SIZE=... for MAIL FROM.
|
||||
func (p *parser) xnumber(maxDigits int) int64 {
|
||||
s := p.takefn1("number", func(c rune, i int) bool {
|
||||
return c >= '0' && c <= '9' && i < maxDigits
|
||||
})
|
||||
v, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
p.xerrorf("bad number %q: %s", s, err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// sasl mechanism, for AUTH command.
|
||||
// ../rfc/4422:436
|
||||
func (p *parser) xsaslMech() string {
|
||||
return p.takefn1("sasl-mech", func(c rune, i int) bool {
|
||||
return i < 20 && (c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_')
|
||||
})
|
||||
}
|
||||
|
||||
// ../rfc/4954:696 ../rfc/6533:259
|
||||
func (p *parser) xtext() string {
|
||||
r := ""
|
||||
for !p.empty() {
|
||||
b := p.orig[p.o]
|
||||
if b >= 0x21 && b < 0x7f && b != '+' && b != '=' && b != ' ' {
|
||||
r += string(b)
|
||||
p.xtaken(1)
|
||||
continue
|
||||
}
|
||||
if b != '+' {
|
||||
break
|
||||
}
|
||||
p.xtaken(1)
|
||||
x := p.xtaken(2)
|
||||
for _, b := range x {
|
||||
if b >= '0' && b <= '9' || b >= 'A' && b <= 'F' {
|
||||
continue
|
||||
}
|
||||
p.xerrorf("parsing xtext: invalid hexadecimal %q", x)
|
||||
}
|
||||
const hex = "0123456789ABCDEF"
|
||||
b = byte(strings.IndexByte(hex, x[0])<<4) | byte(strings.IndexByte(hex, x[1])<<0)
|
||||
r += string(rune(b))
|
||||
}
|
||||
return r
|
||||
}
|
23
smtpserver/parse_test.go
Normal file
23
smtpserver/parse_test.go
Normal file
@ -0,0 +1,23 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/smtp"
|
||||
)
|
||||
|
||||
func tcompare(t *testing.T, got, exp any) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, exp) {
|
||||
t.Fatalf("got %v, expected %v", got, exp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
tcompare(t, newParser("<@hosta.int,@jkl.org:userc@d.bar.org>", false, nil).xpath(), smtp.Path{Localpart: "userc", IPDomain: dns.IPDomain{Domain: dns.Domain{ASCII: "d.bar.org"}}})
|
||||
|
||||
tcompare(t, newParser("e+3Dmc2@example.com", false, nil).xtext(), "e=mc2@example.com")
|
||||
tcompare(t, newParser("", false, nil).xtext(), "")
|
||||
}
|
67
smtpserver/rejects.go
Normal file
67
smtpserver/rejects.go
Normal file
@ -0,0 +1,67 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/mjl-/bstore"
|
||||
|
||||
"github.com/mjl-/mox/message"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
"github.com/mjl-/mox/moxio"
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
// rejectPresent returns whether the message is already present in the rejects mailbox.
|
||||
func rejectPresent(log *mlog.Log, acc *store.Account, rejectsMailbox string, m *store.Message, f *os.File) (present bool, msgID string, hash []byte, rerr error) {
|
||||
if p, err := message.Parse(store.FileMsgReader(m.MsgPrefix, f)); err != nil {
|
||||
log.Infox("parsing reject message for message-id", err)
|
||||
} else if header, err := p.Header(); err != nil {
|
||||
log.Infox("parsing reject message header for message-id", err)
|
||||
} else {
|
||||
msgID = header.Get("Message-Id")
|
||||
}
|
||||
|
||||
// We must not read MsgPrefix, it will likely change for subsequent deliveries.
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, &moxio.AtReader{R: f}); err != nil {
|
||||
log.Infox("copying reject message to hash", err)
|
||||
} else {
|
||||
hash = h.Sum(nil)
|
||||
}
|
||||
|
||||
if msgID == "" && len(hash) == 0 {
|
||||
return false, "", nil, fmt.Errorf("no message-id or hash for determining reject message presence")
|
||||
}
|
||||
|
||||
var exists bool
|
||||
var err error
|
||||
acc.WithRLock(func() {
|
||||
err = acc.DB.Read(func(tx *bstore.Tx) error {
|
||||
mbq := bstore.QueryTx[store.Mailbox](tx)
|
||||
mbq.FilterNonzero(store.Mailbox{Name: rejectsMailbox})
|
||||
mb, err := mbq.Get()
|
||||
if err == bstore.ErrAbsent {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("looking for rejects mailbox: %w", err)
|
||||
}
|
||||
|
||||
q := bstore.QueryTx[store.Message](tx)
|
||||
q.FilterNonzero(store.Message{MailboxID: mb.ID})
|
||||
q.FilterFn(func(m store.Message) bool {
|
||||
return msgID != "" && m.MessageID == msgID || len(hash) > 0 && bytes.Equal(m.MessageHash, hash)
|
||||
})
|
||||
exists, err = q.Exists()
|
||||
return err
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return false, "", nil, fmt.Errorf("querying for presence of reject message: %w", err)
|
||||
}
|
||||
return exists, msgID, hash, nil
|
||||
}
|
380
smtpserver/reputation.go
Normal file
380
smtpserver/reputation.go
Normal file
@ -0,0 +1,380 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mjl-/bstore"
|
||||
|
||||
"github.com/mjl-/mox/mlog"
|
||||
"github.com/mjl-/mox/smtp"
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
type reputationMethod string
|
||||
|
||||
const (
|
||||
methodMsgfromFull reputationMethod = "msgfromfull"
|
||||
methodMsgtoFull reputationMethod = "msgtofull"
|
||||
methodMsgfromDomain reputationMethod = "msgfromdomain"
|
||||
methodMsgfromOrgDomain reputationMethod = "msgfromorgdomain"
|
||||
methodMsgtoDomain reputationMethod = "msgtodomain"
|
||||
methodMsgtoOrgDomain reputationMethod = "msgtoorgdomain"
|
||||
methodDKIMSPF reputationMethod = "dkimspf"
|
||||
methodIP1 reputationMethod = "ip1"
|
||||
methodIP2 reputationMethod = "ip2"
|
||||
methodIP3 reputationMethod = "ip3"
|
||||
methodNone reputationMethod = "none"
|
||||
)
|
||||
|
||||
// Reputation returns whether message m is likely junk.
|
||||
//
|
||||
// This function is called after checking for a manually configured spf mailfrom
|
||||
// allow (e.g. for mailing lists), and after checking for a dmarc reject policy.
|
||||
//
|
||||
// The decision is made based on historic messages delivered to the same
|
||||
// destination mailbox, MailboxOrigID. Because each mailbox may have a different
|
||||
// accept policy, for example mailing lists with an SPF mailfrom allow. We only use
|
||||
// messages that have been marked as read. We expect users to mark junk messages as
|
||||
// such when they read it. And to keep it in their inbox, regular trash or archive
|
||||
// if it is not.
|
||||
//
|
||||
// The basic idea is to keep accepting messages that were accepted in the past, and
|
||||
// keep rejecting those that were rejected. This is relatively easy to check if
|
||||
// mail passes SPF and/or DKIM with Message-From alignment. Regular email from
|
||||
// known people will be let in. But spammers are trickier. They will use new
|
||||
// (sub)domains, no or newly created SPF and/or DKIM identifiers, new localparts,
|
||||
// etc. This function likely ends up returning "inconclusive" for such emails. The
|
||||
// junkfilter will have to take care of a final decision.
|
||||
//
|
||||
// In case of doubt, it doesn't hurt much to accept another mail that a user has
|
||||
// communicated successfully with in the past. If the most recent message is marked
|
||||
// as junk that could have happened accidental. If another message is let in, and
|
||||
// it is again junk, future messages will be rejected.
|
||||
//
|
||||
// Actual spammers will probably try to use identifiers, i.e. (sub)domain, dkim/spf
|
||||
// identifiers and ip addresses for which we have no history. We may only have
|
||||
// ip-based reputation, perhaps only an ip range, perhaps nothing.
|
||||
//
|
||||
// Some profiles of first-time senders:
|
||||
//
|
||||
// - Individuals. They can typically get past the junkfilter if needed.
|
||||
// - Transaction emails. They should get past the junkfilter. If they use one of
|
||||
// the larger email service providers, their reputation could help. If the
|
||||
// junkfilter rejects the message, users can recover the message from the Rejects
|
||||
// mailbox. The first message is typically initiated by a user, e.g. by registering.
|
||||
// - Desired commercial email will have to get past the junkfilter based on its
|
||||
// content. There will typically be earlier communication with the (organizational)
|
||||
// domain that would let the message through.
|
||||
// - Mailing list. May get past the junkfilter. If delivery is to a separate
|
||||
// mailbox, the junkfilter will let it in because of little history. Long enough to
|
||||
// build reputation based on DKIM/SPF signals.
|
||||
//
|
||||
// The decision-making process looks at historic messages. The following properties
|
||||
// are checked until matching messages are found. If they are found, a decision is
|
||||
// returned, which may be inconclusive. The next property on the list is only
|
||||
// checked if a step did not match any messages.
|
||||
//
|
||||
// - Messages matching full "message from" address, either with strict/relaxed
|
||||
// dkim/spf-verification, or without.
|
||||
// - Messages the user sent to the "message from" address.
|
||||
// - Messages matching only the domain of the "message from" address (different
|
||||
// localpart), again with verification or without.
|
||||
// - Messages sent to an address in the domain of the "message from" address.
|
||||
// - The previous two checks again, but now checking against the organizational
|
||||
// domain instead of the exact domain.
|
||||
// - Matching DKIM domains and a matching SPF mailfrom, or mailfrom domain, or ehlo
|
||||
// domain.
|
||||
// - "Exact" IP, or nearby IPs (/24 or /48).
|
||||
//
|
||||
// References:
|
||||
// ../rfc/5863
|
||||
// ../rfc/7960
|
||||
// ../rfc/6376:1915
|
||||
// ../rfc/6376:3716
|
||||
// ../rfc/7208:2167
|
||||
func reputation(tx *bstore.Tx, log *mlog.Log, m *store.Message) (rjunk *bool, rconclusive bool, rmethod reputationMethod, rerr error) {
|
||||
boolptr := func(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
xfalse := boolptr(false)
|
||||
xtrue := boolptr(true)
|
||||
|
||||
type queryError string
|
||||
|
||||
defer func() {
|
||||
x := recover()
|
||||
if x == nil {
|
||||
return
|
||||
}
|
||||
if xerr, ok := x.(queryError); ok {
|
||||
rerr = errors.New(string(xerr))
|
||||
return
|
||||
}
|
||||
panic(x)
|
||||
}()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// messageQuery returns a base query for historic seen messages to the same
|
||||
// mailbox, at most maxAge old, and at most maxCount messages.
|
||||
messageQuery := func(fm *store.Message, maxAge time.Duration, maxCount int) *bstore.Query[store.Message] {
|
||||
q := bstore.QueryTx[store.Message](tx)
|
||||
q.FilterEqual("MailboxOrigID", m.MailboxID)
|
||||
q.FilterEqual("Seen", true)
|
||||
if fm != nil {
|
||||
q.FilterNonzero(*fm)
|
||||
}
|
||||
q.FilterGreaterEqual("Received", now.Add(-maxAge))
|
||||
q.Limit(maxCount)
|
||||
q.SortDesc("Received")
|
||||
return q
|
||||
}
|
||||
|
||||
// Execute the query, returning messages or returning error through panic.
|
||||
xmessageList := func(q *bstore.Query[store.Message], descr string) []store.Message {
|
||||
t0 := time.Now()
|
||||
l, err := q.List()
|
||||
log.Debugx("querying messages for reputation", err, mlog.Field("msgs", len(l)), mlog.Field("descr", descr), mlog.Field("queryduration", time.Since(t0)))
|
||||
if err != nil {
|
||||
panic(queryError(fmt.Sprintf("listing messages: %v", err)))
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
xrecipientExists := func(q *bstore.Query[store.Recipient]) bool {
|
||||
exists, err := q.Exists()
|
||||
if err != nil {
|
||||
panic(queryError(fmt.Sprintf("checking for recipient: %v", err)))
|
||||
}
|
||||
return exists
|
||||
}
|
||||
|
||||
const year = 365 * 24 * time.Hour
|
||||
|
||||
// Look for historic messages with same "message from" address. We'll
|
||||
// treat any validation (strict/dmarc/relaxed) the same, but "none"
|
||||
// separately.
|
||||
//
|
||||
// We only need 1 message, and sometimes look at a second message. If
|
||||
// the last message or the message before was an accept, we accept. If
|
||||
// the single last or last two were a reject, we reject.
|
||||
//
|
||||
// If there was no validation, any signal is inconclusive.
|
||||
if m.MsgFromDomain != "" {
|
||||
q := messageQuery(&store.Message{MsgFromLocalpart: m.MsgFromLocalpart, MsgFromDomain: m.MsgFromDomain}, 3*year, 2)
|
||||
q.FilterEqual("MsgFromValidated", m.MsgFromValidated)
|
||||
msgs := xmessageList(q, "mgsfromfull")
|
||||
if len(msgs) > 0 {
|
||||
ham := !msgs[0].Junk || len(msgs) > 1 && !msgs[1].Junk
|
||||
conclusive := m.MsgFromValidated
|
||||
// todo: we may want to look at dkim/spf in this case.
|
||||
spam := !ham
|
||||
return &spam, conclusive, methodMsgfromFull, nil
|
||||
}
|
||||
if !m.MsgFromValidated {
|
||||
// Look for historic messages that were validated. If present, this is likely spam.
|
||||
// Only return as conclusively spam if history also says this From-address sent
|
||||
// spam.
|
||||
q := messageQuery(&store.Message{MsgFromLocalpart: m.MsgFromLocalpart, MsgFromDomain: m.MsgFromDomain, MsgFromValidated: true}, 3*year, 2)
|
||||
msgs = xmessageList(q, "msgfromfull-validated")
|
||||
if len(msgs) > 0 {
|
||||
ham := !msgs[0].Junk || len(msgs) > 1 && !msgs[1].Junk
|
||||
return xtrue, !ham, methodMsgfromFull, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Look if we ever sent to this address. If so, we accept,
|
||||
qr := bstore.QueryTx[store.Recipient](tx)
|
||||
qr.FilterEqual("Localpart", m.MsgFromLocalpart)
|
||||
qr.FilterEqual("Domain", m.MsgFromDomain)
|
||||
qr.FilterGreaterEqual("Sent", now.Add(-3*year))
|
||||
if xrecipientExists(qr) {
|
||||
return xfalse, true, methodMsgtoFull, nil
|
||||
}
|
||||
|
||||
// Look for domain match, then for organizational domain match.
|
||||
for _, orgdomain := range []bool{false, true} {
|
||||
qm := store.Message{}
|
||||
var method reputationMethod
|
||||
var descr string
|
||||
if orgdomain {
|
||||
qm.MsgFromOrgDomain = m.MsgFromOrgDomain
|
||||
method = methodMsgfromOrgDomain
|
||||
descr = "msgfromorgdomain"
|
||||
} else {
|
||||
qm.MsgFromDomain = m.MsgFromDomain
|
||||
method = methodMsgfromDomain
|
||||
descr = "msgfromdomain"
|
||||
}
|
||||
|
||||
q := messageQuery(&qm, 2*year, 20)
|
||||
q.FilterEqual("MsgFromValidated", m.MsgFromValidated)
|
||||
msgs := xmessageList(q, descr)
|
||||
if len(msgs) > 0 {
|
||||
nham := 0
|
||||
for _, m := range msgs {
|
||||
if !m.Junk {
|
||||
nham++
|
||||
}
|
||||
}
|
||||
if 100*nham/len(msgs) > 80 {
|
||||
return xfalse, true, method, nil
|
||||
}
|
||||
if nham == 0 {
|
||||
// Only conclusive with at least 3 different localparts.
|
||||
localparts := map[smtp.Localpart]struct{}{}
|
||||
for _, m := range msgs {
|
||||
localparts[m.MsgFromLocalpart] = struct{}{}
|
||||
if len(localparts) == 3 {
|
||||
return xtrue, true, method, nil
|
||||
}
|
||||
}
|
||||
return xtrue, false, method, nil
|
||||
}
|
||||
// Mixed signals from domain. We don't want to block a new sender.
|
||||
return nil, false, method, nil
|
||||
}
|
||||
if !m.MsgFromValidated {
|
||||
// Look for historic messages that were validated. If present, this is likely spam.
|
||||
// Only return as conclusively spam if history also says this From-address sent
|
||||
// spam.
|
||||
q := messageQuery(&qm, 2*year, 2)
|
||||
q.FilterEqual("MsgFromValidated", true)
|
||||
msgs = xmessageList(q, descr+"-validated")
|
||||
if len(msgs) > 0 {
|
||||
ham := !msgs[0].Junk || len(msgs) > 1 && !msgs[1].Junk
|
||||
return xtrue, !ham, method, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Look if we ever sent to this address. If so, we accept,
|
||||
qr := bstore.QueryTx[store.Recipient](tx)
|
||||
if orgdomain {
|
||||
qr.FilterEqual("OrgDomain", m.MsgFromOrgDomain)
|
||||
method = methodMsgtoOrgDomain
|
||||
} else {
|
||||
qr.FilterEqual("Domain", m.MsgFromDomain)
|
||||
method = methodMsgtoDomain
|
||||
}
|
||||
qr.FilterGreaterEqual("Sent", now.Add(-2*year))
|
||||
if xrecipientExists(qr) {
|
||||
return xfalse, true, method, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DKIM and SPF.
|
||||
// We only use identities that passed validation. Failed identities are ignored. ../rfc/6376:2447
|
||||
// todo future: we could do something with the DKIM identity (i=) field if it is more specific than just the domain (d=).
|
||||
dkimspfsignals := []float64{}
|
||||
dkimspfmsgs := 0
|
||||
for _, dom := range m.DKIMDomains {
|
||||
// todo: should get dkimdomains in an index for faster lookup. bstore does not yet support "in" indexes.
|
||||
q := messageQuery(nil, year/2, 50)
|
||||
q.FilterFn(func(m store.Message) bool {
|
||||
for _, d := range m.DKIMDomains {
|
||||
if d == dom {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
msgs := xmessageList(q, "dkimdomain")
|
||||
if len(msgs) > 0 {
|
||||
nspam := 0
|
||||
for _, m := range msgs {
|
||||
if m.Junk {
|
||||
nspam++
|
||||
}
|
||||
}
|
||||
pspam := float64(nspam) / float64(len(msgs))
|
||||
dkimspfsignals = append(dkimspfsignals, pspam)
|
||||
dkimspfmsgs = len(msgs)
|
||||
}
|
||||
}
|
||||
if m.MailFromValidated || m.EHLOValidated {
|
||||
var msgs []store.Message
|
||||
if m.MailFromValidated && m.MailFromDomain != "" {
|
||||
q := messageQuery(&store.Message{MailFromLocalpart: m.MailFromLocalpart, MailFromDomain: m.MailFromDomain}, year/2, 50)
|
||||
msgs = xmessageList(q, "mailfrom")
|
||||
if len(msgs) == 0 {
|
||||
q := messageQuery(&store.Message{MailFromDomain: m.MailFromDomain}, year/2, 50)
|
||||
msgs = xmessageList(q, "mailfromdomain")
|
||||
}
|
||||
}
|
||||
if len(msgs) == 0 && m.EHLOValidated && m.EHLODomain != "" {
|
||||
q := messageQuery(&store.Message{EHLODomain: m.EHLODomain}, year/2, 50)
|
||||
msgs = xmessageList(q, "ehlodomain")
|
||||
}
|
||||
if len(msgs) > 0 {
|
||||
nspam := 0
|
||||
for _, m := range msgs {
|
||||
if m.Junk {
|
||||
nspam++
|
||||
}
|
||||
}
|
||||
pspam := float64(nspam) / float64(len(msgs))
|
||||
dkimspfsignals = append(dkimspfsignals, pspam)
|
||||
if len(msgs) > dkimspfmsgs {
|
||||
dkimspfmsgs = len(msgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(dkimspfsignals) > 0 {
|
||||
var nham, nspam int
|
||||
for _, p := range dkimspfsignals {
|
||||
if p < .1 {
|
||||
nham++
|
||||
} else if p > .9 {
|
||||
nspam++
|
||||
}
|
||||
}
|
||||
if nham > 0 && nspam == 0 {
|
||||
return xfalse, true, methodDKIMSPF, nil
|
||||
}
|
||||
if nspam > 0 && nham == 0 {
|
||||
return xtrue, dkimspfmsgs > 1, methodDKIMSPF, nil
|
||||
}
|
||||
return nil, false, methodDKIMSPF, nil
|
||||
}
|
||||
|
||||
// IP-based. A wider mask needs more messages to be conclusive.
|
||||
// We require the resulting signal to be strong, i.e. likely ham or likely spam.
|
||||
q := messageQuery(&store.Message{RemoteIPMasked1: m.RemoteIPMasked1}, year/4, 50)
|
||||
msgs := xmessageList(q, "ip1")
|
||||
need := 2
|
||||
method := methodIP1
|
||||
if len(msgs) == 0 {
|
||||
q := messageQuery(&store.Message{RemoteIPMasked2: m.RemoteIPMasked2}, year/4, 50)
|
||||
msgs = xmessageList(q, "ip2")
|
||||
need = 5
|
||||
method = methodIP2
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
q := messageQuery(&store.Message{RemoteIPMasked3: m.RemoteIPMasked3}, year/4, 50)
|
||||
msgs = xmessageList(q, "ip3")
|
||||
need = 10
|
||||
method = methodIP3
|
||||
}
|
||||
if len(msgs) > 0 {
|
||||
nspam := 0
|
||||
for _, m := range msgs {
|
||||
if m.Junk {
|
||||
nspam++
|
||||
}
|
||||
}
|
||||
pspam := float64(nspam) / float64(len(msgs))
|
||||
var spam *bool
|
||||
if pspam < .25 {
|
||||
spam = xfalse
|
||||
} else if pspam > .75 {
|
||||
spam = xtrue
|
||||
}
|
||||
conclusive := len(msgs) >= need && (pspam <= 0.1 || pspam >= 0.9)
|
||||
return spam, conclusive, method, nil
|
||||
}
|
||||
|
||||
return nil, false, methodNone, nil
|
||||
}
|
421
smtpserver/reputation_test.go
Normal file
421
smtpserver/reputation_test.go
Normal file
@ -0,0 +1,421 @@
|
||||
package smtpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mjl-/bstore"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/publicsuffix"
|
||||
"github.com/mjl-/mox/smtp"
|
||||
"github.com/mjl-/mox/store"
|
||||
)
|
||||
|
||||
func TestReputation(t *testing.T) {
|
||||
boolptr := func(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
xtrue := boolptr(true)
|
||||
xfalse := boolptr(false)
|
||||
|
||||
now := time.Now()
|
||||
var uidgen store.UID
|
||||
|
||||
message := func(junk bool, ageDays int, ehlo, mailfrom, msgfrom, rcptto string, msgfromvalidation store.Validation, dkimDomains []string, mailfromValid, ehloValid bool, ip string) store.Message {
|
||||
|
||||
mailFromValidation := store.ValidationNone
|
||||
if mailfromValid {
|
||||
mailFromValidation = store.ValidationPass
|
||||
}
|
||||
ehloValidation := store.ValidationNone
|
||||
if ehloValid {
|
||||
ehloValidation = store.ValidationPass
|
||||
}
|
||||
|
||||
msgFrom, err := smtp.ParseAddress(msgfrom)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("parsing msgfrom %q: %w", msgfrom, err))
|
||||
}
|
||||
|
||||
rcptTo, err := smtp.ParseAddress(rcptto)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("parsing rcptto %q: %w", rcptto, err))
|
||||
}
|
||||
|
||||
mailFrom := msgFrom
|
||||
if mailfrom != "" {
|
||||
mailFrom, err = smtp.ParseAddress(mailfrom)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("parsing mailfrom %q: %w", mailfrom, err))
|
||||
}
|
||||
}
|
||||
|
||||
var ipmasked1, ipmasked2, ipmasked3 string
|
||||
var xip = net.ParseIP(ip)
|
||||
if xip.To4() != nil {
|
||||
ipmasked1 = xip.String()
|
||||
ipmasked2 = xip.Mask(net.CIDRMask(26, 32)).String()
|
||||
ipmasked3 = xip.Mask(net.CIDRMask(21, 32)).String()
|
||||
} else {
|
||||
ipmasked1 = xip.Mask(net.CIDRMask(64, 128)).String()
|
||||
ipmasked2 = xip.Mask(net.CIDRMask(48, 128)).String()
|
||||
ipmasked3 = xip.Mask(net.CIDRMask(32, 128)).String()
|
||||
}
|
||||
|
||||
uidgen++
|
||||
m := store.Message{
|
||||
UID: uidgen, // Not relevant here.
|
||||
MailboxID: 1,
|
||||
MailboxOrigID: 1,
|
||||
Received: now.Add(time.Duration(-ageDays) * 24 * time.Hour),
|
||||
RemoteIP: ip,
|
||||
RemoteIPMasked1: ipmasked1,
|
||||
RemoteIPMasked2: ipmasked2,
|
||||
RemoteIPMasked3: ipmasked3,
|
||||
|
||||
EHLODomain: ehlo,
|
||||
MailFrom: mailfrom,
|
||||
MailFromLocalpart: mailFrom.Localpart,
|
||||
MailFromDomain: mailFrom.Domain.Name(),
|
||||
RcptToLocalpart: rcptTo.Localpart,
|
||||
RcptToDomain: rcptTo.Domain.Name(),
|
||||
|
||||
MsgFromLocalpart: msgFrom.Localpart,
|
||||
MsgFromDomain: msgFrom.Domain.Name(),
|
||||
MsgFromOrgDomain: publicsuffix.Lookup(context.Background(), msgFrom.Domain).Name(),
|
||||
|
||||
MailFromValidated: mailfromValid,
|
||||
EHLOValidated: ehloValid,
|
||||
MsgFromValidated: msgfromvalidation == store.ValidationStrict || msgfromvalidation == store.ValidationRelaxed || msgfromvalidation == store.ValidationDMARC,
|
||||
|
||||
MailFromValidation: mailFromValidation,
|
||||
EHLOValidation: ehloValidation,
|
||||
MsgFromValidation: msgfromvalidation,
|
||||
|
||||
DKIMDomains: dkimDomains,
|
||||
|
||||
Flags: store.Flags{
|
||||
Junk: junk,
|
||||
Seen: true,
|
||||
},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
check := func(m store.Message, history []store.Message, expJunk *bool, expConclusive bool, expMethod reputationMethod) {
|
||||
t.Helper()
|
||||
|
||||
p := "../testdata/smtpserver-reputation.db"
|
||||
defer os.Remove(p)
|
||||
|
||||
db, err := bstore.Open(p, &bstore.Options{Timeout: 5 * time.Second}, store.Message{}, store.Recipient{}, store.Mailbox{})
|
||||
tcheck(t, err, "open db")
|
||||
defer db.Close()
|
||||
|
||||
err = db.Write(func(tx *bstore.Tx) error {
|
||||
err = tx.Insert(&store.Mailbox{ID: 1, Name: "Inbox"})
|
||||
tcheck(t, err, "insert into db")
|
||||
|
||||
for _, hm := range history {
|
||||
err := tx.Insert(&hm)
|
||||
tcheck(t, err, "insert message")
|
||||
|
||||
rcptToDomain, err := dns.ParseDomain(hm.RcptToDomain)
|
||||
tcheck(t, err, "parse rcptToDomain")
|
||||
rcptToOrgDomain := publicsuffix.Lookup(context.Background(), rcptToDomain)
|
||||
r := store.Recipient{MessageID: hm.ID, Localpart: hm.RcptToLocalpart, Domain: hm.RcptToDomain, OrgDomain: rcptToOrgDomain.Name(), Sent: hm.Received}
|
||||
err = tx.Insert(&r)
|
||||
tcheck(t, err, "insert recipient")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
tcheck(t, err, "commit")
|
||||
|
||||
var isjunk *bool
|
||||
var conclusive bool
|
||||
var method reputationMethod
|
||||
err = db.Read(func(tx *bstore.Tx) error {
|
||||
var err error
|
||||
isjunk, conclusive, method, err = reputation(tx, xlog, &m)
|
||||
return err
|
||||
})
|
||||
tcheck(t, err, "read tx")
|
||||
|
||||
if method != expMethod {
|
||||
t.Fatalf("got method %q, expected %q", method, expMethod)
|
||||
}
|
||||
if conclusive != expConclusive {
|
||||
t.Fatalf("got conclusive %v, expected %v", conclusive, expConclusive)
|
||||
}
|
||||
if (isjunk == nil) != (expJunk == nil) || (isjunk != nil && expJunk != nil && *isjunk != *expJunk) {
|
||||
t.Fatalf("got isjunk %v, expected %v", isjunk, expJunk)
|
||||
}
|
||||
}
|
||||
|
||||
var msgs []store.Message
|
||||
var m store.Message
|
||||
|
||||
msgs = []store.Message{
|
||||
message(false, 4, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationDMARC, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(true, 3, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(false, 2, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"), // causes accept
|
||||
message(true, 1, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationRelaxed, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
}
|
||||
m = message(false, 0, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationDMARC, []string{"othersite.example"}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xfalse, true, methodMsgfromFull)
|
||||
|
||||
// Two most recents are spam, reject.
|
||||
msgs = []store.Message{
|
||||
message(false, 3, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(true, 2, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(true, 1, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationDMARC, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
}
|
||||
m = message(false, 0, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xtrue, true, methodMsgfromFull)
|
||||
|
||||
// If localpart matches, other localsparts are not used.
|
||||
msgs = []store.Message{
|
||||
message(true, 3, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(true, 2, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationRelaxed, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(false, 1, "host.othersite.example", "", "b@remote.example", "mjl@local.example", store.ValidationDMARC, []string{"othersite.example"}, true, true, "10.0.0.1"), // other localpart, ignored
|
||||
}
|
||||
m = message(false, 0, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationRelaxed, []string{"othersite.example"}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xtrue, true, methodMsgfromFull)
|
||||
|
||||
// Incoming message, we have only seen other unverified msgs from sender.
|
||||
msgs = []store.Message{
|
||||
message(true, 3, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationNone, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(true, 2, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationNone, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
}
|
||||
m = message(false, 0, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationNone, []string{"othersite.example"}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xtrue, false, methodMsgfromFull)
|
||||
|
||||
// Incoming message, we have only seen verified msgs from sender, and at least two, so this is a likely but inconclusive spam.
|
||||
msgs = []store.Message{
|
||||
message(false, 3, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationStrict, []string{"remote.example"}, true, true, "10.0.0.1"),
|
||||
message(false, 2, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationStrict, []string{"remote.example"}, true, true, "10.0.0.1"),
|
||||
}
|
||||
m = message(false, 0, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationNone, []string{}, false, false, "10.10.0.1")
|
||||
check(m, msgs, xtrue, false, methodMsgfromFull)
|
||||
|
||||
// Incoming message, we have only seen 1 verified message from sender, so inconclusive for reject.
|
||||
msgs = []store.Message{
|
||||
message(false, 2, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationStrict, []string{"remote.example"}, true, true, "10.0.0.1"),
|
||||
}
|
||||
m = message(false, 0, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationNone, []string{}, false, false, "10.10.0.1")
|
||||
check(m, msgs, xtrue, false, methodMsgfromFull)
|
||||
|
||||
// Incoming message, we have only seen 1 verified message from sender, and it was spam, so we can safely reject.
|
||||
msgs = []store.Message{
|
||||
message(true, 2, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationStrict, []string{"remote.example"}, true, true, "10.0.0.1"),
|
||||
}
|
||||
m = message(false, 0, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationNone, []string{}, false, false, "10.10.0.1")
|
||||
check(m, msgs, xtrue, true, methodMsgfromFull)
|
||||
|
||||
// We received spam from other senders in the domain, but we sent to msgfrom.
|
||||
msgs = []store.Message{
|
||||
message(true, 3, "host.othersite.example", "", "a@remote.example", "mjl@local.example", store.ValidationStrict, []string{"remote.example"}, true, true, "10.0.0.1"), // other localpart
|
||||
message(false, 2, "host.local.example", "", "mjl@local.example", "other@remote.example", store.ValidationNone, []string{}, false, false, "127.0.0.1"), // we sent to remote, accept
|
||||
message(true, 1, "host.othersite.example", "", "a@remote.example", "mjl@local.example", store.ValidationStrict, []string{"remote.example"}, true, true, "10.0.0.1"), // other localpart
|
||||
message(true, 1, "host.othersite.example", "", "a@remote.example", "mjl@local.example", store.ValidationStrict, []string{"remote.example"}, true, true, "10.0.0.1"), // other localpart
|
||||
}
|
||||
m = message(false, 0, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationNone, []string{}, false, false, "10.10.0.1")
|
||||
check(m, msgs, xfalse, true, methodMsgtoFull)
|
||||
|
||||
// Other messages in same domain, inconclusive.
|
||||
msgs = []store.Message{
|
||||
message(true, 7*30, "host.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(false, 3*30, "host.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(true, 3*30, "host.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(false, 3*30, "host.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(true, 3*30, "host.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(false, 8, "host.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(false, 8, "host.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(false, 4, "host.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(true, 2, "host.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(false, 1, "host.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
}
|
||||
m = message(false, 0, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1")
|
||||
check(m, msgs, nil, false, methodMsgfromDomain)
|
||||
|
||||
// Mostly ham, so we'll allow it.
|
||||
msgs = []store.Message{
|
||||
message(false, 7*30, "host1.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(false, 3*30, "host1.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(false, 3*30, "host2.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite3.example"}, true, true, "10.0.0.2"),
|
||||
message(false, 3*30, "host2.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite2.example"}, true, true, "10.0.0.2"),
|
||||
message(false, 3*30, "host3.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.100"),
|
||||
message(false, 8, "host3.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite2.example"}, true, true, "10.0.0.100"),
|
||||
message(false, 8, "host4.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite2.example"}, true, true, "10.0.0.4"),
|
||||
message(false, 4, "host4.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.4"),
|
||||
message(false, 2, "host5.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.10.0.5"),
|
||||
message(true, 1, "host5.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"none.example"}, true, true, "10.10.0.5"),
|
||||
}
|
||||
m = message(false, 0, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example", "othersite3.example"}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xfalse, true, methodMsgfromDomain)
|
||||
|
||||
// Not clearly spam, so inconclusive.
|
||||
msgs = []store.Message{
|
||||
message(true, 3*30, "host3.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.100"),
|
||||
message(true, 4, "host4.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.4"),
|
||||
message(true, 2, "host5.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.10.0.5"),
|
||||
message(false, 1, "host5.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"none.example"}, true, true, "10.10.0.5"),
|
||||
}
|
||||
m = message(false, 0, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example", "othersite3.example"}, true, true, "10.0.0.1")
|
||||
check(m, msgs, nil, false, methodMsgfromDomain)
|
||||
|
||||
// We only received spam from this domain by at least 3 localparts: reject.
|
||||
msgs = []store.Message{
|
||||
message(true, 3*30, "host3.othersite.example", "", "a@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.100"),
|
||||
message(true, 4, "host4.othersite.example", "", "b@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.4"),
|
||||
message(true, 2, "host5.othersite.example", "", "c@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.10.0.5"),
|
||||
message(true, 1, "host5.othersite.example", "", "c@remote.example", "mjl@local.example", store.ValidationStrict, []string{"none.example"}, true, true, "10.10.0.5"),
|
||||
}
|
||||
m = message(false, 0, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example", "othersite3.example"}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xtrue, true, methodMsgfromDomain)
|
||||
|
||||
// We only received spam from this org domain by at least 3 localparts. so reject.
|
||||
msgs = []store.Message{
|
||||
message(true, 3*30, "host3.othersite.example", "", "a@a.remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.100"),
|
||||
message(true, 4, "host4.othersite.example", "", "b@b.remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.4"),
|
||||
message(true, 2, "host5.othersite.example", "", "c@c.remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.10.0.5"),
|
||||
message(true, 1, "host5.othersite.example", "", "c@c.remote.example", "mjl@local.example", store.ValidationStrict, []string{"none.example"}, true, true, "10.10.0.5"),
|
||||
}
|
||||
m = message(false, 0, "host.othersite.example", "", "other@d.remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example", "othersite3.example"}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xtrue, true, methodMsgfromOrgDomain)
|
||||
|
||||
// We've only seen spam, but we don"t want to reject an entire domain with only 2 froms, so inconclusive.
|
||||
msgs = []store.Message{
|
||||
message(true, 2*30, "host3.othersite.example", "", "a@remote.example", "mjl@local.example", store.ValidationStrict, []string{"remote.example"}, true, true, "10.0.0.100"),
|
||||
message(true, 4, "host4.othersite.example", "", "a@remote.example", "mjl@local.example", store.ValidationStrict, []string{"remote.example"}, true, true, "10.0.0.4"),
|
||||
message(true, 2, "host5.othersite.example", "", "b@remote.example", "mjl@local.example", store.ValidationStrict, []string{"remote.example"}, true, true, "10.10.0.5"),
|
||||
message(true, 1, "host5.othersite.example", "", "b@remote.example", "mjl@local.example", store.ValidationStrict, []string{"remote.example"}, true, true, "10.10.0.5"),
|
||||
}
|
||||
m = message(false, 0, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationStrict, []string{"remote.example"}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xtrue, false, methodMsgfromDomain)
|
||||
|
||||
// we"ve only seen spam, but we don"t want to reject an entire orgdomain with only 2 froms, so inconclusive.
|
||||
msgs = []store.Message{
|
||||
message(true, 2*30, "host3.othersite.example", "", "a@a.remote.example", "mjl@local.example", store.ValidationStrict, []string{"remote.example"}, true, true, "10.0.0.100"),
|
||||
message(true, 4, "host4.othersite.example", "", "a@a.remote.example", "mjl@local.example", store.ValidationStrict, []string{"remote.example"}, true, true, "10.0.0.4"),
|
||||
message(true, 2, "host5.othersite.example", "", "b@b.remote.example", "mjl@local.example", store.ValidationStrict, []string{"remote.example"}, true, true, "10.10.0.5"),
|
||||
message(true, 1, "host5.othersite.example", "", "b@b.remote.example", "mjl@local.example", store.ValidationStrict, []string{"remote.example"}, true, true, "10.10.0.5"),
|
||||
}
|
||||
m = message(false, 0, "host.othersite.example", "", "other@remote.example", "mjl@local.example", store.ValidationStrict, []string{"remote.example"}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xtrue, false, methodMsgfromOrgDomain)
|
||||
|
||||
// All dkim/spf signs are good, so accept.
|
||||
msgs = []store.Message{
|
||||
message(false, 2*30, "host3.esp.example", "bulk@esp.example", "a@espcustomer1.example", "mjl@local.example", store.ValidationNone, []string{"esp.example"}, true, true, "10.0.0.100"),
|
||||
message(false, 4, "host4.esp.example", "bulk@esp.example", "b@espcustomer2.example", "mjl@local.example", store.ValidationNone, []string{"esp.example"}, true, true, "10.0.0.4"),
|
||||
message(false, 2, "host5.esp.example", "bulk@esp.example", "c@espcustomer2.example", "mjl@local.example", store.ValidationNone, []string{"esp.example"}, true, true, "10.10.0.5"),
|
||||
message(false, 1, "host5.esp.example", "bulk@esp.example", "d@espcustomer2.example", "mjl@local.example", store.ValidationNone, []string{"espcustomer2.example", "esp.example"}, true, true, "10.10.0.5"),
|
||||
}
|
||||
m = message(false, 0, "host3.esp.example", "bulk@esp.example", "other@espcustomer3.example", "mjl@local.example", store.ValidationNone, []string{"espcustomer3.example", "esp.example"}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xfalse, true, "dkimspf")
|
||||
|
||||
// All dkim/spf signs are bad, so reject.
|
||||
msgs = []store.Message{
|
||||
message(true, 2*30, "host3.esp.example", "bulk@esp.example", "a@espcustomer1.example", "mjl@local.example", store.ValidationNone, []string{"esp.example"}, true, true, "10.0.0.100"),
|
||||
message(true, 4, "host4.esp.example", "bulk@esp.example", "b@espcustomer2.example", "mjl@local.example", store.ValidationNone, []string{"esp.example"}, true, true, "10.0.0.4"),
|
||||
message(true, 2, "host5.esp.example", "bulk@esp.example", "c@espcustomer2.example", "mjl@local.example", store.ValidationNone, []string{"esp.example"}, true, true, "10.10.0.5"),
|
||||
message(true, 1, "host5.esp.example", "bulk@esp.example", "d@espcustomer2.example", "mjl@local.example", store.ValidationNone, []string{"espcustomer2.example", "esp.example"}, true, true, "10.10.0.5"),
|
||||
}
|
||||
m = message(false, 0, "host3.esp.example", "bulk@esp.example", "other@espcustomer3.example", "mjl@local.example", store.ValidationNone, []string{"espcustomer3.example", "esp.example"}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xtrue, true, "dkimspf")
|
||||
|
||||
// Mixed dkim/spf signals, inconclusive.
|
||||
msgs = []store.Message{
|
||||
message(false, 2*30, "host3.esp.example", "bulk@esp.example", "a@espcustomer1.example", "mjl@local.example", store.ValidationNone, []string{"esp.example"}, true, true, "10.0.0.100"),
|
||||
message(false, 4, "host4.esp.example", "bulk@esp.example", "b@espcustomer2.example", "mjl@local.example", store.ValidationNone, []string{"esp.example"}, true, true, "10.0.0.4"),
|
||||
message(true, 2, "host5.esp.example", "bulk@esp.example", "c@espcustomer2.example", "mjl@local.example", store.ValidationNone, []string{"esp.example"}, true, true, "10.10.0.5"),
|
||||
message(true, 1, "host5.esp.example", "bulk@esp.example", "d@espcustomer2.example", "mjl@local.example", store.ValidationNone, []string{"espcustomer2.example", "esp.example"}, true, true, "10.10.0.5"),
|
||||
}
|
||||
m = message(false, 0, "host3.esp.example", "bulk@esp.example", "other@espcustomer3.example", "mjl@local.example", store.ValidationNone, []string{"espcustomer3.example", "esp.example"}, true, true, "10.0.0.1")
|
||||
check(m, msgs, nil, false, "dkimspf")
|
||||
|
||||
// Just one dkim/spf message, enough for accept.
|
||||
msgs = []store.Message{
|
||||
message(false, 4, "host4.esp.example", "bulk@esp.example", "b@espcustomer2.example", "mjl@local.example", store.ValidationNone, []string{"esp.example"}, true, true, "10.0.0.4"),
|
||||
}
|
||||
m = message(false, 0, "host3.esp.example", "bulk@esp.example", "other@espcustomer3.example", "mjl@local.example", store.ValidationNone, []string{"espcustomer3.example", "esp.example"}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xfalse, true, "dkimspf")
|
||||
|
||||
// Just one dkim/spf message, not enough for reject.
|
||||
msgs = []store.Message{
|
||||
message(true, 4, "host4.esp.example", "bulk@esp.example", "b@espcustomer2.example", "mjl@local.example", store.ValidationNone, []string{"esp.example"}, true, true, "10.0.0.4"),
|
||||
}
|
||||
m = message(false, 0, "host3.esp.example", "bulk@esp.example", "other@espcustomer3.example", "mjl@local.example", store.ValidationNone, []string{"espcustomer3.example", "esp.example"}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xtrue, false, "dkimspf")
|
||||
|
||||
// The exact IP is almost bad, but we need 3 msgs. Other IPs don't matter.
|
||||
msgs = []store.Message{
|
||||
message(false, 7*30, "host1.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"), // too old
|
||||
message(true, 4*30, "host1.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(true, 2*30, "host1.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(true, 1*30, "host2.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite3.example"}, true, true, "10.0.0.2"), // irrelevant
|
||||
}
|
||||
m = message(false, 0, "host.different.example", "sender@different.example", "other@other.example", "mjl@local.example", store.ValidationStrict, []string{}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xtrue, false, "ip1")
|
||||
|
||||
// The exact IP is almost ok, so accept.
|
||||
msgs = []store.Message{
|
||||
message(true, 7*30, "host1.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"), // too old
|
||||
message(false, 2*30, "host1.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(false, 2*30, "host1.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(false, 2*30, "host1.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(false, 1*30, "host2.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite3.example"}, true, true, "10.0.0.2"), // irrelevant
|
||||
}
|
||||
m = message(false, 0, "host.different.example", "sender@different.example", "other@other.example", "mjl@local.example", store.ValidationStrict, []string{}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xfalse, true, "ip1")
|
||||
|
||||
// The exact IP is bad, with enough msgs. Other IPs don't matter.
|
||||
msgs = []store.Message{
|
||||
message(true, 4*30, "host1.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"), // too old
|
||||
message(true, 2*30, "host1.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(true, 2*30, "host1.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(true, 1*30, "host1.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.1"),
|
||||
message(true, 1*30, "host2.othersite.example", "", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite3.example"}, true, true, "10.0.0.2"), // irrelevant
|
||||
}
|
||||
m = message(false, 0, "host.different.example", "sender@different.example", "other@other.example", "mjl@local.example", store.ValidationStrict, []string{}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xtrue, true, "ip1")
|
||||
|
||||
// No exact ip match, nearby IPs (we need 5) are all bad, so reject.
|
||||
msgs = []store.Message{
|
||||
message(true, 2*30, "host2.othersite.example", "sender3@othersite3.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite3.example"}, true, true, "10.0.0.2"),
|
||||
message(true, 2*30, "host2.othersite.example", "sender@othersite2.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite2.example"}, true, true, "10.0.0.2"),
|
||||
message(false, 2*30, "host3.othersite.example", "sender@othersite.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.100"), // other ip
|
||||
message(false, 8, "host3.othersite.example", "sender@othersite2.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite2.example"}, true, true, "10.0.0.100"), // other ip
|
||||
message(true, 8, "host4.othersite.example", "sender@othersite2.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite2.example"}, true, true, "10.0.0.4"),
|
||||
message(true, 4, "host4.othersite.example", "sender@othersite.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.4"),
|
||||
message(true, 2, "host4.othersite.example", "sender@othersite.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.4"),
|
||||
message(false, 2, "host5.othersite.example", "sender@othersite.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.10.0.5"), // other ip
|
||||
message(false, 1, "host5.othersite.example", "sender@othersite.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"none.example"}, true, true, "10.10.0.5"), // other ip
|
||||
}
|
||||
m = message(false, 0, "host.different.example", "sender@different.example", "other@other.example", "mjl@local.example", store.ValidationStrict, []string{}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xtrue, true, "ip2")
|
||||
|
||||
// IPs further away are bad (we need 10), reject.
|
||||
msgs = []store.Message{
|
||||
message(true, 2*30, "host2.othersite.example", "sender3@othersite3.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite3.example"}, true, true, "10.0.0.100"),
|
||||
message(true, 2*30, "host2.othersite.example", "sender@othersite2.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite2.example"}, true, true, "10.0.0.100"),
|
||||
message(true, 2*30, "host2.othersite.example", "sender@othersite2.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite2.example"}, true, true, "10.0.0.100"),
|
||||
message(true, 2*30, "host3.othersite.example", "sender@othersite.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.100"),
|
||||
message(true, 8, "host3.othersite.example", "sender@othersite2.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite2.example"}, true, true, "10.0.0.100"),
|
||||
message(true, 8, "host4.othersite.example", "sender@othersite2.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite2.example"}, true, true, "10.0.0.100"),
|
||||
message(true, 4, "host4.othersite.example", "sender@othersite.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.100"),
|
||||
message(true, 4, "host4.othersite.example", "sender@othersite.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.100"),
|
||||
message(true, 4, "host4.othersite.example", "sender@othersite.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.100"),
|
||||
message(true, 4, "host4.othersite.example", "sender@othersite.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.0.0.100"),
|
||||
message(false, 2, "host5.othersite.example", "sender@othersite.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"othersite.example"}, true, true, "10.10.0.5"),
|
||||
message(false, 1, "host5.othersite.example", "sender@othersite.example", "second@remote.example", "mjl@local.example", store.ValidationStrict, []string{"none.example"}, true, true, "10.10.0.5"),
|
||||
}
|
||||
m = message(false, 0, "host.different.example", "sender@different.example", "other@other.example", "mjl@local.example", store.ValidationStrict, []string{}, true, true, "10.0.0.1")
|
||||
check(m, msgs, xtrue, true, "ip3")
|
||||
}
|
2070
smtpserver/server.go
Normal file
2070
smtpserver/server.go
Normal file
File diff suppressed because it is too large
Load Diff
749
smtpserver/server_test.go
Normal file
749
smtpserver/server_test.go
Normal file
@ -0,0 +1,749 @@
|
||||
package smtpserver
|
||||
|
||||
// todo: test delivery with failing spf/dkim/dmarc
|
||||
// todo: test delivering a message to multiple recipients, and with some of them failing.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
cryptorand "crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mjl-/bstore"
|
||||
|
||||
"github.com/mjl-/mox/config"
|
||||
"github.com/mjl-/mox/dkim"
|
||||
"github.com/mjl-/mox/dmarcdb"
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/mox-"
|
||||
"github.com/mjl-/mox/queue"
|
||||
"github.com/mjl-/mox/smtp"
|
||||
"github.com/mjl-/mox/smtpclient"
|
||||
"github.com/mjl-/mox/store"
|
||||
"github.com/mjl-/mox/subjectpass"
|
||||
"github.com/mjl-/mox/tlsrptdb"
|
||||
)
|
||||
|
||||
func tcheck(t *testing.T, err error, msg string) {
|
||||
if err != nil {
|
||||
t.Helper()
|
||||
t.Fatalf("%s: %s", msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
var submitMessage = strings.ReplaceAll(`From: <mjl@mox.example>
|
||||
To: <remote@example.org>
|
||||
Subject: test
|
||||
Message-Id: <test@mox.example>
|
||||
|
||||
test email
|
||||
`, "\n", "\r\n")
|
||||
|
||||
var deliverMessage = strings.ReplaceAll(`From: <remote@example.org>
|
||||
To: <mjl@mox.example>
|
||||
Subject: test
|
||||
Message-Id: <test@example.org>
|
||||
|
||||
test email
|
||||
`, "\n", "\r\n")
|
||||
|
||||
type testserver struct {
|
||||
t *testing.T
|
||||
acc *store.Account
|
||||
switchDone chan struct{}
|
||||
comm *store.Comm
|
||||
cid int64
|
||||
resolver dns.Resolver
|
||||
user, pass string
|
||||
submission bool
|
||||
dnsbls []dns.Domain
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, configPath string, resolver dns.Resolver) *testserver {
|
||||
ts := testserver{t: t, cid: 1, resolver: resolver}
|
||||
|
||||
mox.Context = context.Background()
|
||||
mox.ConfigStaticPath = configPath
|
||||
mox.MustLoadConfig()
|
||||
dataDir := mox.ConfigDirPath(mox.Conf.Static.DataDir)
|
||||
os.RemoveAll(dataDir)
|
||||
var err error
|
||||
ts.acc, err = store.OpenAccount("mjl")
|
||||
tcheck(t, err, "open account")
|
||||
err = ts.acc.SetPassword("testtest")
|
||||
tcheck(t, err, "set password")
|
||||
ts.switchDone = store.Switchboard()
|
||||
err = queue.Init()
|
||||
tcheck(t, err, "queue init")
|
||||
|
||||
ts.comm = store.RegisterComm(ts.acc)
|
||||
|
||||
return &ts
|
||||
}
|
||||
|
||||
func (ts *testserver) close() {
|
||||
ts.comm.Unregister()
|
||||
queue.Shutdown()
|
||||
close(ts.switchDone)
|
||||
ts.acc.Close()
|
||||
}
|
||||
|
||||
func (ts *testserver) run(fn func(helloErr error, client *smtpclient.Client)) {
|
||||
ts.t.Helper()
|
||||
|
||||
ts.cid += 2
|
||||
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer serverConn.Close()
|
||||
// clientConn is closed as part of closing client.
|
||||
serverdone := make(chan struct{})
|
||||
defer func() { <-serverdone }()
|
||||
|
||||
go func() {
|
||||
tlsConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{fakeCert(ts.t)},
|
||||
}
|
||||
serve("test", ts.cid-2, dns.Domain{ASCII: "mox.example"}, tlsConfig, serverConn, ts.resolver, ts.submission, false, 100<<20, false, false, ts.dnsbls)
|
||||
close(serverdone)
|
||||
}()
|
||||
|
||||
var authLine string
|
||||
if ts.user != "" {
|
||||
authLine = fmt.Sprintf("AUTH PLAIN %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("\u0000%s\u0000%s", ts.user, ts.pass))))
|
||||
}
|
||||
|
||||
client, err := smtpclient.New(context.Background(), xlog.WithCid(ts.cid-1), clientConn, smtpclient.TLSOpportunistic, "mox.example", authLine)
|
||||
if err != nil {
|
||||
clientConn.Close()
|
||||
} else {
|
||||
defer client.Close()
|
||||
}
|
||||
fn(err, client)
|
||||
}
|
||||
|
||||
// Just a cert that appears valid. SMTP client will not verify anything about it
|
||||
// (that is opportunistic TLS for you, "better some than none"). Let's enjoy this
|
||||
// one moment where it makes life easier.
|
||||
func fakeCert(t *testing.T) tls.Certificate {
|
||||
privKey := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) // Fake key, don't use this for real!
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1), // Required field...
|
||||
}
|
||||
localCertBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
|
||||
if err != nil {
|
||||
t.Fatalf("making certificate: %s", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(localCertBuf)
|
||||
if err != nil {
|
||||
t.Fatalf("parsing generated certificate: %s", err)
|
||||
}
|
||||
c := tls.Certificate{
|
||||
Certificate: [][]byte{localCertBuf},
|
||||
PrivateKey: privKey,
|
||||
Leaf: cert,
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Test submission from authenticated user.
|
||||
func TestSubmission(t *testing.T) {
|
||||
ts := newTestServer(t, "../testdata/smtp/mox.conf", dns.MockResolver{})
|
||||
defer ts.close()
|
||||
|
||||
// Set DKIM signing config.
|
||||
dom, _ := mox.Conf.Domain(dns.Domain{ASCII: "mox.example"})
|
||||
sel := config.Selector{
|
||||
HashEffective: "sha256",
|
||||
HeadersEffective: []string{"From", "To", "Subject"},
|
||||
Key: ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)), // Fake key, don't use for real.
|
||||
Domain: dns.Domain{ASCII: "mox.example"},
|
||||
}
|
||||
dom.DKIM = config.DKIM{
|
||||
Selectors: map[string]config.Selector{"testsel": sel},
|
||||
Sign: []string{"testsel"},
|
||||
}
|
||||
mox.Conf.Dynamic.Domains["mox.example"] = dom
|
||||
|
||||
testAuth := func(user, pass string, expErr *smtpclient.Error) {
|
||||
t.Helper()
|
||||
ts.user = user
|
||||
ts.pass = pass
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
t.Helper()
|
||||
mailFrom := "mjl@mox.example"
|
||||
rcptTo := "remote@example.org"
|
||||
if err == nil {
|
||||
err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(submitMessage)), strings.NewReader(submitMessage), false, false)
|
||||
}
|
||||
var cerr smtpclient.Error
|
||||
if expErr == nil && err != nil || expErr != nil && (err == nil || !errors.As(err, &cerr) || cerr.Secode != expErr.Secode) {
|
||||
t.Fatalf("got err %#v, expected %#v", err, expErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ts.submission = true
|
||||
testAuth("", "", &smtpclient.Error{Permanent: true, Code: smtp.C530SecurityRequired, Secode: smtp.SePol7Other0})
|
||||
testAuth("mjl@mox.example", "test", &smtpclient.Error{Secode: smtp.SePol7AuthBadCreds8}) // Bad (short) password.
|
||||
testAuth("mjl@mox.example", "testtesttest", &smtpclient.Error{Secode: smtp.SePol7AuthBadCreds8}) // Bad password.
|
||||
testAuth("mjl@mox.example", "testtest", nil)
|
||||
}
|
||||
|
||||
// Test delivery from external MTA.
|
||||
func TestDelivery(t *testing.T) {
|
||||
resolver := dns.MockResolver{
|
||||
A: map[string][]string{
|
||||
"example.org.": {"127.0.0.10"}, // For mx check.
|
||||
},
|
||||
PTR: map[string][]string{},
|
||||
}
|
||||
ts := newTestServer(t, "../testdata/smtp/mox.conf", resolver)
|
||||
defer ts.close()
|
||||
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "mjl@127.0.0.10"
|
||||
if err == nil {
|
||||
err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false)
|
||||
}
|
||||
var cerr smtpclient.Error
|
||||
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail {
|
||||
t.Fatalf("deliver to ip address, got err %v, expected smtpclient.Error with code %d", err, smtp.C550MailboxUnavail)
|
||||
}
|
||||
})
|
||||
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "mjl@test.example" // Not configured as destination.
|
||||
if err == nil {
|
||||
err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false)
|
||||
}
|
||||
var cerr smtpclient.Error
|
||||
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail {
|
||||
t.Fatalf("deliver to unknown domain, got err %v, expected smtpclient.Error with code %d", err, smtp.C550MailboxUnavail)
|
||||
}
|
||||
})
|
||||
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "unknown@mox.example" // User unknown.
|
||||
if err == nil {
|
||||
err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false)
|
||||
}
|
||||
var cerr smtpclient.Error
|
||||
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail {
|
||||
t.Fatalf("deliver to unknown user for known domain, got err %v, expected smtpclient.Error with code %d", err, smtp.C550MailboxUnavail)
|
||||
}
|
||||
})
|
||||
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "mjl@mox.example"
|
||||
if err == nil {
|
||||
err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false)
|
||||
}
|
||||
var cerr smtpclient.Error
|
||||
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr {
|
||||
t.Fatalf("deliver from user without reputation, valid iprev required, got err %v, expected smtpclient.Error with code %d", err, smtp.C451LocalErr)
|
||||
}
|
||||
})
|
||||
|
||||
// Set up iprev to get delivery from unknown user to be accepted.
|
||||
resolver.PTR["127.0.0.10"] = []string{"example.org."}
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "mjl@mox.example"
|
||||
if err == nil {
|
||||
err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false)
|
||||
}
|
||||
tcheck(t, err, "deliver to remote")
|
||||
|
||||
changes := make(chan []store.Change)
|
||||
go func() {
|
||||
changes <- ts.comm.Get()
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(time.Second)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-changes:
|
||||
case <-timer.C:
|
||||
t.Fatalf("no delivery in 1s")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func tinsertmsg(t *testing.T, acc *store.Account, mailbox string, m *store.Message, msg string) {
|
||||
mf, err := store.CreateMessageTemp("queue-dsn")
|
||||
tcheck(t, err, "temp message")
|
||||
_, err = mf.Write([]byte(msg))
|
||||
tcheck(t, err, "write message")
|
||||
err = acc.DeliverMailbox(xlog, mailbox, m, mf, true)
|
||||
tcheck(t, err, "deliver message")
|
||||
err = mf.Close()
|
||||
tcheck(t, err, "close message")
|
||||
}
|
||||
|
||||
func tretrain(t *testing.T, acc *store.Account) {
|
||||
t.Helper()
|
||||
|
||||
// Fresh empty junkfilter.
|
||||
basePath := mox.DataDirPath("accounts")
|
||||
dbPath := filepath.Join(basePath, acc.Name, "junkfilter.db")
|
||||
bloomPath := filepath.Join(basePath, acc.Name, "junkfilter.bloom")
|
||||
os.Remove(dbPath)
|
||||
os.Remove(bloomPath)
|
||||
jf, _, err := acc.OpenJunkFilter(xlog)
|
||||
tcheck(t, err, "open junk filter")
|
||||
defer jf.Close()
|
||||
|
||||
// Fetch messags to retrain on.
|
||||
q := bstore.QueryDB[store.Message](acc.DB)
|
||||
q.FilterEqual("Seen", true)
|
||||
q.FilterFn(func(m store.Message) bool {
|
||||
return m.Flags.Junk || m.Flags.Notjunk
|
||||
})
|
||||
msgs, err := q.List()
|
||||
tcheck(t, err, "fetch messages")
|
||||
|
||||
// Retrain the messages.
|
||||
for _, m := range msgs {
|
||||
ham := m.Flags.Notjunk
|
||||
|
||||
f, err := os.Open(acc.MessagePath(m.ID))
|
||||
tcheck(t, err, "open message")
|
||||
r := store.FileMsgReader(m.MsgPrefix, f)
|
||||
|
||||
jf.TrainMessage(r, m.Size, ham)
|
||||
|
||||
err = r.Close()
|
||||
tcheck(t, err, "close message")
|
||||
}
|
||||
|
||||
err = jf.Save()
|
||||
tcheck(t, err, "save junkfilter")
|
||||
}
|
||||
|
||||
// Test accept/reject with DMARC reputation and with spammy content.
|
||||
func TestSpam(t *testing.T) {
|
||||
resolver := &dns.MockResolver{
|
||||
A: map[string][]string{
|
||||
"example.org.": {"127.0.0.1"}, // For mx check.
|
||||
},
|
||||
TXT: map[string][]string{
|
||||
"example.org.": {"v=spf1 ip4:127.0.0.10 -all"},
|
||||
"_dmarc.example.org.": {"v=DMARC1;p=reject"},
|
||||
},
|
||||
}
|
||||
ts := newTestServer(t, "../testdata/smtp/junk/mox.conf", resolver)
|
||||
defer ts.close()
|
||||
|
||||
// Insert spammy messages. No junkfilter training yet.
|
||||
m := store.Message{
|
||||
RemoteIP: "127.0.0.10",
|
||||
RemoteIPMasked1: "127.0.0.10",
|
||||
RemoteIPMasked2: "127.0.0.0",
|
||||
RemoteIPMasked3: "127.0.0.0",
|
||||
MailFrom: "remote@example.org",
|
||||
MailFromLocalpart: smtp.Localpart("remote"),
|
||||
MailFromDomain: "example.org",
|
||||
RcptToLocalpart: smtp.Localpart("mjl"),
|
||||
RcptToDomain: "mox.example",
|
||||
MsgFromLocalpart: smtp.Localpart("remote"),
|
||||
MsgFromDomain: "example.org",
|
||||
MsgFromOrgDomain: "example.org",
|
||||
MsgFromValidated: true,
|
||||
MsgFromValidation: store.ValidationStrict,
|
||||
Flags: store.Flags{Seen: true, Junk: true},
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
nm := m
|
||||
tinsertmsg(t, ts.acc, "Inbox", &nm, deliverMessage)
|
||||
}
|
||||
|
||||
checkRejectsCount := func(expect int) {
|
||||
t.Helper()
|
||||
q := bstore.QueryDB[store.Mailbox](ts.acc.DB)
|
||||
q.FilterNonzero(store.Mailbox{Name: "Rejects"})
|
||||
mb, err := q.Get()
|
||||
tcheck(t, err, "get rejects mailbox")
|
||||
qm := bstore.QueryDB[store.Message](ts.acc.DB)
|
||||
qm.FilterNonzero(store.Message{MailboxID: mb.ID})
|
||||
n, err := qm.Count()
|
||||
tcheck(t, err, "count messages in rejects mailbox")
|
||||
if n != expect {
|
||||
t.Fatalf("messages in rejects mailbox, found %d, expected %d", n, expect)
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery from sender with bad reputation should fail.
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "mjl@mox.example"
|
||||
if err == nil {
|
||||
err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false)
|
||||
}
|
||||
var cerr smtpclient.Error
|
||||
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr {
|
||||
t.Fatalf("delivery by bad sender, got err %v, expected smtpclient.Error with code %d", err, smtp.C451LocalErr)
|
||||
}
|
||||
|
||||
// Message should now be in Rejects mailbox.
|
||||
checkRejectsCount(1)
|
||||
})
|
||||
|
||||
// Mark the messages as having good reputation.
|
||||
q := bstore.QueryDB[store.Message](ts.acc.DB)
|
||||
_, err := q.UpdateFields(map[string]any{"Junk": false, "Notjunk": true})
|
||||
tcheck(t, err, "update junkiness")
|
||||
|
||||
// Message should now be accepted.
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "mjl@mox.example"
|
||||
if err == nil {
|
||||
err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false)
|
||||
}
|
||||
tcheck(t, err, "deliver")
|
||||
|
||||
// Message should now be removed from Rejects mailbox.
|
||||
checkRejectsCount(0)
|
||||
})
|
||||
|
||||
// Undo dmarc pass, mark messages as junk, and train the filter.
|
||||
resolver.TXT = nil
|
||||
q = bstore.QueryDB[store.Message](ts.acc.DB)
|
||||
_, err = q.UpdateFields(map[string]any{"Junk": true, "Notjunk": false})
|
||||
tcheck(t, err, "update junkiness")
|
||||
tretrain(t, ts.acc)
|
||||
|
||||
// Message should be refused for spammy content.
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "mjl@mox.example"
|
||||
if err == nil {
|
||||
err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false)
|
||||
}
|
||||
var cerr smtpclient.Error
|
||||
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr {
|
||||
t.Fatalf("attempt to deliver spamy message, got err %v, expected smtpclient.Error with code %d", err, smtp.C451LocalErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Messages that we sent to, that have passing DMARC, but that are otherwise spammy, should be accepted.
|
||||
func TestDMARCSent(t *testing.T) {
|
||||
resolver := &dns.MockResolver{
|
||||
A: map[string][]string{
|
||||
"example.org.": {"127.0.0.1"}, // For mx check.
|
||||
},
|
||||
TXT: map[string][]string{
|
||||
"example.org.": {"v=spf1 ip4:127.0.0.10 -all"},
|
||||
"_dmarc.example.org.": {"v=DMARC1;p=reject"},
|
||||
},
|
||||
}
|
||||
ts := newTestServer(t, "../testdata/smtp/junk/mox.conf", resolver)
|
||||
defer ts.close()
|
||||
|
||||
// Insert spammy messages not related to the test message.
|
||||
m := store.Message{
|
||||
MailFrom: "remote@test.example",
|
||||
RcptToLocalpart: smtp.Localpart("mjl"),
|
||||
RcptToDomain: "mox.example",
|
||||
Flags: store.Flags{Seen: true, Junk: true},
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
nm := m
|
||||
tinsertmsg(t, ts.acc, "Archive", &nm, deliverMessage)
|
||||
}
|
||||
tretrain(t, ts.acc)
|
||||
|
||||
// Baseline, message should be refused for spammy content.
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "mjl@mox.example"
|
||||
if err == nil {
|
||||
err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false)
|
||||
}
|
||||
var cerr smtpclient.Error
|
||||
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr {
|
||||
t.Fatalf("attempt to deliver spamy message, got err %v, expected smtpclient.Error with code %d", err, smtp.C451LocalErr)
|
||||
}
|
||||
})
|
||||
|
||||
// Insert a message that we sent to the address that is about to send to us.
|
||||
var sentMsg store.Message
|
||||
tinsertmsg(t, ts.acc, "Sent", &sentMsg, deliverMessage)
|
||||
err := ts.acc.DB.Insert(&store.Recipient{MessageID: sentMsg.ID, Localpart: "remote", Domain: "example.org", OrgDomain: "example.org", Sent: time.Now()})
|
||||
tcheck(t, err, "inserting message recipient")
|
||||
|
||||
// We should now be accepting the message because we recently sent a message.
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "mjl@mox.example"
|
||||
if err == nil {
|
||||
err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false)
|
||||
}
|
||||
tcheck(t, err, "deliver")
|
||||
})
|
||||
}
|
||||
|
||||
// Test DNSBL, then getting through with subjectpass.
|
||||
func TestBlocklistedSubjectpass(t *testing.T) {
|
||||
// Set up a DNSBL on dnsbl.example, and get DMARC pass.
|
||||
resolver := &dns.MockResolver{
|
||||
A: map[string][]string{
|
||||
"example.org.": {"127.0.0.10"}, // For mx check.
|
||||
"2.0.0.127.dnsbl.example.": {"127.0.0.2"}, // For healthcheck.
|
||||
"10.0.0.127.dnsbl.example.": {"127.0.0.10"}, // Where our connection pretends to come from.
|
||||
},
|
||||
TXT: map[string][]string{
|
||||
"10.0.0.127.dnsbl.example.": {"blocklisted"},
|
||||
"example.org.": {"v=spf1 ip4:127.0.0.10 -all"},
|
||||
"_dmarc.example.org.": {"v=DMARC1;p=reject"},
|
||||
},
|
||||
PTR: map[string][]string{
|
||||
"127.0.0.10": {"example.org."}, // For iprev check.
|
||||
},
|
||||
}
|
||||
ts := newTestServer(t, "../testdata/smtp/mox.conf", resolver)
|
||||
ts.dnsbls = []dns.Domain{{ASCII: "dnsbl.example"}}
|
||||
defer ts.close()
|
||||
|
||||
// Message should be refused softly (temporary error) due to DNSBL.
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "mjl@mox.example"
|
||||
if err == nil {
|
||||
err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false)
|
||||
}
|
||||
var cerr smtpclient.Error
|
||||
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr {
|
||||
t.Fatalf("attempted deliver from dnsblocklisted ip, got err %v, expected smtpclient.Error with code %d", err, smtp.C451LocalErr)
|
||||
}
|
||||
})
|
||||
|
||||
// Set up subjectpass on account.
|
||||
acc := mox.Conf.Dynamic.Accounts[ts.acc.Name]
|
||||
acc.SubjectPass.Period = time.Hour
|
||||
mox.Conf.Dynamic.Accounts[ts.acc.Name] = acc
|
||||
|
||||
// Message should be refused quickly (permanent error) due to DNSBL and Subjectkey.
|
||||
var pass string
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "mjl@mox.example"
|
||||
if err == nil {
|
||||
err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false)
|
||||
}
|
||||
var cerr smtpclient.Error
|
||||
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail {
|
||||
t.Fatalf("attempted deliver from dnsblocklisted ip, got err %v, expected smtpclient.Error with code %d", err, smtp.C550MailboxUnavail)
|
||||
}
|
||||
i := strings.Index(cerr.Line, subjectpass.Explanation)
|
||||
if i < 0 {
|
||||
t.Fatalf("got error line %q, expected error line with subjectpass", cerr.Line)
|
||||
}
|
||||
pass = cerr.Line[i+len(subjectpass.Explanation):]
|
||||
})
|
||||
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "mjl@mox.example"
|
||||
passMessage := strings.Replace(deliverMessage, "Subject: test", "Subject: test "+pass, 1)
|
||||
if err == nil {
|
||||
err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(passMessage)), strings.NewReader(passMessage), false, false)
|
||||
}
|
||||
tcheck(t, err, "deliver with subjectpass")
|
||||
})
|
||||
}
|
||||
|
||||
// Test accepting a DMARC report.
|
||||
func TestDMARCReport(t *testing.T) {
|
||||
resolver := &dns.MockResolver{
|
||||
A: map[string][]string{
|
||||
"example.org.": {"127.0.0.10"}, // For mx check.
|
||||
},
|
||||
TXT: map[string][]string{
|
||||
"example.org.": {"v=spf1 ip4:127.0.0.10 -all"},
|
||||
"_dmarc.example.org.": {"v=DMARC1;p=reject"},
|
||||
},
|
||||
PTR: map[string][]string{
|
||||
"127.0.0.10": {"example.org."}, // For iprev check.
|
||||
},
|
||||
}
|
||||
ts := newTestServer(t, "../testdata/smtp/dmarcreport/mox.conf", resolver)
|
||||
defer ts.close()
|
||||
|
||||
run := func(report string, n int) {
|
||||
t.Helper()
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
t.Helper()
|
||||
|
||||
tcheck(t, err, "run")
|
||||
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "mjl@mox.example"
|
||||
|
||||
msgb := &bytes.Buffer{}
|
||||
_, xerr := fmt.Fprintf(msgb, "From: %s\r\nTo: %s\r\nSubject: dmarc report\r\nMIME-Version: 1.0\r\nContent-Type: text/xml\r\n\r\n", mailFrom, rcptTo)
|
||||
tcheck(t, xerr, "write msg headers")
|
||||
w := quotedprintable.NewWriter(msgb)
|
||||
_, xerr = w.Write([]byte(strings.ReplaceAll(report, "\n", "\r\n")))
|
||||
tcheck(t, xerr, "write message")
|
||||
msg := msgb.String()
|
||||
|
||||
if err == nil {
|
||||
err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false)
|
||||
}
|
||||
tcheck(t, err, "deliver")
|
||||
|
||||
records, err := dmarcdb.Records(context.Background())
|
||||
tcheck(t, err, "dmarcdb records")
|
||||
if len(records) != n {
|
||||
t.Fatalf("got %d dmarcdb records, expected %d or more", len(records), n)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
run(dmarcReport, 0)
|
||||
run(strings.ReplaceAll(dmarcReport, "xmox.nl", "mox.example"), 1)
|
||||
}
|
||||
|
||||
const dmarcReport = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name>example.org</org_name>
|
||||
<email>postmaster@example.org</email>
|
||||
<report_id>1</report_id>
|
||||
<date_range>
|
||||
<begin>1596412800</begin>
|
||||
<end>1596499199</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>xmox.nl</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>reject</p>
|
||||
<sp>reject</sp>
|
||||
<pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>127.0.0.10</source_ip>
|
||||
<count>1</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>pass</dkim>
|
||||
<spf>pass</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>xmox.nl</header_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim>
|
||||
<domain>xmox.nl</domain>
|
||||
<result>pass</result>
|
||||
<selector>testsel</selector>
|
||||
</dkim>
|
||||
<spf>
|
||||
<domain>xmox.nl</domain>
|
||||
<result>pass</result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
`
|
||||
|
||||
// Test accepting a TLS report.
|
||||
func TestTLSReport(t *testing.T) {
|
||||
// Requires setting up DKIM.
|
||||
privKey := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) // Fake key, don't use this for real!
|
||||
dkimRecord := dkim.Record{
|
||||
Version: "DKIM1",
|
||||
Hashes: []string{"sha256"},
|
||||
Flags: []string{"s"},
|
||||
PublicKey: privKey.Public(),
|
||||
Key: "ed25519",
|
||||
}
|
||||
dkimTxt, err := dkimRecord.Record()
|
||||
tcheck(t, err, "dkim record")
|
||||
|
||||
sel := config.Selector{
|
||||
HashEffective: "sha256",
|
||||
HeadersEffective: []string{"From", "To", "Subject", "Date"},
|
||||
Key: privKey,
|
||||
Domain: dns.Domain{ASCII: "testsel"},
|
||||
}
|
||||
dkimConf := config.DKIM{
|
||||
Selectors: map[string]config.Selector{"testsel": sel},
|
||||
Sign: []string{"testsel"},
|
||||
}
|
||||
|
||||
resolver := &dns.MockResolver{
|
||||
A: map[string][]string{
|
||||
"example.org.": {"127.0.0.10"}, // For mx check.
|
||||
},
|
||||
TXT: map[string][]string{
|
||||
"testsel._domainkey.example.org.": {dkimTxt},
|
||||
"example.org.": {"v=spf1 ip4:127.0.0.10 -all"},
|
||||
"_dmarc.example.org.": {"v=DMARC1;p=reject"},
|
||||
},
|
||||
PTR: map[string][]string{
|
||||
"127.0.0.10": {"example.org."}, // For iprev check.
|
||||
},
|
||||
}
|
||||
ts := newTestServer(t, "../testdata/smtp/tlsrpt/mox.conf", resolver)
|
||||
defer ts.close()
|
||||
|
||||
run := func(tlsrpt string, n int) {
|
||||
t.Helper()
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
t.Helper()
|
||||
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "mjl@mox.example"
|
||||
|
||||
msgb := &bytes.Buffer{}
|
||||
_, xerr := fmt.Fprintf(msgb, "From: %s\r\nTo: %s\r\nSubject: tlsrpt report\r\nMIME-Version: 1.0\r\nContent-Type: application/tlsrpt+json\r\n\r\n%s\r\n", mailFrom, rcptTo, tlsrpt)
|
||||
tcheck(t, xerr, "write msg")
|
||||
msg := msgb.String()
|
||||
|
||||
headers, xerr := dkim.Sign(context.Background(), "remote", dns.Domain{ASCII: "example.org"}, dkimConf, false, strings.NewReader(msg))
|
||||
tcheck(t, xerr, "dkim sign")
|
||||
msg = headers + msg
|
||||
|
||||
if err == nil {
|
||||
err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false)
|
||||
}
|
||||
tcheck(t, err, "deliver")
|
||||
|
||||
records, err := tlsrptdb.Records(context.Background())
|
||||
tcheck(t, err, "tlsrptdb records")
|
||||
if len(records) != n {
|
||||
t.Fatalf("got %d tlsrptdb records, expected %d", len(records), n)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const tlsrpt = `{"organization-name":"Example.org","date-range":{"start-datetime":"2022-01-07T00:00:00Z","end-datetime":"2022-01-07T23:59:59Z"},"contact-info":"tlsrpt@example.org","report-id":"1","policies":[{"policy":{"policy-type":"no-policy-found","policy-domain":"xmox.nl"},"summary":{"total-successful-session-count":1,"total-failure-session-count":0}}]}`
|
||||
|
||||
run(tlsrpt, 0)
|
||||
run(strings.ReplaceAll(tlsrpt, "xmox.nl", "mox.example"), 1)
|
||||
|
||||
}
|
Reference in New Issue
Block a user