1package message
2
3import (
4 "errors"
5 "fmt"
6 "strings"
7
8 "github.com/mjl-/mox/moxvar"
9 "github.com/mjl-/mox/smtp"
10)
11
12var errBadMessageID = errors.New("not a message-id")
13
14// MessageIDCanonical parses the Message-ID, returning a canonical value that is
15// lower-cased, without <>, and no unneeded quoting. For matching in threading,
16// with References/In-Reply-To. If the message-id is invalid (e.g. no <>), an error
17// is returned. If the message-id could not be parsed as address (localpart "@"
18// domain), the raw value and the bool return parameter true is returned. It is
19// quite common that message-id's don't adhere to the localpart @ domain
20// syntax.
21func MessageIDCanonical(s string) (string, bool, error) {
22 // ../rfc/5322:1383
23
24 s = strings.TrimSpace(s)
25 if !strings.HasPrefix(s, "<") {
26 return "", false, fmt.Errorf("%w: missing <", errBadMessageID)
27 }
28 s = s[1:]
29 // Seen in practice: Message-ID: <valid@valid.example> (added by postmaster@some.example)
30 // Doesn't seem valid, but we allow it.
31 s, rem, have := strings.Cut(s, ">")
32 if !have || (rem != "" && (moxvar.Pedantic || !strings.HasPrefix(rem, " "))) {
33 return "", false, fmt.Errorf("%w: missing >", errBadMessageID)
34 }
35 // We canonicalize the Message-ID: lower-case, no unneeded quoting.
36 s = strings.ToLower(s)
37 if s == "" {
38 return "", false, fmt.Errorf("%w: empty message-id", errBadMessageID)
39 }
40 addr, err := smtp.ParseAddress(s)
41 if err != nil {
42 // Common reasons for not being an address:
43 // 1. underscore in hostname.
44 // 2. ip literal instead of domain.
45 // 3. two @'s, perhaps intended as time-separator
46 // 4. no @'s, so no domain/host
47 return s, true, nil
48 }
49 // We preserve the unicode-ness of domain.
50 t := strings.Split(s, "@")
51 s = addr.Localpart.String() + "@" + t[len(t)-1]
52 return s, false, nil
53}
54