Files
zeekayandHanzo Dev 15909c7b79 Rename Go module path to github.com/hanzoai/mail
Fork of github.com/mjl-/mox at 5656ca6d1b
(upstream v0.0.15 plus 55 commits).

Rename the module path in go.mod and the import paths in 268 Go files. Also
update the five scripts that "go install" the module (genwebsite.sh,
docker-release.sh, apidiff.sh, test-upgrade.sh, Dockerfile.release), which
would otherwise build upstream rather than this fork.

Add NOTICE recording the upstream project, author, license, and fork point,
and stating which license covers which files: MIT for the mox source and our
changes, MPL-2.0 for publicsuffix/public_suffix_list.txt alone, and the
vendored dependencies' own licenses under licenses/. Embed NOTICE so
"mail licenses" prints the lineage from the running binary. LICENSE.MIT,
LICENSE.MPLv2.0 and the licenses/ tree are preserved verbatim; no code is
relicensed.

The mox command name, config keys, data directory layout and website content
are unchanged. LLM.md records the fork point, the rename, what was left alone,
and the steps to stand the server up.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 23:16:19 -07:00

482 lines
10 KiB
Go

package message
import (
"fmt"
"slices"
"strings"
"github.com/hanzoai/mail/dns"
)
// ../rfc/8601:577
// Authentication-Results header, see RFC 8601.
type AuthResults struct {
Hostname string
// Optional version of Authentication-Results header, assumed "1" when absent,
// which is common.
Version string
Comment string // If not empty, header comment without "()", added after Hostname.
Methods []AuthMethod // Can be empty, in case of "none".
}
// ../rfc/8601:598
// AuthMethod is a result for one authentication method.
//
// Example encoding in the header: "spf=pass smtp.mailfrom=example.net".
type AuthMethod struct {
// E.g. "dkim", "spf", "iprev", "auth".
Method string
Version string // For optional method version. "1" is implied when missing, which is common.
Result string // Each method has a set of known values, e.g. "pass", "temperror", etc.
Comment string // Optional, message header comment.
Reason string // Optional.
Props []AuthProp
}
// ../rfc/8601:606
// AuthProp describes properties for an authentication method.
// Each method has a set of known properties.
// Encoded in the header as "type.property=value", e.g. "smtp.mailfrom=example.net"
// for spf.
type AuthProp struct {
// Valid values maintained at https://www.iana.org/assignments/email-auth/email-auth.xhtml
Type string
Property string
Value string
// Whether value is address-like (localpart@domain, or domain). Or another value,
// which is subject to escaping.
IsAddrLike bool
Comment string // If not empty, header comment without "()", added after Value.
}
// MakeAuthProp is a convenient way to make an AuthProp.
func MakeAuthProp(typ, property, value string, isAddrLike bool, Comment string) AuthProp {
return AuthProp{typ, property, value, isAddrLike, Comment}
}
// todo future: we could store fields as dns.Domain, and when we encode as non-ascii also add the ascii version as a comment.
// Header returns an Authentication-Results header, possibly spanning multiple
// lines, always ending in crlf.
func (h AuthResults) Header() string {
// Escaping of values: ../rfc/8601:684 ../rfc/2045:661
optComment := func(s string) string {
if s != "" {
return " (" + s + ")"
}
return s
}
w := &HeaderWriter{}
w.Add("", "Authentication-Results:"+optComment(h.Comment)+" "+value(h.Hostname, false)+";")
for i, m := range h.Methods {
w.Newline()
tokens := []string{}
addf := func(format string, args ...any) {
s := fmt.Sprintf(format, args...)
tokens = append(tokens, s)
}
addf("%s=%s", m.Method, m.Result)
if m.Comment != "" && (m.Reason != "" || len(m.Props) > 0) {
addf("(%s)", m.Comment)
}
if m.Reason != "" {
addf("reason=%s", value(m.Reason, false))
}
for _, p := range m.Props {
v := value(p.Value, p.IsAddrLike)
addf("%s.%s=%s%s", p.Type, p.Property, v, optComment(p.Comment))
}
for j, t := range tokens {
var sep string
if j > 0 {
sep = " "
}
if j == len(tokens)-1 && i < len(h.Methods)-1 {
t += ";"
}
w.Add(sep, t)
}
}
return w.String()
}
func value(s string, isAddrLike bool) string {
quote := s == ""
for _, c := range s {
// utf-8 does not have to be quoted. ../rfc/6532:242
// Characters outside of tokens do. ../rfc/2045:661
if c <= ' ' || c == 0x7f || (c == '@' && !isAddrLike) || strings.ContainsRune(`()<>,;:\\"/[]?= `, c) {
quote = true
break
}
}
if !quote {
return s
}
var r strings.Builder
r.WriteString(`"`)
for _, c := range s {
if c == '"' || c == '\\' {
r.WriteString("\\")
}
r.WriteString(string(c))
}
r.WriteString(`"`)
return r.String()
}
// ParseAuthResults parses a Authentication-Results header value.
//
// Comments are not populated in the returned AuthResults.
// Both crlf and lf line-endings are accepted. The input string must end with
// either crlf or lf.
func ParseAuthResults(s string) (ar AuthResults, err error) {
// ../rfc/8601:577
lower := make([]byte, len(s))
for i, c := range []byte(s) {
if c >= 'A' && c <= 'Z' {
c += 'a' - 'A'
}
lower[i] = c
}
p := &parser{s: s, lower: string(lower)}
defer p.recover(&err)
p.cfws()
ar.Hostname = p.xvalue()
p.cfws()
ar.Version = p.digits()
p.cfws()
for {
p.xtake(";")
p.cfws()
// Yahoo has ";" at the end of the header value, incorrect.
if !Pedantic && p.end() {
break
}
method := p.xkeyword(false)
p.cfws()
if method == "none" {
if len(ar.Methods) == 0 {
p.xerrorf("missing results")
}
if !p.end() {
p.xerrorf(`data after "none" result`)
}
return
}
ar.Methods = append(ar.Methods, p.xresinfo(method))
p.cfws()
if p.end() {
break
}
}
return
}
type parser struct {
s string
lower string // Like s, but with ascii characters lower-cased (utf-8 offsets preserved).
o int
}
type parseError struct{ err error }
func (p *parser) recover(err *error) {
x := recover()
if x == nil {
return
}
perr, ok := x.(parseError)
if ok {
*err = perr.err
return
}
panic(x)
}
func (p *parser) xerrorf(format string, args ...any) {
panic(parseError{fmt.Errorf(format, args...)})
}
func (p *parser) end() bool {
return p.s[p.o:] == "\r\n" || p.s[p.o:] == "\n"
}
// ../rfc/5322:599
func (p *parser) cfws() {
p.fws()
for p.prefix("(") {
p.xcomment()
}
p.fws()
}
func (p *parser) fws() {
for p.take(" ") || p.take("\t") {
}
opts := []string{"\n ", "\n\t", "\r\n ", "\r\n\t"}
if slices.ContainsFunc(opts, p.take) {
}
for p.take(" ") || p.take("\t") {
}
}
func (p *parser) xcomment() {
p.xtake("(")
p.fws()
for !p.take(")") {
if p.empty() {
p.xerrorf("unexpected end in comment")
}
if p.prefix("(") {
p.xcomment()
p.fws()
continue
}
p.take(`\`)
if c := p.s[p.o]; c > ' ' && c < 0x7f {
p.o++
} else {
p.xerrorf("bad character %c in comment", c)
}
p.fws()
}
}
func (p *parser) prefix(s string) bool {
return strings.HasPrefix(p.lower[p.o:], s)
}
func (p *parser) xvalue() string {
if p.prefix(`"`) {
return p.xquotedString()
}
return p.xtakefn1("value token", func(c rune, i int) bool {
// ../rfc/2045:661
// todo: token cannot contain utf-8? not updated in ../rfc/6532. however, we also use it for the localpart & domain parsing, so we'll allow it.
return c > ' ' && !strings.ContainsRune(`()<>@,;:\\"/[]?= `, c)
})
}
func (p *parser) xchar() rune {
// We are careful to track invalid utf-8 properly.
if p.empty() {
p.xerrorf("need another character")
}
var r rune
var o int
for i, c := range p.s[p.o:] {
if i > 0 {
o = i
break
}
r = c
}
if o == 0 {
p.o = len(p.s)
} else {
p.o += o
}
return r
}
func (p *parser) xquotedString() string {
p.xtake(`"`)
var s string
var esc bool
for {
c := p.xchar()
if esc {
if c >= ' ' && c < 0x7f {
s += string(c)
esc = false
continue
}
p.xerrorf("bad escaped char %c in quoted string", c)
}
if c == '\\' {
esc = true
continue
}
if c == '"' {
return s
}
if c >= ' ' && c != '\\' && c != '"' {
s += string(c)
continue
}
p.xerrorf("invalid quoted string, invalid character %c", c)
}
}
func (p *parser) digits() string {
o := p.o
for o < len(p.s) && p.s[o] >= '0' && p.s[o] <= '9' {
o++
}
p.o = o
return p.s[o:p.o]
}
func (p *parser) xdigits() string {
s := p.digits()
if s == "" {
p.xerrorf("expected digits, remaining %q", p.s[p.o:])
}
return s
}
func (p *parser) xtake(s string) {
if !p.prefix(s) {
p.xerrorf("expected %q, remaining %q", s, p.s[p.o:])
}
p.o += len(s)
}
func (p *parser) empty() bool {
return p.o >= len(p.s)
}
func (p *parser) take(s string) bool {
if p.prefix(s) {
p.o += len(s)
return true
}
return false
}
func (p *parser) xtakefn1(what string, fn func(c rune, i int) bool) string {
if p.empty() {
p.xerrorf("need at least one char for %s", what)
}
for i, c := range p.s[p.o:] {
if !fn(c, i) {
if i == 0 {
p.xerrorf("expected at least one char for %s, remaining %q", what, p.s[p.o:])
}
s := p.s[p.o : p.o+i]
p.o += i
return s
}
}
s := p.s[p.o:]
p.o = len(p.s)
return s
}
// ../rfc/5321:2287
func (p *parser) xkeyword(isResult bool) string {
s := strings.ToLower(p.xtakefn1("keyword", func(c rune, i int) bool {
// Yahoo sends results like "dkim=perm_fail".
return c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || isResult && !Pedantic && c == '_'
}))
if s == "-" {
p.xerrorf("missing keyword")
} else if strings.HasSuffix(s, "-") {
p.o--
s = s[:len(s)-1]
}
return s
}
func (p *parser) xmethodspec(methodKeyword string) (string, string, string) {
p.cfws()
var methodDigits string
if p.take("/") {
methodDigits = p.xdigits()
p.cfws()
}
p.xtake("=")
p.cfws()
result := p.xkeyword(true)
return methodKeyword, methodDigits, result
}
func (p *parser) xpropspec() (ap AuthProp) {
ap.Type = p.xkeyword(false)
p.cfws()
p.xtake(".")
p.cfws()
if p.take("mailfrom") {
ap.Property = "mailfrom"
} else if p.take("rcptto") {
ap.Property = "rcptto"
} else {
ap.Property = p.xkeyword(false)
}
p.cfws()
p.xtake("=")
ap.IsAddrLike, ap.Value = p.xpvalue()
return
}
// method keyword has been parsed, method-version not yet.
func (p *parser) xresinfo(methodKeyword string) (am AuthMethod) {
p.cfws()
am.Method, am.Version, am.Result = p.xmethodspec(methodKeyword)
p.cfws()
if p.take("reason") {
p.cfws()
p.xtake("=")
p.cfws()
am.Reason = p.xvalue()
}
p.cfws()
for !p.prefix(";") && !p.end() {
am.Props = append(am.Props, p.xpropspec())
p.cfws()
}
return
}
// todo: could keep track whether this is a localpart.
func (p *parser) xpvalue() (bool, string) {
p.cfws()
if p.take("@") {
// Bare domain.
dom, _ := p.xdomain()
return true, "@" + dom
}
s := p.xvalue()
if p.take("@") {
dom, _ := p.xdomain()
s += "@" + dom
return true, s
}
return false, s
}
// ../rfc/5321:2291
func (p *parser) xdomain() (string, dns.Domain) {
var s strings.Builder
s.WriteString(p.xsubdomain())
for p.take(".") {
s.WriteString("." + p.xsubdomain())
}
d, err := dns.ParseDomain(s.String())
if err != nil {
p.xerrorf("parsing domain name %q: %s", s.String(), err)
}
if len(s.String()) > 255 {
// ../rfc/5321:3491
p.xerrorf("domain longer than 255 octets")
}
return s.String(), d
}
// ../rfc/5321:2303
// ../rfc/5321:2303 ../rfc/6531:411
func (p *parser) xsubdomain() string {
return p.xtakefn1("subdomain", func(c rune, i int) bool {
return c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || i > 0 && c == '-' || c > 0x7f
})
}