mox/smtp/address.go

323 lines
7 KiB
Go
Raw Normal View History

2023-01-30 16:27:06 +03:00
package smtp
import (
"errors"
"fmt"
"strings"
"github.com/mjl-/mox/dns"
)
var ErrBadAddress = errors.New("invalid email address")
// Localpart is a decoded local part of an email address, before the "@".
// For quoted strings, values do not hold the double quote or escaping backslashes.
// An empty string can be a valid localpart.
type Localpart string
// String returns a packed representation of an address, with proper escaping/quoting, for use in SMTP.
func (lp Localpart) String() string {
// See ../rfc/5321:2322 ../rfc/6531:414
// First we try as dot-string. If not possible we make a quoted-string.
dotstr := true
t := strings.Split(string(lp), ".")
for _, e := range t {
for _, c := range e {
if c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c > 0x7f {
continue
}
switch c {
case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?', '^', '_', '`', '{', '|', '}', '~':
continue
}
dotstr = false
break
}
dotstr = dotstr && len(e) > 0
}
dotstr = dotstr && len(t) > 0
if dotstr {
return string(lp)
}
// Make quoted-string.
r := `"`
for _, b := range lp {
if b == '"' || b == '\\' {
r += "\\" + string(b)
} else {
r += string(b)
}
}
r += `"`
return r
}
// DSNString returns the localpart as string for use in a DSN.
// utf8 indicates if the remote MTA supports utf8 messaging. If not, the 7bit DSN
// encoding for "utf-8-addr-xtext" from RFC 6533 is used.
func (lp Localpart) DSNString(utf8 bool) string {
if utf8 {
return lp.String()
}
// ../rfc/6533:259
r := ""
for _, c := range lp {
if c > 0x20 && c < 0x7f && c != '\\' && c != '+' && c != '=' {
r += string(c)
} else {
r += fmt.Sprintf(`\x{%x}`, c)
}
}
return r
}
// IsInternational returns if this is an internationalized local part, i.e. has
// non-ASCII characters.
func (lp Localpart) IsInternational() bool {
for _, c := range lp {
if c > 0x7f {
return true
}
}
return false
}
// Address is a parsed email address.
type Address struct {
Localpart Localpart
Domain dns.Domain // todo: shouldn't we accept an ip address here too? and merge this type into smtp.Path.
}
// NewAddress returns an address.
func NewAddress(localpart Localpart, domain dns.Domain) Address {
return Address{localpart, domain}
}
func (a Address) IsZero() bool {
return a == Address{}
}
// Pack returns the address in string form. If smtputf8 is true, the domain is
// formatted with non-ASCII characters. If localpart has non-ASCII characters,
// they are returned regardless of smtputf8.
func (a Address) Pack(smtputf8 bool) string {
if a.IsZero() {
return ""
}
2023-01-30 16:27:06 +03:00
return a.Localpart.String() + "@" + a.Domain.XName(smtputf8)
}
// String returns the address in string form with non-ASCII characters.
func (a Address) String() string {
if a.IsZero() {
return ""
}
2023-01-30 16:27:06 +03:00
return a.Localpart.String() + "@" + a.Domain.Name()
}
// ParseAddress parses an email address. UTF-8 is allowed.
// Returns ErrBadAddress for invalid addresses.
func ParseAddress(s string) (address Address, err error) {
lp, rem, err := parseLocalPart(s)
if err != nil {
return Address{}, fmt.Errorf("%w: %s", ErrBadAddress, err)
}
if !strings.HasPrefix(rem, "@") {
return Address{}, fmt.Errorf("%w: expected @", ErrBadAddress)
}
rem = rem[1:]
d, err := dns.ParseDomain(rem)
if err != nil {
return Address{}, fmt.Errorf("%w: %s", ErrBadAddress, err)
}
return Address{lp, d}, err
}
var ErrBadLocalpart = errors.New("invalid localpart")
// ParseLocalpart parses the local part.
// UTF-8 is allowed.
// Returns ErrBadAddress for invalid addresses.
func ParseLocalpart(s string) (localpart Localpart, err error) {
lp, rem, err := parseLocalPart(s)
if err != nil {
return "", err
}
if rem != "" {
return "", fmt.Errorf("%w: remaining after localpart: %q", ErrBadLocalpart, rem)
}
return lp, nil
}
func parseLocalPart(s string) (localpart Localpart, remain string, err error) {
p := &parser{s, 0}
defer func() {
x := recover()
if x == nil {
return
}
e, ok := x.(error)
if !ok {
panic(x)
}
err = fmt.Errorf("%w: %s", ErrBadLocalpart, e)
}()
lp := p.xlocalpart()
return lp, p.remainder(), nil
}
type parser struct {
s string
o int
}
func (p *parser) xerrorf(format string, args ...any) {
panic(fmt.Errorf(format, args...))
}
func (p *parser) hasPrefix(s string) bool {
return strings.HasPrefix(p.s[p.o:], s)
}
func (p *parser) take(s string) bool {
if p.hasPrefix(s) {
p.o += len(s)
return true
}
return false
}
func (p *parser) xtake(s string) {
if !p.take(s) {
p.xerrorf("expected %q", s)
}
}
func (p *parser) empty() bool {
return p.o == len(p.s)
}
func (p *parser) xtaken(n int) string {
r := p.s[p.o : p.o+n]
p.o += n
return r
}
func (p *parser) remainder() string {
r := p.s[p.o:]
p.o = len(p.s)
return r
}
// todo: reduce duplication between implementations: ../smtp/address.go:/xlocalpart ../dkim/parser.go:/xlocalpart ../smtpserver/parse.go:/xlocalpart
func (p *parser) xlocalpart() Localpart {
// ../rfc/5321:2316
var s string
if p.hasPrefix(`"`) {
s = p.xquotedString()
} else {
s = p.xatom()
for p.take(".") {
s += "." + p.xatom()
}
}
// todo: have a strict parser that only allows the actual max of 64 bytes. some services have large localparts because of generated (bounce) addresses.
if len(s) > 128 {
// ../rfc/5321:3486
p.xerrorf("localpart longer than 64 octets")
}
return Localpart(s)
}
func (p *parser) xquotedString() string {
p.xtake(`"`)
var s string
var esc bool
for {
c := p.xchar()
if esc {
if c >= ' ' && c < 0x7f {
s += string(c)
esc = false
continue
}
p.xerrorf("invalid localpart, bad escaped char %c", c)
}
if c == '\\' {
esc = true
continue
}
if c == '"' {
return s
}
// todo: should we be accepting utf8 for quoted strings?
if c >= ' ' && c < 0x7f && c != '\\' && c != '"' || c > 0x7f {
s += string(c)
continue
}
p.xerrorf("invalid localpart, invalid character %c", c)
}
}
func (p *parser) xchar() rune {
// We are careful to track invalid utf-8 properly.
if p.empty() {
p.xerrorf("need another character")
}
var r rune
var o int
for i, c := range p.s[p.o:] {
if i > 0 {
o = i
break
}
r = c
}
if o == 0 {
p.o = len(p.s)
} else {
p.o += o
}
return r
}
func (p *parser) takefn1(what string, fn func(c rune, i int) bool) string {
if p.empty() {
p.xerrorf("need at least one char for %s", what)
}
for i, c := range p.s[p.o:] {
if !fn(c, i) {
if i == 0 {
p.xerrorf("expected at least one char for %s, got char %c", what, c)
}
return p.xtaken(i)
}
}
return p.remainder()
}
func (p *parser) xatom() string {
return p.takefn1("atom", func(c rune, i int) bool {
switch c {
case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?', '^', '_', '`', '{', '|', '}', '~':
return true
}
return isalphadigit(c) || c > 0x7f
})
}
func isalpha(c rune) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
}
func isdigit(c rune) bool {
return c >= '0' && c <= '9'
}
func isalphadigit(c rune) bool {
return isalpha(c) || isdigit(c)
}