1package message
2
3import (
4 "fmt"
5 "slices"
6 "strings"
7
8 "github.com/mjl-/mox/dns"
9)
10
11// ../rfc/8601:577
12
13// Authentication-Results header, see RFC 8601.
14type AuthResults struct {
15 Hostname string
16 // Optional version of Authentication-Results header, assumed "1" when absent,
17 // which is common.
18 Version string
19 Comment string // If not empty, header comment without "()", added after Hostname.
20 Methods []AuthMethod // Can be empty, in case of "none".
21}
22
23// ../rfc/8601:598
24
25// AuthMethod is a result for one authentication method.
26//
27// Example encoding in the header: "spf=pass smtp.mailfrom=example.net".
28type AuthMethod struct {
29 // E.g. "dkim", "spf", "iprev", "auth".
30 Method string
31 Version string // For optional method version. "1" is implied when missing, which is common.
32 Result string // Each method has a set of known values, e.g. "pass", "temperror", etc.
33 Comment string // Optional, message header comment.
34 Reason string // Optional.
35 Props []AuthProp
36}
37
38// ../rfc/8601:606
39
40// AuthProp describes properties for an authentication method.
41// Each method has a set of known properties.
42// Encoded in the header as "type.property=value", e.g. "smtp.mailfrom=example.net"
43// for spf.
44type AuthProp struct {
45 // Valid values maintained at https://www.iana.org/assignments/email-auth/email-auth.xhtml
46 Type string
47 Property string
48 Value string
49 // Whether value is address-like (localpart@domain, or domain). Or another value,
50 // which is subject to escaping.
51 IsAddrLike bool
52 Comment string // If not empty, header comment without "()", added after Value.
53}
54
55// MakeAuthProp is a convenient way to make an AuthProp.
56func MakeAuthProp(typ, property, value string, isAddrLike bool, Comment string) AuthProp {
57 return AuthProp{typ, property, value, isAddrLike, Comment}
58}
59
60// 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
62// Header returns an Authentication-Results header, possibly spanning multiple
63// lines, always ending in crlf.
64func (h AuthResults) Header() string {
65 // Escaping of values: ../rfc/8601:684 ../rfc/2045:661
66
67 optComment := func(s string) string {
68 if s != "" {
69 return " (" + s + ")"
70 }
71 return s
72 }
73
74 w := &HeaderWriter{}
75 w.Add("", "Authentication-Results:"+optComment(h.Comment)+" "+value(h.Hostname, false)+";")
76 for i, m := range h.Methods {
77 w.Newline()
78
79 tokens := []string{}
80 addf := func(format string, args ...any) {
81 s := fmt.Sprintf(format, args...)
82 tokens = append(tokens, s)
83 }
84 addf("%s=%s", m.Method, m.Result)
85 if m.Comment != "" && (m.Reason != "" || len(m.Props) > 0) {
86 addf("(%s)", m.Comment)
87 }
88 if m.Reason != "" {
89 addf("reason=%s", value(m.Reason, false))
90 }
91 for _, p := range m.Props {
92 v := value(p.Value, p.IsAddrLike)
93 addf("%s.%s=%s%s", p.Type, p.Property, v, optComment(p.Comment))
94 }
95 for j, t := range tokens {
96 var sep string
97 if j > 0 {
98 sep = " "
99 }
100 if j == len(tokens)-1 && i < len(h.Methods)-1 {
101 t += ";"
102 }
103 w.Add(sep, t)
104 }
105 }
106 return w.String()
107}
108
109func value(s string, isAddrLike bool) string {
110 quote := s == ""
111 for _, c := range s {
112 // utf-8 does not have to be quoted. ../rfc/6532:242
113 // Characters outside of tokens do. ../rfc/2045:661
114 if c <= ' ' || c == 0x7f || (c == '@' && !isAddrLike) || strings.ContainsRune(`()<>,;:\\"/[]?= `, c) {
115 quote = true
116 break
117 }
118 }
119 if !quote {
120 return s
121 }
122 var r strings.Builder
123 r.WriteString(`"`)
124 for _, c := range s {
125 if c == '"' || c == '\\' {
126 r.WriteString("\\")
127 }
128 r.WriteString(string(c))
129 }
130 r.WriteString(`"`)
131 return r.String()
132}
133
134// ParseAuthResults parses a Authentication-Results header value.
135//
136// Comments are not populated in the returned AuthResults.
137// Both crlf and lf line-endings are accepted. The input string must end with
138// either crlf or lf.
139func ParseAuthResults(s string) (ar AuthResults, err error) {
140 // ../rfc/8601:577
141 lower := make([]byte, len(s))
142 for i, c := range []byte(s) {
143 if c >= 'A' && c <= 'Z' {
144 c += 'a' - 'A'
145 }
146 lower[i] = c
147 }
148 p := &parser{s: s, lower: string(lower)}
149 defer p.recover(&err)
150
151 p.cfws()
152 ar.Hostname = p.xvalue()
153 p.cfws()
154 ar.Version = p.digits()
155 p.cfws()
156 for {
157 p.xtake(";")
158 p.cfws()
159 // Yahoo has ";" at the end of the header value, incorrect.
160 if !Pedantic && p.end() {
161 break
162 }
163 method := p.xkeyword(false)
164 p.cfws()
165 if method == "none" {
166 if len(ar.Methods) == 0 {
167 p.xerrorf("missing results")
168 }
169 if !p.end() {
170 p.xerrorf(`data after "none" result`)
171 }
172 return
173 }
174 ar.Methods = append(ar.Methods, p.xresinfo(method))
175 p.cfws()
176 if p.end() {
177 break
178 }
179 }
180 return
181}
182
183type parser struct {
184 s string
185 lower string // Like s, but with ascii characters lower-cased (utf-8 offsets preserved).
186 o int
187}
188
189type parseError struct{ err error }
190
191func (p *parser) recover(err *error) {
192 x := recover()
193 if x == nil {
194 return
195 }
196 perr, ok := x.(parseError)
197 if ok {
198 *err = perr.err
199 return
200 }
201 panic(x)
202}
203
204func (p *parser) xerrorf(format string, args ...any) {
205 panic(parseError{fmt.Errorf(format, args...)})
206}
207
208func (p *parser) end() bool {
209 return p.s[p.o:] == "\r\n" || p.s[p.o:] == "\n"
210}
211
212// ../rfc/5322:599
213func (p *parser) cfws() {
214 p.fws()
215 for p.prefix("(") {
216 p.xcomment()
217 }
218 p.fws()
219}
220
221func (p *parser) fws() {
222 for p.take(" ") || p.take("\t") {
223 }
224 opts := []string{"\n ", "\n\t", "\r\n ", "\r\n\t"}
225 if slices.ContainsFunc(opts, p.take) {
226
227 }
228 for p.take(" ") || p.take("\t") {
229 }
230}
231
232func (p *parser) xcomment() {
233 p.xtake("(")
234 p.fws()
235 for !p.take(")") {
236 if p.empty() {
237 p.xerrorf("unexpected end in comment")
238 }
239 if p.prefix("(") {
240 p.xcomment()
241 p.fws()
242 continue
243 }
244 p.take(`\`)
245 if c := p.s[p.o]; c > ' ' && c < 0x7f {
246 p.o++
247 } else {
248 p.xerrorf("bad character %c in comment", c)
249 }
250 p.fws()
251 }
252}
253
254func (p *parser) prefix(s string) bool {
255 return strings.HasPrefix(p.lower[p.o:], s)
256}
257
258func (p *parser) xvalue() string {
259 if p.prefix(`"`) {
260 return p.xquotedString()
261 }
262 return p.xtakefn1("value token", func(c rune, i int) bool {
263 // ../rfc/2045:661
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)
266 })
267}
268
269func (p *parser) xchar() rune {
270 // We are careful to track invalid utf-8 properly.
271 if p.empty() {
272 p.xerrorf("need another character")
273 }
274 var r rune
275 var o int
276 for i, c := range p.s[p.o:] {
277 if i > 0 {
278 o = i
279 break
280 }
281 r = c
282 }
283 if o == 0 {
284 p.o = len(p.s)
285 } else {
286 p.o += o
287 }
288 return r
289}
290
291func (p *parser) xquotedString() string {
292 p.xtake(`"`)
293 var s string
294 var esc bool
295 for {
296 c := p.xchar()
297 if esc {
298 if c >= ' ' && c < 0x7f {
299 s += string(c)
300 esc = false
301 continue
302 }
303 p.xerrorf("bad escaped char %c in quoted string", c)
304 }
305 if c == '\\' {
306 esc = true
307 continue
308 }
309 if c == '"' {
310 return s
311 }
312 if c >= ' ' && c != '\\' && c != '"' {
313 s += string(c)
314 continue
315 }
316 p.xerrorf("invalid quoted string, invalid character %c", c)
317 }
318}
319
320func (p *parser) digits() string {
321 o := p.o
322 for o < len(p.s) && p.s[o] >= '0' && p.s[o] <= '9' {
323 o++
324 }
325 p.o = o
326 return p.s[o:p.o]
327}
328
329func (p *parser) xdigits() string {
330 s := p.digits()
331 if s == "" {
332 p.xerrorf("expected digits, remaining %q", p.s[p.o:])
333 }
334 return s
335}
336
337func (p *parser) xtake(s string) {
338 if !p.prefix(s) {
339 p.xerrorf("expected %q, remaining %q", s, p.s[p.o:])
340 }
341 p.o += len(s)
342}
343
344func (p *parser) empty() bool {
345 return p.o >= len(p.s)
346}
347
348func (p *parser) take(s string) bool {
349 if p.prefix(s) {
350 p.o += len(s)
351 return true
352 }
353 return false
354}
355
356func (p *parser) xtakefn1(what string, fn func(c rune, i int) bool) string {
357 if p.empty() {
358 p.xerrorf("need at least one char for %s", what)
359 }
360 for i, c := range p.s[p.o:] {
361 if !fn(c, i) {
362 if i == 0 {
363 p.xerrorf("expected at least one char for %s, remaining %q", what, p.s[p.o:])
364 }
365 s := p.s[p.o : p.o+i]
366 p.o += i
367 return s
368 }
369 }
370 s := p.s[p.o:]
371 p.o = len(p.s)
372 return s
373}
374
375// ../rfc/5321:2287
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 == '_'
380 }))
381 if s == "-" {
382 p.xerrorf("missing keyword")
383 } else if strings.HasSuffix(s, "-") {
384 p.o--
385 s = s[:len(s)-1]
386 }
387 return s
388}
389
390func (p *parser) xmethodspec(methodKeyword string) (string, string, string) {
391 p.cfws()
392 var methodDigits string
393 if p.take("/") {
394 methodDigits = p.xdigits()
395 p.cfws()
396 }
397 p.xtake("=")
398 p.cfws()
399 result := p.xkeyword(true)
400 return methodKeyword, methodDigits, result
401}
402
403func (p *parser) xpropspec() (ap AuthProp) {
404 ap.Type = p.xkeyword(false)
405 p.cfws()
406 p.xtake(".")
407 p.cfws()
408 if p.take("mailfrom") {
409 ap.Property = "mailfrom"
410 } else if p.take("rcptto") {
411 ap.Property = "rcptto"
412 } else {
413 ap.Property = p.xkeyword(false)
414 }
415 p.cfws()
416 p.xtake("=")
417 ap.IsAddrLike, ap.Value = p.xpvalue()
418 return
419}
420
421// method keyword has been parsed, method-version not yet.
422func (p *parser) xresinfo(methodKeyword string) (am AuthMethod) {
423 p.cfws()
424 am.Method, am.Version, am.Result = p.xmethodspec(methodKeyword)
425 p.cfws()
426 if p.take("reason") {
427 p.cfws()
428 p.xtake("=")
429 p.cfws()
430 am.Reason = p.xvalue()
431 }
432 p.cfws()
433 for !p.prefix(";") && !p.end() {
434 am.Props = append(am.Props, p.xpropspec())
435 p.cfws()
436 }
437 return
438}
439
440// todo: could keep track whether this is a localpart.
441func (p *parser) xpvalue() (bool, string) {
442 p.cfws()
443 if p.take("@") {
444 // Bare domain.
445 dom, _ := p.xdomain()
446 return true, "@" + dom
447 }
448 s := p.xvalue()
449 if p.take("@") {
450 dom, _ := p.xdomain()
451 s += "@" + dom
452 return true, s
453 }
454 return false, s
455}
456
457// ../rfc/5321:2291
458func (p *parser) xdomain() (string, dns.Domain) {
459 var s strings.Builder
460 s.WriteString(p.xsubdomain())
461 for p.take(".") {
462 s.WriteString("." + p.xsubdomain())
463 }
464 d, err := dns.ParseDomain(s.String())
465 if err != nil {
466 p.xerrorf("parsing domain name %q: %s", s.String(), err)
467 }
468 if len(s.String()) > 255 {
469 // ../rfc/5321:3491
470 p.xerrorf("domain longer than 255 octets")
471 }
472 return s.String(), d
473}
474
475// ../rfc/5321:2303
476// ../rfc/5321:2303 ../rfc/6531:411
477func (p *parser) xsubdomain() string {
478 return p.xtakefn1("subdomain", func(c rune, i int) bool {
479 return c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || i > 0 && c == '-' || c > 0x7f
480 })
481}
482