add more documentation, examples with tests to illustrate reusable components

This commit is contained in:
Mechiel Lukkien 2023-12-12 15:47:26 +01:00
parent 810cbdc61d
commit d1b66035a9
No known key found for this signature in database
40 changed files with 973 additions and 119 deletions

View file

@ -44,6 +44,7 @@ Below are the incompatible changes between v0.0.8 and v0.0.9, per package.
# sasl
# scram
- HMAC: removed
# smtp

View file

@ -36,7 +36,7 @@ func TestAutotls(t *testing.T) {
if err := m.HostPolicy(context.Background(), "mox.example"); err == nil || !errors.Is(err, errHostNotAllowed) {
t.Fatalf("hostpolicy, got err %v, expected errHostNotAllowed", err)
}
m.SetAllowedHostnames(log, dns.StrictResolver{}, map[dns.Domain]struct{}{{ASCII: "mox.example"}: {}}, nil, false)
m.SetAllowedHostnames(log, dns.MockResolver{}, map[dns.Domain]struct{}{{ASCII: "mox.example"}: {}}, nil, false)
l = m.Hostnames()
if !reflect.DeepEqual(l, []dns.Domain{{ASCII: "mox.example"}}) {
t.Fatalf("hostnames, got %v, expected single mox.example", l)
@ -90,7 +90,7 @@ func TestAutotls(t *testing.T) {
t.Fatalf("private key changed after reload")
}
m.shutdown = make(chan struct{})
m.SetAllowedHostnames(log, dns.StrictResolver{}, map[dns.Domain]struct{}{{ASCII: "mox.example"}: {}}, nil, false)
m.SetAllowedHostnames(log, dns.MockResolver{}, map[dns.Domain]struct{}{{ASCII: "mox.example"}: {}}, nil, false)
if err := m.HostPolicy(context.Background(), "mox.example"); err != nil {
t.Fatalf("hostpolicy, got err %v, expected no error", err)
}

View file

@ -36,11 +36,11 @@
//
// For TLS certificate verification that requires PKIX/WebPKI/trusted-anchor
// verification (all except DANE-EE), the potential second TLSA candidate base
// domain name is also valid. With SMTP, additionally for hosts found in MX records
// for a "next-hop domain", the "original next-hop domain" (domain of an email
// address to deliver to) is also a valid name, as is the "CNAME-expanded original
// next-hop domain", bringing the potential total allowed names to four (if CNAMEs
// are followed for the MX hosts).
// domain name is also a valid hostname. With SMTP, additionally for hosts found in
// MX records for a "next-hop domain", the "original next-hop domain" (domain of an
// email address to deliver to) is also a valid name, as is the "CNAME-expanded
// original next-hop domain", bringing the potential total allowed names to four
// (if CNAMEs are followed for the MX hosts).
package dane
// todo: why is https://datatracker.ietf.org/doc/html/draft-barnes-dane-uks-00 not in use? sounds reasonable.
@ -105,10 +105,10 @@ func (e VerifyError) Unwrap() error {
return e.Err
}
// Dial looks up a DNSSEC-protected DANE TLSA record for the domain name and
// Dial looks up DNSSEC-protected DANE TLSA records for the domain name and
// port/service in address, checks for allowed usages, makes a network connection
// and verifies the remote certificate against the TLSA records. If
// verification succeeds, the verified record is returned.
// and verifies the remote certificate against the TLSA records. If verification
// succeeds, the verified record is returned.
//
// Different protocols require different usages. For example, SMTP with STARTTLS
// for delivery only allows usages DANE-TA and DANE-EE. If allowedUsages is
@ -273,7 +273,7 @@ func Dial(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, network
// TLSClientConfig returns a tls.Config to be used for dialing/handshaking a
// TLS connection with DANE verification.
//
// Callers should only pass records that are allowed for the use of DANE. DANE
// Callers should only pass records that are allowed for the intended use. DANE
// with SMTP only allows DANE-EE and DANE-TA usages, not the PKIX-usages.
//
// The config has InsecureSkipVerify set to true, with a custom VerifyConnection
@ -317,11 +317,16 @@ func TLSClientConfig(elog *slog.Logger, records []adns.TLSA, allowedHost dns.Dom
//
// When one of the records matches, Verify returns true, along with the matching
// record and a nil error.
// If there is no match, then in the typical case false, a zero record value and a
// nil error is returned.
// If there is no match, then in the typical case Verify returns: false, a zero
// record value and a nil error.
// If an error is encountered while verifying a record, e.g. for x509
// trusted-anchor verification, an error may be returned, typically one or more
// (wrapped) errors of type VerifyError.
//
// Verify is useful when DANE verification and its results has to be done
// separately from other validation, e.g. for MTA-STS. The caller can create a
// tls.Config with a VerifyConnection function that checks DANE and MTA-STS
// separately.
func Verify(elog *slog.Logger, records []adns.TLSA, cs tls.ConnectionState, allowedHost dns.Domain, moreAllowedHosts []dns.Domain, pkixRoots *x509.CertPool) (verified bool, matching adns.TLSA, rerr error) {
log := mlog.New("dane", elog)
MetricVerify.Inc()

33
dane/examples_test.go Normal file
View file

@ -0,0 +1,33 @@
package dane_test
import (
"context"
"crypto/x509"
"log"
"golang.org/x/exp/slog"
"github.com/mjl-/adns"
"github.com/mjl-/mox/dane"
"github.com/mjl-/mox/dns"
)
func ExampleDial() {
ctx := context.Background()
resolver := dns.StrictResolver{}
usages := []adns.TLSAUsage{adns.TLSAUsageDANETA, adns.TLSAUsageDANEEE}
pkixRoots, err := x509.SystemCertPool()
if err != nil {
log.Fatalf("system pkix roots: %v", err)
}
// Connect to SMTP server, use STARTTLS, and verify TLS certificate with DANE.
conn, verifiedRecord, err := dane.Dial(ctx, slog.Default(), resolver, "tcp", "mx.example.com", usages, pkixRoots)
if err != nil {
log.Fatalf("dial: %v", err)
}
defer conn.Close()
log.Printf("connected, conn %v, verified record %s", conn, verifiedRecord)
}

View file

@ -58,7 +58,8 @@ const (
// Result is a DMARC policy evaluation.
type Result struct {
// Whether to reject the message based on policies. If false, the message should
// not necessarily be accepted, e.g. due to reputation or content-based analysis.
// not necessarily be accepted: other checks such as reputation-based and
// content-based analysis may lead to reject the message.
Reject bool
// Result of DMARC validation. A message can fail validation, but still
// not be rejected, e.g. if the policy is "none".
@ -86,12 +87,12 @@ type Result struct {
// domain is the domain with the DMARC record.
//
// rauthentic indicates if the DNS results were DNSSEC-verified.
func Lookup(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, from dns.Domain) (status Status, domain dns.Domain, record *Record, txt string, rauthentic bool, rerr error) {
func Lookup(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, msgFrom dns.Domain) (status Status, domain dns.Domain, record *Record, txt string, rauthentic bool, rerr error) {
log := mlog.New("dmarc", elog)
start := time.Now()
defer func() {
log.Debugx("dmarc lookup result", rerr,
slog.Any("fromdomain", from),
slog.Any("fromdomain", msgFrom),
slog.Any("status", status),
slog.Any("domain", domain),
slog.Any("record", record),
@ -99,15 +100,15 @@ func Lookup(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, from
}()
// ../rfc/7489:859 ../rfc/7489:1370
domain = from
domain = msgFrom
status, record, txt, authentic, err := lookupRecord(ctx, resolver, domain)
if status != StatusNone {
return status, domain, record, txt, authentic, err
}
if record == nil {
// ../rfc/7489:761 ../rfc/7489:1377
domain = publicsuffix.Lookup(ctx, log.Logger, from)
if domain == from {
domain = publicsuffix.Lookup(ctx, log.Logger, msgFrom)
if domain == msgFrom {
return StatusNone, domain, nil, txt, authentic, err
}
@ -222,8 +223,9 @@ func LookupExternalReportsAccepted(ctx context.Context, elog *slog.Logger, resol
// Verify always returns the result of verifying the DMARC policy
// against the message (for inclusion in Authentication-Result headers).
//
// useResult indicates if the result should be applied in a policy decision.
func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, from dns.Domain, dkimResults []dkim.Result, spfResult spf.Status, spfIdentity *dns.Domain, applyRandomPercentage bool) (useResult bool, result Result) {
// useResult indicates if the result should be applied in a policy decision,
// based on the "pct" field in the DMARC record.
func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, msgFrom dns.Domain, dkimResults []dkim.Result, spfResult spf.Status, spfIdentity *dns.Domain, applyRandomPercentage bool) (useResult bool, result Result) {
log := mlog.New("dmarc", elog)
start := time.Now()
defer func() {
@ -237,7 +239,7 @@ func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, from
}
MetricVerify.ObserveLabels(float64(time.Since(start))/float64(time.Second), string(result.Status), reject, use)
log.Debugx("dmarc verify result", result.Err,
slog.Any("fromdomain", from),
slog.Any("fromdomain", msgFrom),
slog.Any("dkimresults", dkimResults),
slog.Any("spfresult", spfResult),
slog.Any("status", result.Status),
@ -246,7 +248,7 @@ func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, from
slog.Duration("duration", time.Since(start)))
}()
status, recordDomain, record, _, authentic, err := Lookup(ctx, log.Logger, resolver, from)
status, recordDomain, record, _, authentic, err := Lookup(ctx, log.Logger, resolver, msgFrom)
if record == nil {
return false, Result{false, status, false, false, recordDomain, record, authentic, err}
}
@ -261,7 +263,7 @@ func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, from
// We treat "quarantine" and "reject" the same. Thus, we also don't "downgrade"
// from reject to quarantine if this message was sampled out.
// ../rfc/7489:1446 ../rfc/7489:1024
if recordDomain != from && record.SubdomainPolicy != PolicyEmpty {
if recordDomain != msgFrom && record.SubdomainPolicy != PolicyEmpty {
result.Reject = record.SubdomainPolicy != PolicyNone
} else {
result.Reject = record.Policy != PolicyNone
@ -288,7 +290,7 @@ func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, from
// ../rfc/7489:1319
// ../rfc/7489:544
if spfResult == spf.StatusPass && spfIdentity != nil && (*spfIdentity == from || result.Record.ASPF == "r" && pubsuffix(from) == pubsuffix(*spfIdentity)) {
if spfResult == spf.StatusPass && spfIdentity != nil && (*spfIdentity == msgFrom || result.Record.ASPF == "r" && pubsuffix(msgFrom) == pubsuffix(*spfIdentity)) {
result.AlignedSPFPass = true
}
@ -299,7 +301,7 @@ func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, from
continue
}
// ../rfc/7489:511
if dkimResult.Status == dkim.StatusPass && dkimResult.Sig != nil && (dkimResult.Sig.Domain == from || result.Record.ADKIM == "r" && pubsuffix(from) == pubsuffix(dkimResult.Sig.Domain)) {
if dkimResult.Status == dkim.StatusPass && dkimResult.Sig != nil && (dkimResult.Sig.Domain == msgFrom || result.Record.ADKIM == "r" && pubsuffix(msgFrom) == pubsuffix(dkimResult.Sig.Domain)) {
// ../rfc/7489:535
result.AlignedDKIMPass = true
break

86
dmarc/examples_test.go Normal file
View file

@ -0,0 +1,86 @@
package dmarc_test
import (
"context"
"log"
"net"
"strings"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dmarc"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/message"
"github.com/mjl-/mox/spf"
)
func ExampleLookup() {
ctx := context.Background()
resolver := dns.StrictResolver{}
msgFrom, err := dns.ParseDomain("sub.example.com")
if err != nil {
log.Fatalf("parsing from domain: %v", err)
}
// Lookup DMARC DNS record for domain.
status, domain, record, txt, authentic, err := dmarc.Lookup(ctx, slog.Default(), resolver, msgFrom)
if err != nil {
log.Fatalf("dmarc lookup: %v", err)
}
log.Printf("status %s, domain %s, record %v, txt %q, dnssec %v", status, domain, record, txt, authentic)
}
func ExampleVerify() {
ctx := context.Background()
resolver := dns.StrictResolver{}
// Message to verify.
msg := strings.NewReader("From: <sender@example.com>\r\nMore: headers\r\n\r\nBody\r\n")
msgFrom, _, _, err := message.From(slog.Default(), true, msg)
if err != nil {
log.Fatalf("parsing message for from header: %v", err)
}
// Verify SPF, for use with DMARC.
args := spf.Args{
RemoteIP: net.ParseIP("10.11.12.13"),
MailFromDomain: dns.Domain{ASCII: "sub.example.com"},
}
spfReceived, spfDomain, _, _, err := spf.Verify(ctx, slog.Default(), resolver, args)
if err != nil {
log.Printf("verifying spf: %v", err)
}
// Verify DKIM-Signature headers, for use with DMARC.
smtputf8 := false
ignoreTestMode := false
dkimResults, err := dkim.Verify(ctx, slog.Default(), resolver, smtputf8, dkim.DefaultPolicy, msg, ignoreTestMode)
if err != nil {
log.Printf("verifying dkim: %v", err)
}
// Verify DMARC, based on DKIM and SPF results.
applyRandomPercentage := true
useResult, result := dmarc.Verify(ctx, slog.Default(), resolver, msgFrom.Domain, dkimResults, spfReceived.Result, &spfDomain, applyRandomPercentage)
// Print results.
log.Printf("dmarc status: %s", result.Status)
log.Printf("use result: %v", useResult)
if useResult && result.Reject {
log.Printf("should reject message")
}
log.Printf("result: %#v", result)
}
func ExampleParseRecord() {
txt := "v=DMARC1; p=reject; rua=mailto:postmaster@mox.example"
record, isdmarc, err := dmarc.ParseRecord(txt)
if err != nil {
log.Fatalf("parsing dmarc record: %v (isdmarc: %v)", err, isdmarc)
}
log.Printf("parsed record: %v", record)
}

View file

@ -19,12 +19,17 @@ func (e parseErr) Error() string {
// for easy comparison.
//
// DefaultRecord provides default values for tags not present in s.
//
// isdmarc indicates if the record starts tag "v" with value "DMARC1", and should
// be treated as a valid DMARC record. Used to detect possibly multiple DMARC
// records (invalid) for a domain with multiple TXT record (quite common).
func ParseRecord(s string) (record *Record, isdmarc bool, rerr error) {
return parseRecord(s, true)
}
// ParseRecordNoRequired is like ParseRecord, but don't check for required fields
// for regular DMARC records. Useful for checking the _report._dmarc record.
// for regular DMARC records. Useful for checking the _report._dmarc record,
// used for opting into receiving reports for other domains.
func ParseRecordNoRequired(s string) (record *Record, isdmarc bool, rerr error) {
return parseRecord(s, false)
}

View file

@ -55,17 +55,17 @@ const (
//
// v=DMARC1; p=reject; rua=mailto:postmaster@mox.example
type Record struct {
Version string // "v=DMARC1"
Version string // "v=DMARC1", fixed.
Policy DMARCPolicy // Required, for "p=".
SubdomainPolicy DMARCPolicy // Like policy but for subdomains. Optional, for "sp=".
AggregateReportAddresses []URI // Optional, for "rua=".
FailureReportAddresses []URI // Optional, for "ruf="
ADKIM Align // "r" (default) for relaxed or "s" for simple. For "adkim=".
ASPF Align // "r" (default) for relaxed or "s" for simple. For "aspf=".
AggregateReportingInterval int // Default 86400. For "ri="
AggregateReportAddresses []URI // Optional, for "rua=". Destination addresses for aggregate reports.
FailureReportAddresses []URI // Optional, for "ruf=". Destination addresses for failure reports.
ADKIM Align // Alignment: "r" (default) for relaxed or "s" for simple. For "adkim=".
ASPF Align // Alignment: "r" (default) for relaxed or "s" for simple. For "aspf=".
AggregateReportingInterval int // In seconds, default 86400. For "ri="
FailureReportingOptions []string // "0" (default), "1", "d", "s". For "fo=".
ReportingFormat []string // "afrf" (default). Ffor "rf=".
Percentage int // Between 0 and 100, default 100. For "pct=".
ReportingFormat []string // "afrf" (default). For "rf=".
Percentage int // Between 0 and 100, default 100. For "pct=". Policy applies randomly to this percentage of messages.
}
// DefaultRecord holds the defaults for a DMARC record.

View file

@ -23,13 +23,14 @@ var (
// Domain is a domain name, with one or more labels, with at least an ASCII
// representation, and for IDNA non-ASCII domains a unicode representation.
// The ASCII string must be used for DNS lookups.
// The ASCII string must be used for DNS lookups. The strings do not have a
// trailing dot. When using with StrictResolver, add the trailing dot.
type Domain struct {
// A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved
// letters/digits/hyphens) labels. Always in lower case.
// letters/digits/hyphens) labels. Always in lower case. No trailing dot.
ASCII string
// Name as U-labels. Empty if this is an ASCII-only domain.
// Name as U-labels. Empty if this is an ASCII-only domain. No trailing dot.
Unicode string
}
@ -68,7 +69,8 @@ func (d Domain) String() string {
}
// LogString returns a domain for logging.
// For IDNA names, the string contains both the unicode and ASCII name.
// For IDNA names, the string is the slash-separated Unicode and ASCII name.
// For ASCII-only domain names, just the ASCII string is returned.
func (d Domain) LogString() string {
if d.Unicode == "" {
return d.ASCII
@ -151,8 +153,8 @@ func ParseDomainLax(s string) (Domain, error) {
//
// A DNS server can respond to a lookup with an error "nxdomain" to indicate a
// name does not exist (at all), or with a success status with an empty list.
// The Go resolver returns an IsNotFound error for both cases, there is no need
// to explicitly check for zero entries.
// The adns resolver (just like the Go resolver) returns an IsNotFound error for
// both cases, there is no need to explicitly check for zero entries.
func IsNotFound(err error) bool {
var dnsErr *adns.DNSError
return err != nil && errors.As(err, &dnsErr) && dnsErr.IsNotFound

36
dns/examples_test.go Normal file
View file

@ -0,0 +1,36 @@
package dns_test
import (
"fmt"
"log"
"github.com/mjl-/mox/dns"
)
func ExampleParseDomain() {
// ASCII-only domain.
basic, err := dns.ParseDomain("example.com")
if err != nil {
log.Fatalf("parse domain: %v", err)
}
fmt.Printf("%s\n", basic)
// IDNA domain xn--74h.example.
smile, err := dns.ParseDomain("☺.example")
if err != nil {
log.Fatalf("parse domain: %v", err)
}
fmt.Printf("%s\n", smile)
// ASCII only domain curl.se in surprisingly allowed spelling.
surprising, err := dns.ParseDomain("ℂᵤⓇℒ。𝐒🄴")
if err != nil {
log.Fatalf("parse domain: %v", err)
}
fmt.Printf("%s\n", surprising)
// Output:
// example.com
// ☺.example/xn--74h.example
// curl.se
}

View file

@ -1,4 +1,17 @@
// Package dnsbl implements DNS block lists (RFC 5782), for checking incoming messages from sources without reputation.
//
// A DNS block list contains IP addresses that should be blocked. The DNSBL is
// queried using DNS "A" lookups. The DNSBL starts at a "zone", e.g.
// "dnsbl.example". To look up whether an IP address is listed, a DNS name is
// composed: For 10.11.12.13, that name would be "13.12.11.10.dnsbl.example". If
// the lookup returns "record does not exist", the IP is not listed. If an IP
// address is returned, the IP is listed. If an IP is listed, an additional TXT
// lookup is done for more information about the block. IPv6 addresses are also
// looked up with an DNS "A" lookup of a name similar to an IPv4 address, but with
// 4-bit hexadecimal dot-separated characters, in reverse.
//
// The health of a DNSBL "zone" can be check through a lookup of 127.0.0.1
// (must not be present) and 127.0.0.2 (must be present).
package dnsbl
import (
@ -21,7 +34,7 @@ var (
MetricLookup stub.HistogramVec = stub.HistogramVecIgnore{}
)
var ErrDNS = errors.New("dnsbl: dns error")
var ErrDNS = errors.New("dnsbl: dns error") // Temporary error.
// Status is the result of a DNSBL lookup.
type Status string

31
dnsbl/examples_test.go Normal file
View file

@ -0,0 +1,31 @@
package dnsbl_test
import (
"context"
"log"
"net"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/dnsbl"
)
func ExampleLookup() {
ctx := context.Background()
resolver := dns.StrictResolver{}
// Lookup if ip 127.0.0.2 is in spamhaus blocklist at zone sbl.spamhaus.org.
status, explanation, err := dnsbl.Lookup(ctx, slog.Default(), resolver, dns.Domain{ASCII: "sbl.spamhaus.org"}, net.ParseIP("127.0.0.2"))
if err != nil {
log.Fatalf("dnsbl lookup: %v", err)
}
switch status {
case dnsbl.StatusTemperr:
log.Printf("dnsbl lookup, temporary dns error: %v", err)
case dnsbl.StatusPass:
log.Printf("dnsbl lookup, ip not listed")
case dnsbl.StatusFail:
log.Printf("dnsbl lookup, ip listed: %s", explanation)
}
}

View file

@ -25,7 +25,7 @@ var (
// Lookup errors.
var (
ErrNoRecord = errors.New("iprev: no reverse dns record")
ErrDNS = errors.New("iprev: dns lookup")
ErrDNS = errors.New("iprev: dns lookup") // Temporary error.
)
// ../rfc/8601:1082

196
message/examples_test.go Normal file
View file

@ -0,0 +1,196 @@
package message_test
import (
"bytes"
"errors"
"fmt"
"io"
"log"
"strings"
"time"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/message"
"github.com/mjl-/mox/smtp"
)
func ExampleDecodeReader() {
// Convert from iso-8859-1 to utf-8.
input := []byte{'t', 0xe9, 's', 't'}
output, err := io.ReadAll(message.DecodeReader("iso-8859-1", bytes.NewReader(input)))
if err != nil {
log.Fatalf("read from decoder: %v", err)
}
fmt.Printf("%s\n", string(output))
// Output: tést
}
func ExampleMessageIDCanonical() {
// Valid message-id.
msgid, invalidAddress, err := message.MessageIDCanonical("<ok@localhost>")
if err != nil {
fmt.Printf("invalid message-id: %v\n", err)
} else {
fmt.Printf("canonical: %s %v\n", msgid, invalidAddress)
}
// Missing <>.
msgid, invalidAddress, err = message.MessageIDCanonical("bogus@localhost")
if err != nil {
fmt.Printf("invalid message-id: %v\n", err)
} else {
fmt.Printf("canonical: %s %v\n", msgid, invalidAddress)
}
// Invalid address, but returned as not being in error.
msgid, invalidAddress, err = message.MessageIDCanonical("<invalid>")
if err != nil {
fmt.Printf("invalid message-id: %v\n", err)
} else {
fmt.Printf("canonical: %s %v\n", msgid, invalidAddress)
}
// Output:
// canonical: ok@localhost false
// invalid message-id: not a message-id: missing <
// canonical: invalid true
}
func ExampleThreadSubject() {
// Basic subject.
s, isResp := message.ThreadSubject("nothing special", false)
fmt.Printf("%s, response: %v\n", s, isResp)
// List tags and "re:" are stripped.
s, isResp = message.ThreadSubject("[list1] [list2] Re: test", false)
fmt.Printf("%s, response: %v\n", s, isResp)
// "fwd:" is stripped.
s, isResp = message.ThreadSubject("fwd: a forward", false)
fmt.Printf("%s, response: %v\n", s, isResp)
// Trailing "(fwd)" is also a forward.
s, isResp = message.ThreadSubject("another forward (fwd)", false)
fmt.Printf("%s, response: %v\n", s, isResp)
// [fwd: ...] is stripped.
s, isResp = message.ThreadSubject("[fwd: [list] fwd: re: it's complicated]", false)
fmt.Printf("%s, response: %v\n", s, isResp)
// Output:
// nothing special, response: false
// test, response: true
// a forward, response: true
// another forward, response: true
// it's complicated, response: true
}
func ExampleComposer() {
// We store in a buffer. We could also write to a file.
var b bytes.Buffer
// NewComposer. Keep in mind that operations on a Composer will panic on error.
xc := message.NewComposer(&b, 10*1024*1024)
// Catch and handle errors when composing.
defer func() {
x := recover()
if x == nil {
return
}
if err, ok := x.(error); ok && errors.Is(err, message.ErrCompose) {
log.Printf("compose: %v", err)
}
panic(x)
}()
// Add an address header.
xc.HeaderAddrs("From", []message.NameAddress{{DisplayName: "Charlie", Address: smtp.Address{Localpart: "root", Domain: dns.Domain{ASCII: "localhost"}}}})
// Add subject header, with encoding
xc.Subject("hi ☺")
// Add Date and Message-ID headers, required.
tm, _ := time.Parse(time.RFC3339, "2006-01-02T15:04:05+07:00")
xc.Header("Date", tm.Format(message.RFC5322Z))
xc.Header("Message-ID", "<unique@host>") // Should generate unique id for each message.
xc.Header("MIME-Version", "1.0")
// Write content-* headers for the text body.
body, ct, cte := xc.TextPart("this is the body")
xc.Header("Content-Type", ct)
xc.Header("Content-Transfer-Encoding", cte)
// Header/Body separator
xc.Line()
// The part body. Use mime/multipart to make messages with multiple parts.
xc.Write(body)
// Flush any buffered writes to the original writer.
xc.Flush()
fmt.Println(strings.ReplaceAll(b.String(), "\r\n", "\n"))
// Output:
// From: "Charlie" <root@localhost>
// Subject: hi =?utf-8?q?=E2=98=BA?=
// Date: 2 Jan 2006 15:04:05 +0700
// Message-ID: <unique@host>
// MIME-Version: 1.0
// Content-Type: text/plain; charset=us-ascii
// Content-Transfer-Encoding: 7bit
//
// this is the body
}
func ExamplePart() {
// Parse a message from an io.ReaderAt, which could be a file.
strict := false
r := strings.NewReader("header: value\r\nanother: value\r\n\r\nbody ...\r\n")
part, err := message.Parse(slog.Default(), strict, r)
if err != nil {
log.Fatalf("parsing message: %v", err)
}
// The headers of the first part have been parsed, i.e. the message headers.
// A message can be multipart (e.g. alternative, related, mixed), and possibly
// nested.
// By walking the entire message, all part metadata (like offsets into the file
// where a part starts) is recorded.
err = part.Walk(slog.Default(), nil)
if err != nil {
log.Fatalf("walking message: %v", err)
}
// Messages can have a recursive multipart structure. Print the structure.
var printPart func(indent string, p message.Part)
printPart = func(indent string, p message.Part) {
log.Printf("%s- part: %v", indent, part)
for _, pp := range p.Parts {
printPart(" "+indent, pp)
}
}
printPart("", part)
}
func ExampleWriter() {
// NewWriter on a string builder.
var b strings.Builder
w := message.NewWriter(&b)
// Write some lines, some with proper CRLF line ending, others without.
fmt.Fprint(w, "header: value\r\n")
fmt.Fprint(w, "another: value\n") // missing \r
fmt.Fprint(w, "\r\n")
fmt.Fprint(w, "hi ☺\n") // missing \r
fmt.Printf("%q\n", b.String())
fmt.Printf("%v %v", w.HaveBody, w.Has8bit)
// Output:
// "header: value\r\nanother: value\r\n\r\nhi ☺\r\n"
// true true
}

View file

@ -8,8 +8,9 @@ import (
)
// ParseHeaderFields parses only the header fields in "fields" from the complete
// header buffer "header", while using "scratch" as temporary space, prevent lots
// of unneeded allocations when only a few headers are needed.
// header buffer "header". It uses "scratch" as temporary space, which can be
// reused across calls, potentially saving lots of unneeded allocations when only a
// few headers are needed and/or many messages are parsed.
func ParseHeaderFields(header []byte, scratch []byte, fields [][]byte) (textproto.MIMEHeader, error) {
// todo: should not use mail.ReadMessage, it allocates a bufio.Reader. should implement header parsing ourselves.

View file

@ -580,7 +580,7 @@ func (p *Part) Reader() io.Reader {
return p.bodyReader(p.RawReader())
}
// ReaderUTF8OrBinary returns a reader for the decode body content, transformed to
// ReaderUTF8OrBinary returns a reader for the decoded body content, transformed to
// utf-8 for known mime/iana encodings (only if they aren't us-ascii or utf-8
// already). For unknown or missing character sets/encodings, the original reader
// is returned.

View file

@ -4,8 +4,10 @@ import (
"strings"
)
// NeedsQuotedPrintable returns whether text should be encoded with
// quoted-printable. If not, it can be included as 7bit or 8bit encoding.
// NeedsQuotedPrintable returns whether text, with crlf-separated lines, should be
// encoded with quoted-printable, based on line lengths and any bare carriage
// return or bare newline. If not, it can be included as 7bit or 8bit encoding in a
// new message.
func NeedsQuotedPrintable(text string) bool {
// ../rfc/2045:1025
for _, line := range strings.Split(text, "\r\n") {

View file

@ -9,6 +9,9 @@ import (
// always required to match to an existing thread, both if
// References/In-Reply-To header(s) are present, and if not.
//
// isResponse indicates if this message is a response, such as a reply or a
// forward.
//
// Subject should already be q/b-word-decoded.
//
// If allowNull is true, base subjects with a \0 can be returned. If not set,

View file

@ -9,9 +9,9 @@ import (
type Writer struct {
writer io.Writer
HaveBody bool // Body is optional. ../rfc/5322:343
Has8bit bool // Whether a byte with the high/8bit has been read. So whether this is 8BITMIME instead of 7BIT.
Size int64
HaveBody bool // Body is optional in a message. ../rfc/5322:343
Has8bit bool // Whether a byte with the high/8bit has been read. So whether this needs SMTP 8BITMIME instead of 7BIT.
Size int64 // Number of bytes written, may be different from bytes read due to LF to CRLF conversion.
tail [3]byte // For detecting header/body-separating crlf.
// todo: should be parsing headers here, as we go
@ -22,7 +22,9 @@ func NewWriter(w io.Writer) *Writer {
return &Writer{writer: w, tail: [3]byte{0, '\r', '\n'}}
}
// Write implements io.Writer.
// Write implements io.Writer, and writes buf as message to the Writer's underlying
// io.Writer. It converts bare new lines (LF) to carriage returns with new lines
// (CRLF).
func (w *Writer) Write(buf []byte) (int, error) {
origtail := w.tail

37
mtasts/examples_test.go Normal file
View file

@ -0,0 +1,37 @@
package mtasts_test
import (
"context"
"errors"
"log"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mtasts"
)
func ExampleGet() {
ctx := context.Background()
resolver := dns.StrictResolver{}
// Get for example.org does a DNS TXT lookup at _mta-sts.example.org.
// If the record exists, the policy is fetched from https://mta-sts.<domain>/.well-known/mta-sts.txt, and parsed.
record, policy, policyText, err := mtasts.Get(ctx, slog.Default(), resolver, dns.Domain{ASCII: "example.org"})
if err != nil {
log.Printf("looking up mta-sts record and fetching policy: %v", err)
if !errors.Is(err, mtasts.ErrDNS) {
log.Printf("domain does not implement mta-sts")
}
// Continuing, we may have a record but not a policy.
} else {
log.Printf("domain implements mta-sts")
}
if record != nil {
log.Printf("mta-sts DNS record: %#v", record)
}
if policy != nil {
log.Printf("mta-sts policy: %#v", policy)
log.Printf("mta-sts policy text:\n%s", policyText)
}
}

View file

@ -72,7 +72,7 @@ type Mode string
const (
ModeEnforce Mode = "enforce" // Policy must be followed, i.e. deliveries must fail if a TLS connection cannot be made.
ModeTesting Mode = "testing" // In case TLS cannot be negotiated, plain SMTP can be used, but failures must be reported, e.g. with TLS-RPT.
ModeTesting Mode = "testing" // In case TLS cannot be negotiated, plain SMTP can be used, but failures must be reported, e.g. with TLSRPT.
ModeNone Mode = "none" // In case MTA-STS is not or no longer implemented.
)

View file

@ -0,0 +1,18 @@
package publicsuffix_test
import (
"context"
"fmt"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/publicsuffix"
)
func ExampleLookup() {
// Lookup the organizational domain for sub.example.org.
orgDom := publicsuffix.Lookup(context.Background(), slog.Default(), dns.Domain{ASCII: "sub.example.org"})
fmt.Println(orgDom)
// Output: example.org
}

View file

@ -0,0 +1,49 @@
package ratelimit_test
import (
"fmt"
"net"
"time"
"github.com/mjl-/mox/ratelimit"
)
func ExampleLimiter() {
// Make a new rate limit that has maxima per minute, hour and day. The maxima are
// tracked per ipmasked1 (ipv4 /32 or ipv6 /64), ipmasked2 (ipv4 /26 or ipv6 /48)
// and ipmasked3 (ipv4 /21 or ipv6 /32).
//
// It is common to allow short bursts (with a narrow window), but not allow a high
// sustained rate (with wide window).
limit := ratelimit.Limiter{
WindowLimits: []ratelimit.WindowLimit{
{Window: time.Minute, Limits: [...]int64{2, 3, 4}},
{Window: time.Hour, Limits: [...]int64{4, 6, 8}},
{Window: 24 * time.Hour, Limits: [...]int64{20, 40, 60}},
},
}
tm, _ := time.Parse(time.RFC3339, "2006-01-02T15:04:05Z")
fmt.Println("1:", limit.Add(net.ParseIP("127.0.0.1"), tm, 1)) // Success.
fmt.Println("2:", limit.Add(net.ParseIP("127.0.0.1"), tm, 1)) // Success.
fmt.Println("3:", limit.Add(net.ParseIP("127.0.0.1"), tm, 1)) // Failure, too many from same ip.
fmt.Println("4:", limit.Add(net.ParseIP("127.0.0.2"), tm, 1)) // Success, different IP, though nearby.
fmt.Println("5:", limit.Add(net.ParseIP("127.0.0.2"), tm, 1)) // Failure, hits ipmasked2 check.
fmt.Println("6:", limit.Add(net.ParseIP("127.0.0.1"), tm.Add(time.Minute), 1)) // Success, in next minute.
fmt.Println("7:", limit.Add(net.ParseIP("127.0.0.1"), tm.Add(2*time.Minute), 1)) // Success, in another minute.
fmt.Println("8:", limit.Add(net.ParseIP("127.0.0.1"), tm.Add(3*time.Minute), 1)) // Failure, hitting hourly window for ipmasked1.
limit.Reset(net.ParseIP("127.0.0.1"), tm.Add(3*time.Minute))
fmt.Println("9:", limit.Add(net.ParseIP("127.0.0.1"), tm.Add(3*time.Minute), 1)) // Success.
// Output:
// 1: true
// 2: true
// 3: false
// 4: true
// 5: false
// 6: true
// 7: true
// 8: false
// 9: true
}

View file

@ -12,16 +12,28 @@ import (
"github.com/mjl-/mox/scram"
)
// Client is a SASL client
// Client is a SASL client.
//
// A SASL client can be used for authentication in IMAP, SMTP and other protocols.
// A client and server exchange messages in step lock. In IMAP and SMTP, these
// messages are encoded with base64. Each SASL mechanism has predefined steps, but
// the transaction can be aborted by either side at any time. An IMAP or SMTP
// client must choose a SASL mechanism, instantiate a SASL client, and call Next
// with a nil parameter. The resulting data must be written to the server, properly
// encoded. The client must then read the response from the server and feed it to
// the SASL client, which will return more data to send, or an error.
type Client interface {
// Name as used in SMTP AUTH, e.g. PLAIN, CRAM-MD5, SCRAM-SHA-256.
// cleartextCredentials indicates if credentials are exchanged in clear text, which influences whether they are logged.
// Name as used in SMTP or IMAP authentication, e.g. PLAIN, CRAM-MD5,
// SCRAM-SHA-256. cleartextCredentials indicates if credentials are exchanged in
// clear text, which can be used to decide if the exchange is logged.
Info() (name string, cleartextCredentials bool)
// Next is called for each step of the SASL communication. The first call has a nil
// fromServer and serves to get a possible "initial response" from the client. If
// the client sends its final message it indicates so with last. Returning an error
// aborts the authentication attempt.
// Next must be called for each step of the SASL transaction. The first call has a
// nil fromServer and serves to get a possible "initial response" from the client
// to the server. When last is true, the message from client to server is the last
// one, and the server must send a verdict. If err is set, the transaction must be
// aborted.
//
// For the first toServer ("initial response"), a nil toServer indicates there is
// no data, which is different from a non-nil zero-length toServer.
Next(fromServer []byte) (toServer []byte, last bool, err error)
@ -35,6 +47,9 @@ type clientPlain struct {
var _ Client = (*clientPlain)(nil)
// NewClientPlain returns a client for SASL PLAIN authentication.
//
// PLAIN is specified in RFC 4616, The PLAIN Simple Authentication and Security
// Layer (SASL) Mechanism.
func NewClientPlain(username, password string) Client {
return &clientPlain{username, password, 0}
}
@ -61,6 +76,7 @@ type clientLogin struct {
var _ Client = (*clientLogin)(nil)
// NewClientLogin returns a client for the obsolete SASL LOGIN authentication.
//
// See https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00
func NewClientLogin(username, password string) Client {
return &clientLogin{username, password, 0}
@ -90,6 +106,9 @@ type clientCRAMMD5 struct {
var _ Client = (*clientCRAMMD5)(nil)
// NewClientCRAMMD5 returns a client for SASL CRAM-MD5 authentication.
//
// CRAM-MD5 is specified in RFC 2195, IMAP/POP AUTHorize Extension for Simple
// Challenge/Response.
func NewClientCRAMMD5(username, password string) Client {
return &clientCRAMMD5{username, password, 0}
}
@ -160,11 +179,17 @@ type clientSCRAMSHA struct {
var _ Client = (*clientSCRAMSHA)(nil)
// NewClientSCRAMSHA1 returns a client for SASL SCRAM-SHA-1 authentication.
//
// SCRAM-SHA-1 is specified in RFC 5802, Salted Challenge Response Authentication
// Mechanism (SCRAM) SASL and GSS-API Mechanisms.
func NewClientSCRAMSHA1(username, password string) Client {
return &clientSCRAMSHA{username, password, "SCRAM-SHA-1", 0, nil}
}
// NewClientSCRAMSHA256 returns a client for SASL SCRAM-SHA-256 authentication.
//
// SCRAM-SHA-256 is specified in RFC 7677, SCRAM-SHA-256 and SCRAM-SHA-256-PLUS
// Simple Authentication and Security Layer (SASL) Mechanisms.
func NewClientSCRAMSHA256(username, password string) Client {
return &clientSCRAMSHA{username, password, "SCRAM-SHA-256", 0, nil}
}

70
scram/examples_test.go Normal file
View file

@ -0,0 +1,70 @@
package scram_test
import (
"crypto/sha256"
"fmt"
"log"
"github.com/mjl-/mox/scram"
)
func Example() {
// Prepare credentials.
//
// The client normally remembers the password and uses it during authentication.
//
// The server sets the iteration count, generates a salt and uses the password once
// to generate salted password hash. The salted password hash is used to
// authenticate the client during authentication.
iterations := 4096
salt := scram.MakeRandom()
password := "test1234"
saltedPassword := scram.SaltPassword(sha256.New, password, salt, iterations)
check := func(err error, msg string) {
if err != nil {
log.Fatalf("%s: %s", msg, err)
}
}
// Make a new client for authenticating user mjl with SCRAM-SHA-256.
username := "mjl"
authz := ""
client := scram.NewClient(sha256.New, username, authz)
clientFirst, err := client.ClientFirst()
check(err, "client.ClientFirst")
// Instantia a new server with the initial message from the client.
server, err := scram.NewServer(sha256.New, []byte(clientFirst))
check(err, "NewServer")
// Generate first message from server to client, with a challenge.
serverFirst, err := server.ServerFirst(iterations, salt)
check(err, "server.ServerFirst")
// Continue at client with first message from server, resulting in message from
// client to server.
clientFinal, err := client.ServerFirst([]byte(serverFirst), password)
check(err, "client.ServerFirst")
// Continue at server with message from client.
// The server authenticates the client in this step.
serverFinal, err := server.Finish([]byte(clientFinal), saltedPassword)
if err != nil {
fmt.Println("server does not accept client credentials")
} else {
fmt.Println("server has accepted client credentials")
}
// Finally, the client verifies that the server knows the salted password hash.
err = client.ServerFinal([]byte(serverFinal))
if err != nil {
fmt.Println("client does not accept server")
} else {
fmt.Println("client has accepted server")
}
// Output:
// server has accepted client credentials
// client has accepted server
}

View file

@ -2,7 +2,8 @@
//
// SCRAM-SHA-256 and SCRAM-SHA-1 allow a client to authenticate to a server using a
// password without handing plaintext password over to the server. The client also
// verifies the server knows (a derivative of) the password.
// verifies the server knows (a derivative of) the password. Both the client and
// server side are implemented.
package scram
// todo: test with messages that contains extensions
@ -88,8 +89,8 @@ func SaltPassword(h func() hash.Hash, password string, salt []byte, iterations i
return pbkdf2.Key([]byte(password), salt, iterations, h().Size(), h)
}
// HMAC returns the hmac with key over msg.
func HMAC(h func() hash.Hash, key []byte, msg string) []byte {
// hmac0 returns the hmac with key over msg.
func hmac0(h func() hash.Hash, key []byte, msg string) []byte {
mac := hmac.New(h, key)
mac.Write([]byte(msg))
return mac.Sum(nil)
@ -211,19 +212,19 @@ func (s *Server) Finish(clientFinal []byte, saltedPassword []byte) (serverFinal
msg := s.clientFirstBare + "," + s.serverFirst + "," + s.clientFinalWithoutProof
clientKey := HMAC(s.h, saltedPassword, "Client Key")
clientKey := hmac0(s.h, saltedPassword, "Client Key")
h := s.h()
h.Write(clientKey)
storedKey := h.Sum(nil)
clientSig := HMAC(s.h, storedKey, msg)
clientSig := hmac0(s.h, storedKey, msg)
xor(clientSig, clientKey) // Now clientProof.
if !bytes.Equal(clientSig, proof) {
return "e=" + string(ErrInvalidProof), ErrInvalidProof
}
serverKey := HMAC(s.h, saltedPassword, "Server Key")
serverSig := HMAC(s.h, serverKey, msg)
serverKey := hmac0(s.h, saltedPassword, "Server Key")
serverSig := hmac0(s.h, serverKey, msg)
return fmt.Sprintf("v=%s", base64.StdEncoding.EncodeToString(serverSig)), nil
}
@ -321,11 +322,11 @@ func (c *Client) ServerFirst(serverFirst []byte, password string) (clientFinal s
c.authMessage = c.clientFirstBare + "," + c.serverFirst + "," + c.clientFinalWithoutProof
c.saltedPassword = SaltPassword(c.h, password, salt, iterations)
clientKey := HMAC(c.h, c.saltedPassword, "Client Key")
clientKey := hmac0(c.h, c.saltedPassword, "Client Key")
h := c.h()
h.Write(clientKey)
storedKey := h.Sum(nil)
clientSig := HMAC(c.h, storedKey, c.authMessage)
clientSig := hmac0(c.h, storedKey, c.authMessage)
xor(clientSig, clientKey) // Now clientProof.
clientProof := clientSig
@ -350,8 +351,8 @@ func (c *Client) ServerFinal(serverFinal []byte) (rerr error) {
p.xtake("v=")
verifier := p.xbase64()
serverKey := HMAC(c.h, c.saltedPassword, "Server Key")
serverSig := HMAC(c.h, serverKey, c.authMessage)
serverKey := hmac0(c.h, c.saltedPassword, "Server Key")
serverSig := hmac0(c.h, serverKey, c.authMessage)
if !bytes.Equal(verifier, serverSig) {
return fmt.Errorf("incorrect server signature")
}

View file

@ -1,4 +1,29 @@
// Package smtpclient is an SMTP client, used by the queue for sending outgoing messages.
// Package smtpclient is an SMTP client, for submitting to an SMTP server or
// delivering from a queue.
//
// Email clients can submit a message to SMTP server, after which the server is
// responsible for delivery to the final destination. A submission client
// typically connects with TLS, and PKIX-verifies the server's certificate. The
// client then authenticates using a SASL mechanism.
//
// Email servers manage a message queue, from which they will try to deliver
// messages. In case of temporary failures, the message is kept in the queue and
// tried again later. For delivery, no authentication is done. TLS is opportunistic
// by default (TLS certificates not verified), but TLS and certificate verification
// can be opted into by domains by specifying an MTA-STS policy for the domain, or
// DANE TLSA records for their MX hosts.
//
// Delivering a message from a queue would involve:
// 1. Looking up an MTA-STS policy, through a cache.
// 2. Resolving the MX targets for a domain, through smtpclient.GatherDestinations,
// and for each destination try delivery through:
// 3. Looking up IP addresses for the destination, with smtpclient.GatherIPs.
// 4. Looking up TLSA records for DANE, in case of authentic DNS responses
// (DNSSEC), with smtpclient.GatherTLSA.
// 5. Dialing the MX target with smtpclient.Dial.
// 6. Initializing a SMTP session with smtpclient.New, with proper TLS
// configuration based on discovered MTA-STS and DANE policies, and finally calling
// client.Deliver.
package smtpclient
import (
@ -201,22 +226,28 @@ type Opts struct {
// returned on which eventually Close must be called. Otherwise an error is
// returned and the caller is responsible for closing the connection.
//
// Connecting to the correct host is outside the scope of the client. The queue
// managing outgoing messages decides which host to deliver to, taking multiple MX
// records with preferences, other DNS records, MTA-STS, retries and special
// cases into account.
// Connecting to the correct host for delivery can be done using the Gather
// functions, and with Dial. The queue managing outgoing messages typically decides
// which host to deliver to, taking multiple MX records with preferences, other DNS
// records, MTA-STS, retries and special cases into account.
//
// tlsMode indicates if and how TLS may/must (not) be used. tlsVerifyPKIX
// indicates if TLS certificates must be validated against the PKIX/WebPKI
// certificate authorities (if TLS is done). DANE-verification is done when
// opts.DANERecords is not nil. TLS verification errors will be ignored if
// opts.IgnoreTLSVerification is set. If TLS is done, PKIX verification is
// always performed for tracking the results for TLS reporting, but if
// tlsVerifyPKIX is false, the verification result does not affect the
// connection. At the time of writing, delivery of email on the internet is done
// with opportunistic TLS without PKIX verification by default. Recipient domains
// can opt-in to PKIX verification by publishing an MTA-STS policy, or opt-in to
// DANE verification by publishing DNSSEC-protected TLSA records in DNS.
// tlsMode indicates if and how TLS may/must (not) be used.
//
// tlsVerifyPKIX indicates if TLS certificates must be validated against the
// PKIX/WebPKI certificate authorities (if TLS is done).
//
// DANE-verification is done when opts.DANERecords is not nil.
//
// TLS verification errors will be ignored if opts.IgnoreTLSVerification is set.
//
// If TLS is done, PKIX verification is always performed for tracking the results
// for TLS reporting, but if tlsVerifyPKIX is false, the verification result does
// not affect the connection.
//
// At the time of writing, delivery of email on the internet is done with
// opportunistic TLS without PKIX verification by default. Recipient domains can
// opt-in to PKIX verification by publishing an MTA-STS policy, or opt-in to DANE
// verification by publishing DNSSEC-protected TLSA records in DNS.
func New(ctx context.Context, elog *slog.Logger, conn net.Conn, tlsMode TLSMode, tlsVerifyPKIX bool, ehloHostname, remoteHostname dns.Domain, opts Opts) (*Client, error) {
ensureResult := func(r *tlsrpt.Result) *tlsrpt.Result {
if r == nil {

View file

@ -41,8 +41,9 @@ type Dialer interface {
// Dial connects to host by dialing ips, taking previous attempts in dialedIPs into
// accounts (for greylisting, blocklisting and ipv4/ipv6).
//
// If the previous attempt used IPv4, this attempt will use IPv6 (in case one of
// the IPs is in a DNSBL).
// If the previous attempt used IPv4, this attempt will use IPv6 (useful in case
// one of the IPs is in a DNSBL).
//
// The second attempt for an address family we prefer the same IP as earlier, to
// increase our chances if remote is doing greylisting.
//

View file

@ -0,0 +1,58 @@
package smtpclient_test
import (
"context"
"log"
"net"
"strings"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/sasl"
"github.com/mjl-/mox/smtpclient"
)
func ExampleClient() {
// Submit a message to an SMTP server, with authentication. The SMTP server is
// responsible for getting the message delivered.
// Make TCP connection to submission server.
conn, err := net.Dial("tcp", "submit.example.org:465")
if err != nil {
log.Fatalf("dial submission server: %v", err)
}
defer conn.Close()
// Initialize the SMTP session, with a EHLO, STARTTLS and authentication.
// Verify the server TLS certificate with PKIX/WebPKI.
ctx := context.Background()
tlsVerifyPKIX := true
opts := smtpclient.Opts{
Auth: []sasl.Client{
// Prefer strongest authentication mechanism, allow up to older CRAM-MD5.
sasl.NewClientSCRAMSHA256("mjl", "test1234"),
sasl.NewClientSCRAMSHA1("mjl", "test1234"),
sasl.NewClientCRAMMD5("mjl", "test1234"),
},
}
localname := dns.Domain{ASCII: "localhost"}
remotename := dns.Domain{ASCII: "submit.example.org"}
client, err := smtpclient.New(ctx, slog.Default(), conn, smtpclient.TLSImmediate, tlsVerifyPKIX, localname, remotename, opts)
if err != nil {
log.Fatalf("initialize smtp to submission server: %v", err)
}
defer client.Close()
// Send the message to the server, which will add it to its queue.
req8bitmime := false // ASCII-only, so 8bitmime not required.
reqSMTPUTF8 := false // No UTF-8 headers, so smtputf8 not required.
requireTLS := false // Not supported by most servers at the time of writing.
msg := "From: <mjl@example.org>\r\nTo: <other@example.org>\r\nSubject: hi\r\n\r\nnice to test you.\r\n"
err = client.Deliver(ctx, "mjl@example.org", "other@example.com", int64(len(msg)), strings.NewReader(msg), req8bitmime, reqSMTPUTF8, requireTLS)
if err != nil {
log.Fatalf("submit message to smtp server: %v", err)
}
// Message has been submitted.
}

View file

@ -42,11 +42,11 @@ var (
// expandedNextHopAuthentic indicates if the DNS records after following CNAMEs were
// DNSSEC secure.
//
// These authentic flags are used by DANE, to determine where to look up TLSA
// These authentic results are needed for DANE, to determine where to look up TLSA
// records, and which names to allow in the remote TLS certificate. If MX records
// were found, both the original and expanded next-hops must be authentic for DANE
// to apply. For a non-IP with no MX records found, the authentic result can be
// used to decide which of the names to use as TLSA base domain.
// to be option. For a non-IP with no MX records found, the authentic result can
// be used to decide which of the names to use as TLSA base domain.
func GatherDestinations(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, origNextHop dns.IPDomain) (haveMX, origNextHopAuthentic, expandedNextHopAuthentic bool, expandedNextHop dns.Domain, hosts []dns.IPDomain, permanent bool, err error) {
// ../rfc/5321:3824

61
spf/examples_test.go Normal file
View file

@ -0,0 +1,61 @@
package spf_test
import (
"context"
"log"
"net"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/spf"
)
func ExampleVerify() {
ctx := context.Background()
resolver := dns.StrictResolver{}
args := spf.Args{
// IP from SMTP session.
RemoteIP: net.ParseIP("1.2.3.4"),
// Based on "MAIL FROM" in SMTP session.
MailFromLocalpart: smtp.Localpart("user"),
MailFromDomain: dns.Domain{ASCII: "sendingdomain.example.com"},
// From HELO/EHLO in SMTP session.
HelloDomain: dns.IPDomain{Domain: dns.Domain{ASCII: "mx.example.com"}},
// LocalIP and LocalHostname should be set, they may be used when evaluating macro's.
}
// Lookup SPF record and evaluate against IP and domain in args.
received, domain, explanation, authentic, err := spf.Verify(ctx, slog.Default(), resolver, args)
// received.Result is always set, regardless of err.
switch received.Result {
case spf.StatusNone:
log.Printf("no useful spf result, domain probably has no spf record")
case spf.StatusNeutral:
log.Printf("spf has no statement on ip, with \"?\" qualifier")
case spf.StatusPass:
log.Printf("ip is authorized")
case spf.StatusFail:
log.Printf("ip is not authorized, with \"-\" qualifier")
case spf.StatusSoftfail:
log.Printf("ip is probably not authorized, with \"~\" qualifier, softfail")
case spf.StatusTemperror:
log.Printf("temporary error, possibly dns lookup failure, try again soon")
case spf.StatusPermerror:
log.Printf("permanent error, possibly invalid spf records, later attempts likely have the same result")
}
if err != nil {
log.Printf("error: %v", err)
}
if explanation != "" {
log.Printf("explanation from remote about spf result: %s", explanation)
}
log.Printf("result is for domain %s", domain) // mailfrom or ehlo/ehlo.
log.Printf("dns lookups dnssec-protected: %v", authentic)
}

View file

@ -83,8 +83,8 @@ func quotedString(s string) string {
return w.String()
}
// Header returns a Received-SPF header line including trailing crlf that can
// be prepended to an incoming message.
// Header returns a Received-SPF header including trailing crlf that can be
// prepended to an incoming message.
func (r Received) Header() string {
// ../rfc/7208:2043
w := &message.HeaderWriter{}

View file

@ -117,7 +117,7 @@ var timeNow = time.Now
// Lookup looks up and parses an SPF TXT record for domain.
//
// authentic indicates if the DNS results were DNSSEC-verified.
// Authentic indicates if the DNS results were DNSSEC-verified.
func Lookup(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, domain dns.Domain) (rstatus Status, rtxt string, rrecord *Record, authentic bool, rerr error) {
log := mlog.New("spf", elog)
start := time.Now()
@ -186,7 +186,7 @@ func Lookup(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, domai
// Verify takes the maximum number of 10 DNS requests into account, and the maximum
// of 2 lookups resulting in no records ("void lookups").
//
// authentic indicates if the DNS results were DNSSEC-verified.
// Authentic indicates if the DNS results were DNSSEC-verified.
func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, args Args) (received Received, domain dns.Domain, explanation string, authentic bool, rerr error) {
log := mlog.New("spf", elog)
start := time.Now()
@ -869,7 +869,7 @@ func expandIP(ip net.IP) string {
}
// validateDNS checks if a DNS name is valid. Must not end in dot. This does not
// check valid host names, e.g. _ is allows in DNS but not in a host name.
// check valid host names, e.g. _ is allowed in DNS but not in a host name.
func validateDNS(s string) error {
// ../rfc/7208:800
// note: we are not checking for max 253 bytes length, because one of the callers may be chopping off labels to "correct" the name.

View file

@ -1,4 +1,10 @@
// Package subjectpass implements a mechanism for reject an incoming message with a challenge to include a token in a next delivery attempt.
//
// An SMTP server can reject a message with instructions to send another
// message, this time including a special token. The sender will receive a DSN,
// which will include the error message with instructions. By sending the
// message again with the token, as instructed, the SMTP server can recognize
// the token, verify it, and accept the message.
package subjectpass
import (
@ -38,7 +44,9 @@ var Explanation = "Your message resembles spam. If your email is legitimate, ple
// Generate generates a token that is valid for "mailFrom", starting from "tm"
// and signed with "key".
// The token is of the form: (pass:<signeddata>)
//
// The token is of the form: (pass:<signeddata>). Instructions to the sender should
// be to include this token in the Subject header of a new message.
func Generate(elog *slog.Logger, mailFrom smtp.Address, key []byte, tm time.Time) string {
log := mlog.New("subjectpass", elog)

68
tlsrpt/examples_test.go Normal file
View file

@ -0,0 +1,68 @@
package tlsrpt_test
import (
"context"
"log"
"strings"
"golang.org/x/exp/slog"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/tlsrpt"
)
func ExampleLookup() {
ctx := context.Background()
resolver := dns.StrictResolver{}
domain, err := dns.ParseDomain("domain.example")
if err != nil {
log.Fatalf("parsing domain: %v", err)
}
// Lookup TLSRPT record in DNS, and parse it.
record, txt, err := tlsrpt.Lookup(ctx, slog.Default(), resolver, domain)
if err != nil {
log.Fatalf("looking up tlsrpt record: %v", err)
}
log.Printf("TLSRPT record: %s", txt)
log.Printf("Parsed: %v", record)
}
func ExampleParseMessage() {
// Message, as received over SMTP.
msg := `From: <tlsrpt@mail.sender.example.com>
To: <mts-sts-tlsrpt@example.net>
Subject: Report Domain: example.net
Report-ID: <735ff.e317+bf22029@example.net>
TLS-Report-Domain: example.net
TLS-Report-Submitter: mail.sender.example.com
MIME-Version: 1.0
Content-Type: application/tlsrpt+gzip
Content-Transfer-Encoding: base64
Content-Disposition: attachment;
filename="mail.sender.example!example.com!1013662812!1013749130.json.gz"
H4sIAPZreGUAA51UbW/aMBD+DL/Cyr5NdeokJIVI0za1aPswdRWgiXWqImMbai2JI9tBMMR/n52Y
lwkx2KQocXx3vud57s6bbscTcoFL/gtrLkpY4oJ5KfDuRVHhcg2n3o1xoVgzKHG5sLZNt9PxlMZS
Q7uveRsRoiCBqAdRMEEobZ5nG9zxWEnPeYZRGg/M8+x1O1ubiYhSY6IhL+fC+iqtoGSVkJqXiw/E
oVr5bIWLKmcNutYOObUBMUriXnhHYBjRCPbuCIazhCE46CUMI9YnvVkbVYmcE86UCfphUFpWbnPt
SO7/oV5XzKFpKB0sSksDzJ1h95dMKiNkCsaT8TJw3h2vEJSlQDNleRx2Vyl46xeY5/6O2vqYWuuE
VxlemOh+0kPIa3Zf/kRBhTmjtAjPHWNSwVeh9BHSs4nbDPa9bYI9VRcFlkeyaKFxDlVNCFNqXpul
+dr2IaIubY44CpObY9+5SVVLduIYoego0c6LMm1Wag924yBLpupc78tBmGmLOSe2O9mq4pLRvWrK
dJ2RGhYaQ161bYeClM76KZ4RarozCNP0UCDJCOPLJqJVajcJxSq4VCELm9ETbgFCjUNL7hyJZpJ0
rmAptJG0sr38bzyiK3mEl3gcYneZIh/5QRD5cXKJrEG188CUcnuZmLLbMZZFc7XYA1+1rlR6e9tO
rPJP5tlZMhsH3gNOwTvgJhoQAEEYARq9AWOn2aPQ451iwLtC7CXOOW1vOtdrfxE6GPT9OPBNGf0k
vEak/jVVgDNMftbVf/ZUdGy3oyIZVo2dNudPYzTIvmXD0Sh7Gn2dfs+ePk4+Z1+Gj5/MZzi9Hw4f
hg9Oqa4bc7N46W67vwF2Eq+hDAYAAA==
`
msg = strings.ReplaceAll(msg, "\n", "\r\n")
// Parse the email message, and the TLSRPT report within.
report, err := tlsrpt.ParseMessage(slog.Default(), strings.NewReader(msg))
if err != nil {
log.Fatalf("parsing tlsrpt report in message: %v", err)
}
log.Printf("report: %#v", report)
}

View file

@ -173,6 +173,14 @@ func FetchChangelog(ctx context.Context, elog *slog.Logger, baseURL string, base
// Check checks for an updated version through DNS and fetches a
// changelog if so.
//
// Check looks up a TXT record at _updates.<domain>, and parses the record. If the
// latest version is more recent than lastKnown, an update is available, and Check
// will fetch the signed changes since lastKnown, verify the signatures, and
// return the changelog. The latest version and parsed DNS record is returned
// regardless of whether a new version was found. A non-nil changelog is only
// returned when a new version was found and a changelog could be fetched and
// verified.
func Check(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, domain dns.Domain, lastKnown Version, changelogBaseURL string, pubKey []byte) (rversion Version, rrecord *Record, changelog *Changelog, rerr error) {
log := mlog.New("updates", elog)
start := time.Now()

View file

@ -97,18 +97,18 @@
"Structs": [
{
"Name": "Domain",
"Docs": "Domain is a domain name, with one or more labels, with at least an ASCII\nrepresentation, and for IDNA non-ASCII domains a unicode representation.\nThe ASCII string must be used for DNS lookups.",
"Docs": "Domain is a domain name, with one or more labels, with at least an ASCII\nrepresentation, and for IDNA non-ASCII domains a unicode representation.\nThe ASCII string must be used for DNS lookups. The strings do not have a\ntrailing dot. When using with StrictResolver, add the trailing dot.",
"Fields": [
{
"Name": "ASCII",
"Docs": "A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case.",
"Docs": "A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case. No trailing dot.",
"Typewords": [
"string"
]
},
{
"Name": "Unicode",
"Docs": "Name as U-labels. Empty if this is an ASCII-only domain.",
"Docs": "Name as U-labels. Empty if this is an ASCII-only domain. No trailing dot.",
"Typewords": [
"string"
]

View file

@ -1267,18 +1267,18 @@
},
{
"Name": "Domain",
"Docs": "Domain is a domain name, with one or more labels, with at least an ASCII\nrepresentation, and for IDNA non-ASCII domains a unicode representation.\nThe ASCII string must be used for DNS lookups.",
"Docs": "Domain is a domain name, with one or more labels, with at least an ASCII\nrepresentation, and for IDNA non-ASCII domains a unicode representation.\nThe ASCII string must be used for DNS lookups. The strings do not have a\ntrailing dot. When using with StrictResolver, add the trailing dot.",
"Fields": [
{
"Name": "ASCII",
"Docs": "A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case.",
"Docs": "A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case. No trailing dot.",
"Typewords": [
"string"
]
},
{
"Name": "Unicode",
"Docs": "Name as U-labels. Empty if this is an ASCII-only domain.",
"Docs": "Name as U-labels. Empty if this is an ASCII-only domain. No trailing dot.",
"Typewords": [
"string"
]
@ -1767,7 +1767,7 @@
"Fields": [
{
"Name": "Version",
"Docs": "\"v=DMARC1\"",
"Docs": "\"v=DMARC1\", fixed.",
"Typewords": [
"string"
]
@ -1788,7 +1788,7 @@
},
{
"Name": "AggregateReportAddresses",
"Docs": "Optional, for \"rua=\".",
"Docs": "Optional, for \"rua=\". Destination addresses for aggregate reports.",
"Typewords": [
"[]",
"URI"
@ -1796,7 +1796,7 @@
},
{
"Name": "FailureReportAddresses",
"Docs": "Optional, for \"ruf=\"",
"Docs": "Optional, for \"ruf=\". Destination addresses for failure reports.",
"Typewords": [
"[]",
"URI"
@ -1804,21 +1804,21 @@
},
{
"Name": "ADKIM",
"Docs": "\"r\" (default) for relaxed or \"s\" for simple. For \"adkim=\".",
"Docs": "Alignment: \"r\" (default) for relaxed or \"s\" for simple. For \"adkim=\".",
"Typewords": [
"Align"
]
},
{
"Name": "ASPF",
"Docs": "\"r\" (default) for relaxed or \"s\" for simple. For \"aspf=\".",
"Docs": "Alignment: \"r\" (default) for relaxed or \"s\" for simple. For \"aspf=\".",
"Typewords": [
"Align"
]
},
{
"Name": "AggregateReportingInterval",
"Docs": "Default 86400. For \"ri=\"",
"Docs": "In seconds, default 86400. For \"ri=\"",
"Typewords": [
"int32"
]
@ -1833,7 +1833,7 @@
},
{
"Name": "ReportingFormat",
"Docs": "\"afrf\" (default). Ffor \"rf=\".",
"Docs": "\"afrf\" (default). For \"rf=\".",
"Typewords": [
"[]",
"string"
@ -1841,7 +1841,7 @@
},
{
"Name": "Percentage",
"Docs": "Between 0 and 100, default 100. For \"pct=\".",
"Docs": "Between 0 and 100, default 100. For \"pct=\". Policy applies randomly to this percentage of messages.",
"Typewords": [
"int32"
]
@ -4206,7 +4206,7 @@
{
"Name": "ModeTesting",
"Value": "testing",
"Docs": "In case TLS cannot be negotiated, plain SMTP can be used, but failures must be reported, e.g. with TLS-RPT."
"Docs": "In case TLS cannot be negotiated, plain SMTP can be used, but failures must be reported, e.g. with TLSRPT."
},
{
"Name": "ModeNone",

View file

@ -981,18 +981,18 @@
},
{
"Name": "Domain",
"Docs": "Domain is a domain name, with one or more labels, with at least an ASCII\nrepresentation, and for IDNA non-ASCII domains a unicode representation.\nThe ASCII string must be used for DNS lookups.",
"Docs": "Domain is a domain name, with one or more labels, with at least an ASCII\nrepresentation, and for IDNA non-ASCII domains a unicode representation.\nThe ASCII string must be used for DNS lookups. The strings do not have a\ntrailing dot. When using with StrictResolver, add the trailing dot.",
"Fields": [
{
"Name": "ASCII",
"Docs": "A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case.",
"Docs": "A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case. No trailing dot.",
"Typewords": [
"string"
]
},
{
"Name": "Unicode",
"Docs": "Name as U-labels. Empty if this is an ASCII-only domain.",
"Docs": "Name as U-labels. Empty if this is an ASCII-only domain. No trailing dot.",
"Typewords": [
"string"
]

View file

@ -120,10 +120,11 @@ export interface MessageAddress {
// Domain is a domain name, with one or more labels, with at least an ASCII
// representation, and for IDNA non-ASCII domains a unicode representation.
// The ASCII string must be used for DNS lookups.
// The ASCII string must be used for DNS lookups. The strings do not have a
// trailing dot. When using with StrictResolver, add the trailing dot.
export interface Domain {
ASCII: string // A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case.
Unicode: string // Name as U-labels. Empty if this is an ASCII-only domain.
ASCII: string // A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case. No trailing dot.
Unicode: string // Name as U-labels. Empty if this is an ASCII-only domain. No trailing dot.
}
// SubmitMessage is an email message to be sent to one or more recipients.