7 "github.com/mjl-/mox/dns"
12// Authentication-Results header, see RFC 8601.
13type AuthResults struct {
15 // Optional version of Authentication-Results header, assumed "1" when absent,
18 Comment string // If not empty, header comment without "()", added after Hostname.
19 Methods []AuthMethod // Can be empty, in case of "none".
24// AuthMethod is a result for one authentication method.
26// Example encoding in the header: "spf=pass smtp.mailfrom=example.net".
27type AuthMethod struct {
28 // E.g. "dkim", "spf", "iprev", "auth".
30 Version string // For optional method version. "1" is implied when missing, which is common.
31 Result string // Each method has a set of known values, e.g. "pass", "temperror", etc.
32 Comment string // Optional, message header comment.
33 Reason string // Optional.
39// AuthProp describes properties for an authentication method.
40// Each method has a set of known properties.
41// Encoded in the header as "type.property=value", e.g. "smtp.mailfrom=example.net"
44 // Valid values maintained at https://www.iana.org/assignments/email-auth/email-auth.xhtml
48 // Whether value is address-like (localpart@domain, or domain). Or another value,
49 // which is subject to escaping.
51 Comment string // If not empty, header comment without "()", added after Value.
54// MakeAuthProp is a convenient way to make an AuthProp.
55func MakeAuthProp(typ, property, value string, isAddrLike bool, Comment string) AuthProp {
56 return AuthProp{typ, property, value, isAddrLike, Comment}
59// todo future: we could store fields as dns.Domain, and when we encode as non-ascii also add the ascii version as a comment.
61// Header returns an Authentication-Results header, possibly spanning multiple
62// lines, always ending in crlf.
63func (h AuthResults) Header() string {
66 optComment := func(s string) string {
74 w.Add("", "Authentication-Results:"+optComment(h.Comment)+" "+value(h.Hostname, false)+";")
75 for i, m := range h.Methods {
79 addf := func(format string, args ...any) {
80 s := fmt.Sprintf(format, args...)
81 tokens = append(tokens, s)
83 addf("%s=%s", m.Method, m.Result)
84 if m.Comment != "" && (m.Reason != "" || len(m.Props) > 0) {
85 addf("(%s)", m.Comment)
88 addf("reason=%s", value(m.Reason, false))
90 for _, p := range m.Props {
91 v := value(p.Value, p.IsAddrLike)
92 addf("%s.%s=%s%s", p.Type, p.Property, v, optComment(p.Comment))
94 for j, t := range tokens {
99 if j == len(tokens)-1 && i < len(h.Methods)-1 {
108func value(s string, isAddrLike bool) string {
110 for _, c := range s {
113 if c <= ' ' || c == 0x7f || (c == '@' && !isAddrLike) || strings.ContainsRune(`()<>,;:\\"/[]?= `, c) {
122 for _, c := range s {
123 if c == '"' || c == '\\' {
132// ParseAuthResults parses a Authentication-Results header value.
134// Comments are not populated in the returned AuthResults.
135// Both crlf and lf line-endings are accepted. The input string must end with
137func ParseAuthResults(s string) (ar AuthResults, err error) {
139 lower := make([]byte, len(s))
140 for i, c := range []byte(s) {
141 if c >= 'A' && c <= 'Z' {
146 p := &parser{s: s, lower: string(lower)}
147 defer p.recover(&err)
150 ar.Hostname = p.xvalue()
152 ar.Version = p.digits()
157 // Yahoo has ";" at the end of the header value, incorrect.
158 if !Pedantic && p.end() {
161 method := p.xkeyword(false)
163 if method == "none" {
164 if len(ar.Methods) == 0 {
165 p.xerrorf("missing results")
168 p.xerrorf(`data after "none" result`)
172 ar.Methods = append(ar.Methods, p.xresinfo(method))
183 lower string // Like s, but with ascii characters lower-cased (utf-8 offsets preserved).
187type parseError struct{ err error }
189func (p *parser) recover(err *error) {
194 perr, ok := x.(parseError)
202func (p *parser) xerrorf(format string, args ...any) {
203 panic(parseError{fmt.Errorf(format, args...)})
206func (p *parser) end() bool {
207 return p.s[p.o:] == "\r\n" || p.s[p.o:] == "\n"
211func (p *parser) cfws() {
219func (p *parser) fws() {
220 for p.take(" ") || p.take("\t") {
222 opts := []string{"\n ", "\n\t", "\r\n ", "\r\n\t"}
223 for _, o := range opts {
228 for p.take(" ") || p.take("\t") {
232func (p *parser) xcomment() {
237 p.xerrorf("unexpected end in comment")
245 if c := p.s[p.o]; c > ' ' && c < 0x7f {
248 p.xerrorf("bad character %c in comment", c)
254func (p *parser) prefix(s string) bool {
255 return strings.HasPrefix(p.lower[p.o:], s)
258func (p *parser) xvalue() string {
260 return p.xquotedString()
262 return p.xtakefn1("value token", func(c rune, i int) bool {
264 // 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.
265 return c > ' ' && !strings.ContainsRune(`()<>@,;:\\"/[]?= `, c)
269func (p *parser) xchar() rune {
270 // We are careful to track invalid utf-8 properly.
272 p.xerrorf("need another character")
276 for i, c := range p.s[p.o:] {
291func (p *parser) xquotedString() string {
298 if c >= ' ' && c < 0x7f {
303 p.xerrorf("bad escaped char %c in quoted string", c)
312 if c >= ' ' && c != '\\' && c != '"' {
316 p.xerrorf("invalid quoted string, invalid character %c", c)
320func (p *parser) digits() string {
322 for o < len(p.s) && p.s[o] >= '0' && p.s[o] <= '9' {
329func (p *parser) xdigits() string {
332 p.xerrorf("expected digits, remaining %q", p.s[p.o:])
337func (p *parser) xtake(s string) {
339 p.xerrorf("expected %q, remaining %q", s, p.s[p.o:])
344func (p *parser) empty() bool {
345 return p.o >= len(p.s)
348func (p *parser) take(s string) bool {
356func (p *parser) xtakefn1(what string, fn func(c rune, i int) bool) string {
358 p.xerrorf("need at least one char for %s", what)
360 for i, c := range p.s[p.o:] {
363 p.xerrorf("expected at least one char for %s, remaining %q", what, p.s[p.o:])
365 s := p.s[p.o : p.o+i]
376func (p *parser) xkeyword(isResult bool) string {
377 s := strings.ToLower(p.xtakefn1("keyword", func(c rune, i int) bool {
378 // Yahoo sends results like "dkim=perm_fail".
379 return c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || isResult && !Pedantic && c == '_'
382 p.xerrorf("missing keyword")
383 } else if strings.HasSuffix(s, "-") {
390func (p *parser) xmethodspec(methodKeyword string) (string, string, string) {
392 var methodDigits string
394 methodDigits = p.xdigits()
399 result := p.xkeyword(true)
400 return methodKeyword, methodDigits, result
403func (p *parser) xpropspec() (ap AuthProp) {
404 ap.Type = p.xkeyword(false)
408 if p.take("mailfrom") {
409 ap.Property = "mailfrom"
410 } else if p.take("rcptto") {
411 ap.Property = "rcptto"
413 ap.Property = p.xkeyword(false)
417 ap.IsAddrLike, ap.Value = p.xpvalue()
421// method keyword has been parsed, method-version not yet.
422func (p *parser) xresinfo(methodKeyword string) (am AuthMethod) {
424 am.Method, am.Version, am.Result = p.xmethodspec(methodKeyword)
426 if p.take("reason") {
430 am.Reason = p.xvalue()
433 for !p.prefix(";") && !p.end() {
434 am.Props = append(am.Props, p.xpropspec())
440// todo: could keep track whether this is a localpart.
441func (p *parser) xpvalue() (bool, string) {
445 dom, _ := p.xdomain()
446 return true, "@" + dom
450 dom, _ := p.xdomain()
458func (p *parser) xdomain() (string, dns.Domain) {
461 s += "." + p.xsubdomain()
463 d, err := dns.ParseDomain(s)
465 p.xerrorf("parsing domain name %q: %s", s, err)
469 p.xerrorf("domain longer than 255 octets")
476func (p *parser) xsubdomain() string {
477 return p.xtakefn1("subdomain", func(c rune, i int) bool {
478 return c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || i > 0 && c == '-' || c > 0x7f