mirror of
https://github.com/hanzoai/mail.git
synced 2026-09-23 23:53:02 +00:00
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]>
482 lines
10 KiB
Go
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
|
|
})
|
|
}
|