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) Path() Path {
111 return Path{Localpart: a.Localpart, IPDomain: dns.IPDomain{Domain: a.Domain}}
112}
113
114func (a Address) IsZero() bool {
115 return a == Address{}
116}
117
118// Pack returns the address in string form. If smtputf8 is true, the domain is
119// formatted with non-ASCII characters. If localpart has non-ASCII characters,
120// they are returned regardless of smtputf8.
121func (a Address) Pack(smtputf8 bool) string {
122 if a.IsZero() {
123 return ""
124 }
125 return a.Localpart.String() + "@" + a.Domain.XName(smtputf8)
126}
127
128// String returns the address in string form with non-ASCII characters.
129func (a Address) String() string {
130 if a.IsZero() {
131 return ""
132 }
133 return a.Localpart.String() + "@" + a.Domain.Name()
134}
135
136// LogString returns the address with with utf-8 in localpart and/or domain. In
137// case of an IDNA domain and/or quotable characters in the localpart, an address
138// with quoted/escaped localpart and ASCII domain is also returned.
139func (a Address) LogString() string {
140 if a.IsZero() {
141 return ""
142 }
143 s := a.Pack(true)
144 lp := a.Localpart.String()
145 qlp := strconv.QuoteToASCII(lp)
146 escaped := qlp != `"`+lp+`"`
147 if a.Domain.Unicode != "" || escaped {
148 if escaped {
149 lp = qlp
150 }
151 s += "/" + lp + "@" + a.Domain.ASCII
152 }
153 return s
154}
155
156// ParseAddress parses an email address. UTF-8 is allowed.
157// Returns ErrBadAddress for invalid addresses.
158func ParseAddress(s string) (address Address, err error) {
159 lp, rem, err := parseLocalPart(s)
160 if err != nil {
161 return Address{}, fmt.Errorf("%w: %s", ErrBadAddress, err)
162 }
163 if !strings.HasPrefix(rem, "@") {
164 return Address{}, fmt.Errorf("%w: expected @", ErrBadAddress)
165 }
166 rem = rem[1:]
167 d, err := dns.ParseDomain(rem)
168 if err != nil {
169 return Address{}, fmt.Errorf("%w: %s", ErrBadAddress, err)
170 }
171 return Address{lp, d}, err
172}
173
174var ErrBadLocalpart = errors.New("invalid localpart")
175
176// ParseLocalpart parses the local part.
177// UTF-8 is allowed.
178// Returns ErrBadAddress for invalid addresses.
179func ParseLocalpart(s string) (localpart Localpart, err error) {
180 lp, rem, err := parseLocalPart(s)
181 if err != nil {
182 return "", err
183 }
184 if rem != "" {
185 return "", fmt.Errorf("%w: remaining after localpart: %q", ErrBadLocalpart, rem)
186 }
187 return lp, nil
188}
189
190func parseLocalPart(s string) (localpart Localpart, remain string, err error) {
191 p := &parser{s, 0}
192
193 defer func() {
194 x := recover()
195 if x == nil {
196 return
197 }
198 e, ok := x.(error)
199 if !ok {
200 panic(x)
201 }
202 err = fmt.Errorf("%w: %s", ErrBadLocalpart, e)
203 }()
204
205 lp := p.xlocalpart()
206 return lp, p.remainder(), nil
207}
208
209type parser struct {
210 s string
211 o int
212}
213
214func (p *parser) xerrorf(format string, args ...any) {
215 panic(fmt.Errorf(format, args...))
216}
217
218func (p *parser) hasPrefix(s string) bool {
219 return strings.HasPrefix(p.s[p.o:], s)
220}
221
222func (p *parser) take(s string) bool {
223 if p.hasPrefix(s) {
224 p.o += len(s)
225 return true
226 }
227 return false
228}
229
230func (p *parser) xtake(s string) {
231 if !p.take(s) {
232 p.xerrorf("expected %q", s)
233 }
234}
235
236func (p *parser) empty() bool {
237 return p.o == len(p.s)
238}
239
240func (p *parser) xtaken(n int) string {
241 r := p.s[p.o : p.o+n]
242 p.o += n
243 return r
244}
245
246func (p *parser) remainder() string {
247 r := p.s[p.o:]
248 p.o = len(p.s)
249 return r
250}
251
252// todo: reduce duplication between implementations: ../smtp/address.go:/xlocalpart ../dkim/parser.go:/xlocalpart ../smtpserver/parse.go:/xlocalpart
253func (p *parser) xlocalpart() Localpart {
254 // ../rfc/5321:2316
255 var s string
256 if p.hasPrefix(`"`) {
257 s = p.xquotedString()
258 } else {
259 s = p.xatom()
260 for p.take(".") {
261 s += "." + p.xatom()
262 }
263 }
264 // In the wild, some services use large localparts for generated (bounce) addresses.
265 if moxvar.Pedantic && len(s) > 64 || len(s) > 128 {
266 // ../rfc/5321:3486
267 p.xerrorf("localpart longer than 64 octets")
268 }
269 return Localpart(s)
270}
271
272func (p *parser) xquotedString() string {
273 p.xtake(`"`)
274 var s string
275 var esc bool
276 for {
277 c := p.xchar()
278 if esc {
279 if c >= ' ' && c < 0x7f {
280 s += string(c)
281 esc = false
282 continue
283 }
284 p.xerrorf("invalid localpart, bad escaped char %c", c)
285 }
286 if c == '\\' {
287 esc = true
288 continue
289 }
290 if c == '"' {
291 return s
292 }
293 // todo: should we be accepting utf8 for quoted strings?
294 if c >= ' ' && c < 0x7f && c != '\\' && c != '"' || c > 0x7f {
295 s += string(c)
296 continue
297 }
298 p.xerrorf("invalid localpart, invalid character %c", c)
299 }
300}
301
302func (p *parser) xchar() rune {
303 // We are careful to track invalid utf-8 properly.
304 if p.empty() {
305 p.xerrorf("need another character")
306 }
307 var r rune
308 var o int
309 for i, c := range p.s[p.o:] {
310 if i > 0 {
311 o = i
312 break
313 }
314 r = c
315 }
316 if o == 0 {
317 p.o = len(p.s)
318 } else {
319 p.o += o
320 }
321 return r
322}
323
324func (p *parser) takefn1(what string, fn func(c rune, i int) bool) string {
325 if p.empty() {
326 p.xerrorf("need at least one char for %s", what)
327 }
328 for i, c := range p.s[p.o:] {
329 if !fn(c, i) {
330 if i == 0 {
331 p.xerrorf("expected at least one char for %s, got char %c", what, c)
332 }
333 return p.xtaken(i)
334 }
335 }
336 return p.remainder()
337}
338
339func (p *parser) xatom() string {
340 return p.takefn1("atom", func(c rune, i int) bool {
341 switch c {
342 case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?', '^', '_', '`', '{', '|', '}', '~':
343 return true
344 }
345 return isalphadigit(c) || c > 0x7f
346 })
347}
348
349func isalpha(c rune) bool {
350 return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
351}
352
353func isdigit(c rune) bool {
354 return c >= '0' && c <= '9'
355}
356
357func isalphadigit(c rune) bool {
358 return isalpha(c) || isdigit(c)
359}
360