9 "github.com/mjl-/mox/dns"
10 "github.com/mjl-/mox/moxvar"
13var ErrBadAddress = errors.New("invalid email address")
15// Localpart is a decoded local part of an email address, before the "@".
16// For quoted strings, values do not hold the double quote or escaping backslashes.
17// An empty string can be a valid localpart.
20// String returns a packed representation of an address, with proper escaping/quoting, for use in SMTP.
21func (lp Localpart) String() string {
23 // First we try as dot-string. If not possible we make a quoted-string.
25 t := strings.Split(string(lp), ".")
28 if c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c > 0x7f {
32 case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?', '^', '_', '`', '{', '|', '}', '~':
38 dotstr = dotstr && len(e) > 0
40 dotstr = dotstr && len(t) > 0
45 // Make quoted-string.
47 for _, b := range lp {
48 if b == '"' || b == '\\' {
58// LogString returns the localpart as string for use in smtp, and an escaped
59// representation if it has non-ascii characters.
60func (lp Localpart) LogString() string {
62 qs := strconv.QuoteToASCII(s)
69// DSNString returns the localpart as string for use in a DSN.
70// utf8 indicates if the remote MTA supports utf8 messaging. If not, the 7bit DSN
71// encoding for "utf-8-addr-xtext" from RFC 6533 is used.
72func (lp Localpart) DSNString(utf8 bool) string {
78 for _, c := range lp {
79 if c > 0x20 && c < 0x7f && c != '\\' && c != '+' && c != '=' {
82 r += fmt.Sprintf(`\x{%x}`, c)
88// IsInternational returns if this is an internationalized local part, i.e. has
89// non-ASCII characters.
90func (lp Localpart) IsInternational() bool {
91 for _, c := range lp {
99// Address is a parsed email address.
102 Domain dns.Domain // todo: shouldn't we accept an ip address here too? and merge this type into smtp.Path.
105// NewAddress returns an address.
106func NewAddress(localpart Localpart, domain dns.Domain) Address {
107 return Address{localpart, domain}
110func (a Address) IsZero() bool {
111 return a == Address{}
114// Pack returns the address in string form. If smtputf8 is true, the domain is
115// formatted with non-ASCII characters. If localpart has non-ASCII characters,
116// they are returned regardless of smtputf8.
117func (a Address) Pack(smtputf8 bool) string {
121 return a.Localpart.String() + "@" + a.Domain.XName(smtputf8)
124// String returns the address in string form with non-ASCII characters.
125func (a Address) String() string {
129 return a.Localpart.String() + "@" + a.Domain.Name()
132// LogString returns the address with with utf-8 in localpart and/or domain. In
133// case of an IDNA domain and/or quotable characters in the localpart, an address
134// with quoted/escaped localpart and ASCII domain is also returned.
135func (a Address) LogString() string {
140 lp := a.Localpart.String()
141 qlp := strconv.QuoteToASCII(lp)
142 escaped := qlp != `"`+lp+`"`
143 if a.Domain.Unicode != "" || escaped {
147 s += "/" + lp + "@" + a.Domain.ASCII
152// ParseAddress parses an email address. UTF-8 is allowed.
153// Returns ErrBadAddress for invalid addresses.
154func ParseAddress(s string) (address Address, err error) {
155 lp, rem, err := parseLocalPart(s)
157 return Address{}, fmt.Errorf("%w: %s", ErrBadAddress, err)
159 if !strings.HasPrefix(rem, "@") {
160 return Address{}, fmt.Errorf("%w: expected @", ErrBadAddress)
163 d, err := dns.ParseDomain(rem)
165 return Address{}, fmt.Errorf("%w: %s", ErrBadAddress, err)
167 return Address{lp, d}, err
170var ErrBadLocalpart = errors.New("invalid localpart")
172// ParseLocalpart parses the local part.
174// Returns ErrBadAddress for invalid addresses.
175func ParseLocalpart(s string) (localpart Localpart, err error) {
176 lp, rem, err := parseLocalPart(s)
181 return "", fmt.Errorf("%w: remaining after localpart: %q", ErrBadLocalpart, rem)
186func parseLocalPart(s string) (localpart Localpart, remain string, err error) {
198 err = fmt.Errorf("%w: %s", ErrBadLocalpart, e)
202 return lp, p.remainder(), nil
210func (p *parser) xerrorf(format string, args ...any) {
211 panic(fmt.Errorf(format, args...))
214func (p *parser) hasPrefix(s string) bool {
215 return strings.HasPrefix(p.s[p.o:], s)
218func (p *parser) take(s string) bool {
226func (p *parser) xtake(s string) {
228 p.xerrorf("expected %q", s)
232func (p *parser) empty() bool {
233 return p.o == len(p.s)
236func (p *parser) xtaken(n int) string {
237 r := p.s[p.o : p.o+n]
242func (p *parser) remainder() string {
248// todo: reduce duplication between implementations: ../smtp/address.go:/xlocalpart ../dkim/parser.go:/xlocalpart ../smtpserver/parse.go:/xlocalpart
249func (p *parser) xlocalpart() Localpart {
252 if p.hasPrefix(`"`) {
253 s = p.xquotedString()
260 // In the wild, some services use large localparts for generated (bounce) addresses.
261 if moxvar.Pedantic && len(s) > 64 || len(s) > 128 {
263 p.xerrorf("localpart longer than 64 octets")
268func (p *parser) xquotedString() string {
275 if c >= ' ' && c < 0x7f {
280 p.xerrorf("invalid localpart, bad escaped char %c", c)
289 // todo: should we be accepting utf8 for quoted strings?
290 if c >= ' ' && c < 0x7f && c != '\\' && c != '"' || c > 0x7f {
294 p.xerrorf("invalid localpart, invalid character %c", c)
298func (p *parser) xchar() rune {
299 // We are careful to track invalid utf-8 properly.
301 p.xerrorf("need another character")
305 for i, c := range p.s[p.o:] {
320func (p *parser) takefn1(what string, fn func(c rune, i int) bool) string {
322 p.xerrorf("need at least one char for %s", what)
324 for i, c := range p.s[p.o:] {
327 p.xerrorf("expected at least one char for %s, got char %c", what, c)
335func (p *parser) xatom() string {
336 return p.takefn1("atom", func(c rune, i int) bool {
338 case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?', '^', '_', '`', '{', '|', '}', '~':
341 return isalphadigit(c) || c > 0x7f
345func isalpha(c rune) bool {
346 return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
349func isdigit(c rune) bool {
350 return c >= '0' && c <= '9'
353func isalphadigit(c rune) bool {
354 return isalpha(c) || isdigit(c)