1package smtp
2
3import (
4 "errors"
5 "fmt"
6 "strconv"
7 "strings"
8
9 "golang.org/x/text/unicode/norm"
10
11 "github.com/mjl-/mox/dns"
12)
13
14// Pedantic enables stricter parsing.
15var Pedantic bool
16
17var ErrBadAddress = errors.New("invalid email address")
18
19// Localpart is a decoded local part of an email address, before the "@".
20// For quoted strings, values do not hold the double quote or escaping backslashes.
21// An empty string can be a valid localpart.
22// Localparts are in Unicode NFC.
23type Localpart string
24
25// String returns a packed representation of an address, with proper escaping/quoting, for use in SMTP.
26func (lp Localpart) String() string {
27 // See ../rfc/5321:2322 ../rfc/6531:414
28 // First we try as dot-string. If not possible we make a quoted-string.
29 dotstr := true
30 t := strings.Split(string(lp), ".")
31 for _, e := range t {
32 for _, c := range e {
33 if c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c > 0x7f {
34 continue
35 }
36 switch c {
37 case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?', '^', '_', '`', '{', '|', '}', '~':
38 continue
39 }
40 dotstr = false
41 break
42 }
43 dotstr = dotstr && len(e) > 0
44 }
45 dotstr = dotstr && len(t) > 0
46 if dotstr {
47 return string(lp)
48 }
49
50 // Make quoted-string.
51 var r strings.Builder
52 r.WriteString(`"`)
53 for _, b := range lp {
54 if b == '"' || b == '\\' {
55 r.WriteString("\\" + string(b))
56 } else {
57 r.WriteString(string(b))
58 }
59 }
60 r.WriteString(`"`)
61 return r.String()
62}
63
64// LogString returns the localpart as string for use in smtp, and an escaped
65// representation if it has non-ascii characters.
66func (lp Localpart) LogString() string {
67 s := lp.String()
68 qs := strconv.QuoteToASCII(s)
69 if qs != `"`+s+`"` {
70 s = "/" + qs
71 }
72 return s
73}
74
75// DSNString returns the localpart as string for use in a DSN.
76// utf8 indicates if the remote MTA supports utf8 messaging. If not, the 7bit DSN
77// encoding for "utf-8-addr-xtext" from RFC 6533 is used.
78func (lp Localpart) DSNString(utf8 bool) string {
79 if utf8 {
80 return lp.String()
81 }
82 // ../rfc/6533:259
83 var r strings.Builder
84 for _, c := range lp {
85 if c > 0x20 && c < 0x7f && c != '\\' && c != '+' && c != '=' {
86 r.WriteString(string(c))
87 } else {
88 r.WriteString(fmt.Sprintf(`\x{%x}`, c))
89 }
90 }
91 return r.String()
92}
93
94// IsInternational returns if this is an internationalized local part, i.e. has
95// non-ASCII characters.
96func (lp Localpart) IsInternational() bool {
97 for _, c := range lp {
98 if c > 0x7f {
99 return true
100 }
101 }
102 return false
103}
104
105// Address is a parsed email address.
106type Address struct {
107 Localpart Localpart
108 Domain dns.Domain // todo: shouldn't we accept an ip address here too? and merge this type into smtp.Path.
109}
110
111// NewAddress returns an address.
112func NewAddress(localpart Localpart, domain dns.Domain) Address {
113 return Address{localpart, domain}
114}
115
116func (a Address) Path() Path {
117 return Path{Localpart: a.Localpart, IPDomain: dns.IPDomain{Domain: a.Domain}}
118}
119
120func (a Address) IsZero() bool {
121 return a == Address{}
122}
123
124// Pack returns the address in string form. If smtputf8 is true, the domain is
125// formatted with non-ASCII characters. If localpart has non-ASCII characters,
126// they are returned regardless of smtputf8.
127func (a Address) Pack(smtputf8 bool) string {
128 if a.IsZero() {
129 return ""
130 }
131 return a.Localpart.String() + "@" + a.Domain.XName(smtputf8)
132}
133
134// String returns the address in string form with non-ASCII characters.
135func (a Address) String() string {
136 if a.IsZero() {
137 return ""
138 }
139 return a.Localpart.String() + "@" + a.Domain.Name()
140}
141
142// LogString returns the address with with utf-8 in localpart and/or domain. In
143// case of an IDNA domain and/or quotable characters in the localpart, an address
144// with quoted/escaped localpart and ASCII domain is also returned.
145func (a Address) LogString() string {
146 if a.IsZero() {
147 return ""
148 }
149 s := a.Pack(true)
150 lp := a.Localpart.String()
151 qlp := strconv.QuoteToASCII(lp)
152 escaped := qlp != `"`+lp+`"`
153 if a.Domain.Unicode != "" || escaped {
154 if escaped {
155 lp = qlp
156 }
157 s += "/" + lp + "@" + a.Domain.ASCII
158 }
159 return s
160}
161
162// ParseAddress parses an email address. UTF-8 is allowed.
163// Returns ErrBadAddress for invalid addresses.
164func ParseAddress(s string) (address Address, err error) {
165 lp, rem, err := parseLocalPart(s)
166 if err != nil {
167 return Address{}, fmt.Errorf("%w: %s", ErrBadAddress, err)
168 }
169 if !strings.HasPrefix(rem, "@") {
170 return Address{}, fmt.Errorf("%w: expected @", ErrBadAddress)
171 }
172 rem = rem[1:]
173 d, err := dns.ParseDomain(rem)
174 if err != nil {
175 return Address{}, fmt.Errorf("%w: %s", ErrBadAddress, err)
176 }
177 return Address{lp, d}, err
178}
179
180// ParseNetMailAddress parses a not-quite-valid address as found in
181// net/mail.Address.Address.
182//
183// net/mail does parse quoted addresses properly, but stores the localpart
184// unquoted. So an address `" "@example.com` would be stored as ` @example.com`,
185// which we would fail to parse without special attention.
186func ParseNetMailAddress(a string) (address Address, err error) {
187 i := strings.LastIndex(a, "@")
188 if i < 0 {
189 return Address{}, fmt.Errorf("%w: missing @", ErrBadAddress)
190 }
191 addrStr := Localpart(a[:i]).String() + "@" + a[i+1:]
192 return ParseAddress(addrStr)
193}
194
195var ErrBadLocalpart = errors.New("invalid localpart")
196
197// ParseLocalpart parses the local part.
198// UTF-8 is allowed.
199// Returns ErrBadAddress for invalid addresses.
200func ParseLocalpart(s string) (localpart Localpart, err error) {
201 lp, rem, err := parseLocalPart(s)
202 if err != nil {
203 return "", err
204 }
205 if rem != "" {
206 return "", fmt.Errorf("%w: remaining after localpart: %q", ErrBadLocalpart, rem)
207 }
208 return lp, nil
209}
210
211func parseLocalPart(s string) (localpart Localpart, remain string, err error) {
212 p := &parser{s, 0}
213
214 defer func() {
215 x := recover()
216 if x == nil {
217 return
218 }
219 e, ok := x.(error)
220 if !ok {
221 panic(x)
222 }
223 err = fmt.Errorf("%w: %s", ErrBadLocalpart, e)
224 }()
225
226 lp := p.xlocalpart()
227 return lp, p.remainder(), nil
228}
229
230type parser struct {
231 s string
232 o int
233}
234
235func (p *parser) xerrorf(format string, args ...any) {
236 panic(fmt.Errorf(format, args...))
237}
238
239func (p *parser) hasPrefix(s string) bool {
240 return strings.HasPrefix(p.s[p.o:], s)
241}
242
243func (p *parser) take(s string) bool {
244 if p.hasPrefix(s) {
245 p.o += len(s)
246 return true
247 }
248 return false
249}
250
251func (p *parser) xtake(s string) {
252 if !p.take(s) {
253 p.xerrorf("expected %q", s)
254 }
255}
256
257func (p *parser) empty() bool {
258 return p.o == len(p.s)
259}
260
261func (p *parser) xtaken(n int) string {
262 r := p.s[p.o : p.o+n]
263 p.o += n
264 return r
265}
266
267func (p *parser) remainder() string {
268 r := p.s[p.o:]
269 p.o = len(p.s)
270 return r
271}
272
273// todo: reduce duplication between implementations: ../smtp/address.go:/xlocalpart ../dkim/parser.go:/xlocalpart ../smtpserver/parse.go:/xlocalpart
274func (p *parser) xlocalpart() Localpart {
275 // ../rfc/5321:2316
276 var s string
277 if p.hasPrefix(`"`) {
278 s = p.xquotedString()
279 } else {
280 s = p.xatom()
281 for p.take(".") {
282 s += "." + p.xatom()
283 }
284 }
285 // In the wild, some services use large localparts for generated (bounce) addresses.
286 if Pedantic && len(s) > 64 || len(s) > 128 {
287 // ../rfc/5321:3486
288 p.xerrorf("localpart longer than 64 octets")
289 }
290 return Localpart(norm.NFC.String(s))
291}
292
293func (p *parser) xquotedString() string {
294 p.xtake(`"`)
295 var s string
296 var esc bool
297 for {
298 c := p.xchar()
299 if esc {
300 if c >= ' ' && c < 0x7f {
301 s += string(c)
302 esc = false
303 continue
304 }
305 p.xerrorf("invalid localpart, bad escaped char %c", c)
306 }
307 if c == '\\' {
308 esc = true
309 continue
310 }
311 if c == '"' {
312 return s
313 }
314 // todo: should we be accepting utf8 for quoted strings?
315 if c >= ' ' && c < 0x7f && c != '\\' && c != '"' || c > 0x7f {
316 s += string(c)
317 continue
318 }
319 p.xerrorf("invalid localpart, invalid character %c", c)
320 }
321}
322
323func (p *parser) xchar() rune {
324 // We are careful to track invalid utf-8 properly.
325 if p.empty() {
326 p.xerrorf("need another character")
327 }
328 var r rune
329 var o int
330 for i, c := range p.s[p.o:] {
331 if i > 0 {
332 o = i
333 break
334 }
335 r = c
336 }
337 if o == 0 {
338 p.o = len(p.s)
339 } else {
340 p.o += o
341 }
342 return r
343}
344
345func (p *parser) takefn1(what string, fn func(c rune, i int) bool) string {
346 if p.empty() {
347 p.xerrorf("need at least one char for %s", what)
348 }
349 for i, c := range p.s[p.o:] {
350 if !fn(c, i) {
351 if i == 0 {
352 p.xerrorf("expected at least one char for %s, got char %c", what, c)
353 }
354 return p.xtaken(i)
355 }
356 }
357 return p.remainder()
358}
359
360func (p *parser) xatom() string {
361 return p.takefn1("atom", func(c rune, i int) bool {
362 switch c {
363 case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?', '^', '_', '`', '{', '|', '}', '~':
364 return true
365 }
366 return isalphadigit(c) || c > 0x7f
367 })
368}
369
370func isalpha(c rune) bool {
371 return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
372}
373
374func isdigit(c rune) bool {
375 return c >= '0' && c <= '9'
376}
377
378func isalphadigit(c rune) bool {
379 return isalpha(c) || isdigit(c)
380}
381