diff --git a/apidiff/v0.0.9.txt b/apidiff/v0.0.9.txt index 81c48df..c1c221b 100644 --- a/apidiff/v0.0.9.txt +++ b/apidiff/v0.0.9.txt @@ -44,6 +44,7 @@ Below are the incompatible changes between v0.0.8 and v0.0.9, per package. # sasl # scram +- HMAC: removed # smtp diff --git a/autotls/autotls_test.go b/autotls/autotls_test.go index 8b36159..838100f 100644 --- a/autotls/autotls_test.go +++ b/autotls/autotls_test.go @@ -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) } diff --git a/dane/dane.go b/dane/dane.go index 3f9ea49..1129fed 100644 --- a/dane/dane.go +++ b/dane/dane.go @@ -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() diff --git a/dane/examples_test.go b/dane/examples_test.go new file mode 100644 index 0000000..c3768fd --- /dev/null +++ b/dane/examples_test.go @@ -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) +} diff --git a/dmarc/dmarc.go b/dmarc/dmarc.go index 20ba825..a105481 100644 --- a/dmarc/dmarc.go +++ b/dmarc/dmarc.go @@ -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 diff --git a/dmarc/examples_test.go b/dmarc/examples_test.go new file mode 100644 index 0000000..55498db --- /dev/null +++ b/dmarc/examples_test.go @@ -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: \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) +} diff --git a/dmarc/parse.go b/dmarc/parse.go index f847b58..e920059 100644 --- a/dmarc/parse.go +++ b/dmarc/parse.go @@ -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) } diff --git a/dmarc/txt.go b/dmarc/txt.go index 32c34a9..db584ac 100644 --- a/dmarc/txt.go +++ b/dmarc/txt.go @@ -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. diff --git a/dns/dns.go b/dns/dns.go index df7641d..69c9b67 100644 --- a/dns/dns.go +++ b/dns/dns.go @@ -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 diff --git a/dns/examples_test.go b/dns/examples_test.go new file mode 100644 index 0000000..fde028b --- /dev/null +++ b/dns/examples_test.go @@ -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 +} diff --git a/dnsbl/dnsbl.go b/dnsbl/dnsbl.go index 8f39ac7..17a479b 100644 --- a/dnsbl/dnsbl.go +++ b/dnsbl/dnsbl.go @@ -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 diff --git a/dnsbl/examples_test.go b/dnsbl/examples_test.go new file mode 100644 index 0000000..102946f --- /dev/null +++ b/dnsbl/examples_test.go @@ -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) + } +} diff --git a/iprev/iprev.go b/iprev/iprev.go index 953470c..52b67d1 100644 --- a/iprev/iprev.go +++ b/iprev/iprev.go @@ -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 diff --git a/message/examples_test.go b/message/examples_test.go new file mode 100644 index 0000000..3101d7b --- /dev/null +++ b/message/examples_test.go @@ -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("") + 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("") + 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", "") // 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" + // Subject: hi =?utf-8?q?=E2=98=BA?= + // Date: 2 Jan 2006 15:04:05 +0700 + // Message-ID: + // 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 +} diff --git a/message/parseheaderfields.go b/message/parseheaderfields.go index 4ddff66..358ddaa 100644 --- a/message/parseheaderfields.go +++ b/message/parseheaderfields.go @@ -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. diff --git a/message/part.go b/message/part.go index 2a4fe36..07012b1 100644 --- a/message/part.go +++ b/message/part.go @@ -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. diff --git a/message/qp.go b/message/qp.go index b1cdd30..768c33c 100644 --- a/message/qp.go +++ b/message/qp.go @@ -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") { diff --git a/message/threadsubject.go b/message/threadsubject.go index 3a67e84..3fba7c4 100644 --- a/message/threadsubject.go +++ b/message/threadsubject.go @@ -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, diff --git a/message/writer.go b/message/writer.go index 4b7bc92..d0606df 100644 --- a/message/writer.go +++ b/message/writer.go @@ -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 diff --git a/mtasts/examples_test.go b/mtasts/examples_test.go new file mode 100644 index 0000000..0a2fd81 --- /dev/null +++ b/mtasts/examples_test.go @@ -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./.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) + } +} diff --git a/mtasts/mtasts.go b/mtasts/mtasts.go index f60364b..beaa5f3 100644 --- a/mtasts/mtasts.go +++ b/mtasts/mtasts.go @@ -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. ) diff --git a/publicsuffix/examples_test.go b/publicsuffix/examples_test.go new file mode 100644 index 0000000..2dc6bec --- /dev/null +++ b/publicsuffix/examples_test.go @@ -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 +} diff --git a/ratelimit/examples_test.go b/ratelimit/examples_test.go new file mode 100644 index 0000000..a1fc442 --- /dev/null +++ b/ratelimit/examples_test.go @@ -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 +} diff --git a/sasl/sasl.go b/sasl/sasl.go index cd8736b..2594e8b 100644 --- a/sasl/sasl.go +++ b/sasl/sasl.go @@ -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} } diff --git a/scram/examples_test.go b/scram/examples_test.go new file mode 100644 index 0000000..f978419 --- /dev/null +++ b/scram/examples_test.go @@ -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 +} diff --git a/scram/scram.go b/scram/scram.go index 627e907..16db9c1 100644 --- a/scram/scram.go +++ b/scram/scram.go @@ -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") } diff --git a/smtpclient/client.go b/smtpclient/client.go index 77126b5..8e6a770 100644 --- a/smtpclient/client.go +++ b/smtpclient/client.go @@ -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 { diff --git a/smtpclient/dial.go b/smtpclient/dial.go index e56fd89..742f9d8 100644 --- a/smtpclient/dial.go +++ b/smtpclient/dial.go @@ -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. // diff --git a/smtpclient/examples_test.go b/smtpclient/examples_test.go new file mode 100644 index 0000000..1f159db --- /dev/null +++ b/smtpclient/examples_test.go @@ -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: \r\nTo: \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. +} diff --git a/smtpclient/gather.go b/smtpclient/gather.go index a680fa6..1d28948 100644 --- a/smtpclient/gather.go +++ b/smtpclient/gather.go @@ -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 diff --git a/spf/examples_test.go b/spf/examples_test.go new file mode 100644 index 0000000..bd96c36 --- /dev/null +++ b/spf/examples_test.go @@ -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) +} diff --git a/spf/received.go b/spf/received.go index e16e880..baecd5b 100644 --- a/spf/received.go +++ b/spf/received.go @@ -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{} diff --git a/spf/spf.go b/spf/spf.go index d62d556..06e27ec 100644 --- a/spf/spf.go +++ b/spf/spf.go @@ -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. diff --git a/subjectpass/subjectpass.go b/subjectpass/subjectpass.go index 88d4688..0f98680 100644 --- a/subjectpass/subjectpass.go +++ b/subjectpass/subjectpass.go @@ -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:) +// +// The token is of the form: (pass:). 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) diff --git a/tlsrpt/examples_test.go b/tlsrpt/examples_test.go new file mode 100644 index 0000000..1bbdaa8 --- /dev/null +++ b/tlsrpt/examples_test.go @@ -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: +To: +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) +} diff --git a/updates/updates.go b/updates/updates.go index 0abe607..31d735f 100644 --- a/updates/updates.go +++ b/updates/updates.go @@ -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., 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() diff --git a/webaccount/accountapi.json b/webaccount/accountapi.json index f0fde4b..4cb88dc 100644 --- a/webaccount/accountapi.json +++ b/webaccount/accountapi.json @@ -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" ] diff --git a/webadmin/adminapi.json b/webadmin/adminapi.json index 5087dd3..fb77c93 100644 --- a/webadmin/adminapi.json +++ b/webadmin/adminapi.json @@ -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", diff --git a/webmail/api.json b/webmail/api.json index 0ed9646..c17fe7f 100644 --- a/webmail/api.json +++ b/webmail/api.json @@ -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" ] diff --git a/webmail/api.ts b/webmail/api.ts index f0f7980..8c9ce31 100644 --- a/webmail/api.ts +++ b/webmail/api.ts @@ -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.