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