mirror of
https://github.com/mjl-/mox.git
synced 2025-07-12 18:24:35 +03:00
mox!
This commit is contained in:
316
smtp/address.go
Normal file
316
smtp/address.go
Normal file
@ -0,0 +1,316 @@
|
||||
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 {
|
||||
return a.Localpart.String() + "@" + a.Domain.XName(smtputf8)
|
||||
}
|
||||
|
||||
// String returns the address in string form with non-ASCII characters.
|
||||
func (a Address) String() string {
|
||||
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)
|
||||
}
|
92
smtp/address_test.go
Normal file
92
smtp/address_test.go
Normal file
@ -0,0 +1,92 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseLocalpart(t *testing.T) {
|
||||
good := func(s string) {
|
||||
t.Helper()
|
||||
_, err := ParseLocalpart(s)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for localpart %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
bad := func(s string) {
|
||||
t.Helper()
|
||||
_, err := ParseLocalpart(s)
|
||||
if err == nil {
|
||||
t.Fatalf("did not see expected error for localpart %q", s)
|
||||
}
|
||||
if !errors.Is(err, ErrBadLocalpart) {
|
||||
t.Fatalf("expected ErrBadLocalpart, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
good("user")
|
||||
good("a")
|
||||
good("a.b.c")
|
||||
good(`""`)
|
||||
good(`"ok"`)
|
||||
good(`"a.bc"`)
|
||||
bad("")
|
||||
bad(`"`) // missing ending dquot
|
||||
bad("\x00") // control not allowed
|
||||
bad("\"\\") // ending with backslash
|
||||
bad("\"\x01") // control not allowed in dquote
|
||||
bad(`""leftover`) // leftover data after close dquote
|
||||
}
|
||||
|
||||
func TestParseAddress(t *testing.T) {
|
||||
good := func(s string) {
|
||||
t.Helper()
|
||||
_, err := ParseAddress(s)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for localpart %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
bad := func(s string) {
|
||||
t.Helper()
|
||||
_, err := ParseAddress(s)
|
||||
if err == nil {
|
||||
t.Fatalf("did not see expected error for localpart %q", s)
|
||||
}
|
||||
if !errors.Is(err, ErrBadAddress) {
|
||||
t.Fatalf("expected ErrBadAddress, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
good("user@example.com")
|
||||
bad("user@@example.com")
|
||||
bad("user") // missing @domain
|
||||
bad("@example.com") // missing localpart
|
||||
bad(`"@example.com`) // missing ending dquot or domain
|
||||
bad("\x00@example.com") // control not allowed
|
||||
bad("\"\\@example.com") // missing @domain
|
||||
bad("\"\x01@example.com") // control not allowed in dquote
|
||||
bad(`""leftover@example.com`) // leftover data after close dquot
|
||||
}
|
||||
|
||||
func TestPackLocalpart(t *testing.T) {
|
||||
var l = []struct {
|
||||
input, expect string
|
||||
}{
|
||||
{``, `""`}, // No atom.
|
||||
{`a.`, `"a."`}, // Empty atom not allowed.
|
||||
{`a.b`, `a.b`}, // Fine.
|
||||
{"azAZ09!#$%&'*+-/=?^_`{|}~", "azAZ09!#$%&'*+-/=?^_`{|}~"}, // All ascii that are fine as atom.
|
||||
{` `, `" "`},
|
||||
{"\x01", "\"\x01\""}, // todo: should probably return an error for control characters.
|
||||
{"<>", `"<>"`},
|
||||
}
|
||||
|
||||
for _, e := range l {
|
||||
r := Localpart(e.input).String()
|
||||
if r != e.expect {
|
||||
t.Fatalf("PackLocalpart for %q, expect %q, got %q", e.input, e.expect, r)
|
||||
}
|
||||
}
|
||||
}
|
16
smtp/addrlit.go
Normal file
16
smtp/addrlit.go
Normal file
@ -0,0 +1,16 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"net"
|
||||
)
|
||||
|
||||
// AddressLiteral returns an IPv4 or IPv6 address literal for use in SMTP.
|
||||
func AddressLiteral(ip net.IP) string {
|
||||
// ../rfc/5321:2309
|
||||
s := "["
|
||||
if ip.To4() == nil {
|
||||
s += "IPv6:"
|
||||
}
|
||||
s += ip.String() + "]"
|
||||
return s
|
||||
}
|
145
smtp/codes.go
Normal file
145
smtp/codes.go
Normal file
@ -0,0 +1,145 @@
|
||||
package smtp
|
||||
|
||||
// ../rfc/5321:2863
|
||||
|
||||
// Reply codes.
|
||||
var (
|
||||
C211SystemStatus = 211
|
||||
C214Help = 214
|
||||
C220ServiceReady = 220
|
||||
C221Closing = 221
|
||||
C235AuthSuccess = 235 // ../rfc/4954:573
|
||||
|
||||
C250Completed = 250
|
||||
C251UserNotLocalWillForward = 251
|
||||
C252WithoutVrfy = 252
|
||||
|
||||
C334ContinueAuth = 334 // ../rfc/4954:187
|
||||
C354Continue = 354
|
||||
|
||||
C421ServiceUnavail = 421
|
||||
C432PasswdTransitionNeeded = 432 // ../rfc/4954:578
|
||||
C454TempAuthFail = 454 // ../rfc/4954:586
|
||||
C450MailboxUnavail = 450
|
||||
C451LocalErr = 451
|
||||
C452StorageFull = 452 // Also for "too many recipients", ../rfc/5321:3576
|
||||
C455BadParams = 455
|
||||
|
||||
C500BadSyntax = 500
|
||||
C501BadParamSyntax = 501
|
||||
C502CmdNotImpl = 502
|
||||
C503BadCmdSeq = 503
|
||||
C504ParamNotImpl = 504
|
||||
C521HostNoMail = 521 // ../rfc/7504:179
|
||||
C530SecurityRequired = 530 // ../rfc/3207:148 ../rfc/4954:623
|
||||
C538EncReqForAuth = 538 // ../rfc/4954:630
|
||||
C534AuthMechWeak = 534 // ../rfc/4954:593
|
||||
C535AuthBadCreds = 535 // ../rfc/4954:600
|
||||
C550MailboxUnavail = 550
|
||||
C551UserNotLocal = 551
|
||||
C552MailboxFull = 552
|
||||
C553BadMailbox = 553
|
||||
C554TransactionFailed = 554
|
||||
C555UnrecognizedAddrParams = 555
|
||||
C556DomainNoMail = 556 // ../rfc/7504:207
|
||||
)
|
||||
|
||||
// Short enhanced reply codes, without leading number and first dot.
|
||||
//
|
||||
// See https://www.iana.org/assignments/smtp-enhanced-status-codes/smtp-enhanced-status-codes.xhtml
|
||||
var (
|
||||
// 0.x - Other or Undefined Status.
|
||||
// ../rfc/3463:287
|
||||
SeOther00 = "0.0"
|
||||
|
||||
// 1.x - Address.
|
||||
// ../rfc/3463:295
|
||||
SeAddr1Other0 = "1.0"
|
||||
SeAddr1UnknownDestMailbox1 = "1.1"
|
||||
SeAddr1UnknownSystem2 = "1.2"
|
||||
SeAddr1MailboxSyntax3 = "1.3"
|
||||
SeAddr1MailboxAmbiguous4 = "1.4"
|
||||
SeAddr1DestValid5 = "1.5" // For success responses.
|
||||
SeAddr1DestMailboxMoved6 = "1.6"
|
||||
SeAddr1SenderSyntax7 = "1.7"
|
||||
SeAddr1BadSenderSystemAddress8 = "1.8"
|
||||
SeAddr1NullMX = "1.10" // ../rfc/7505:237
|
||||
|
||||
// 2.x - Mailbox.
|
||||
// ../rfc/3463:361
|
||||
SeMailbox2Other0 = "2.0"
|
||||
SeMailbox2Disabled1 = "2.1"
|
||||
SeMailbox2Full2 = "2.2"
|
||||
SeMailbox2MsgLimitExceeded3 = "2.3"
|
||||
SeMailbox2MailListExpansion4 = "2.4"
|
||||
|
||||
// 3.x - Mail system.
|
||||
// ../rfc/3463:405
|
||||
SeSys3Other0 = "3.0"
|
||||
SeSys3StorageFull1 = "3.1"
|
||||
SeSys3NotAccepting2 = "3.2"
|
||||
SeSys3NotSupported3 = "3.3"
|
||||
SeSys3MsgLimitExceeded4 = "3.4"
|
||||
SeSys3Misconfigured5 = "3.5"
|
||||
|
||||
// 4.x - Network and routing.
|
||||
// ../rfc/3463:455
|
||||
SeNet4Other0 = "4.0"
|
||||
SeNet4NoAnswer1 = "4.1"
|
||||
SeNet4BadConn2 = "4.2"
|
||||
SeNet4Name3 = "4.3"
|
||||
SeNet4Routing4 = "4.4"
|
||||
SeNet4Congestion5 = "4.5"
|
||||
SeNet4Loop6 = "4.6"
|
||||
SeNet4DeliveryExpired7 = "4.7"
|
||||
|
||||
// 5.x - Mail delivery protocol.
|
||||
// ../rfc/3463:527
|
||||
SeProto5Other0 = "5.0"
|
||||
SeProto5BadCmdOrSeq1 = "5.1"
|
||||
SeProto5Syntax2 = "5.2"
|
||||
SeProto5TooManyRcpts3 = "5.3"
|
||||
SeProto5BadParams4 = "5.4"
|
||||
SeProto5ProtocolMismatch5 = "5.5"
|
||||
SeProto5AuthExchangeTooLong = "5.6" // ../rfc/4954:650
|
||||
|
||||
// 6.x - Message content/media.
|
||||
// ../rfc/3463:579
|
||||
SeMsg6Other0 = "6.0"
|
||||
SeMsg6MediaUnsupported1 = "6.1"
|
||||
SeMsg6ConversionProhibited2 = "6.2"
|
||||
SeMsg6ConversoinUnsupported3 = "6.3"
|
||||
SeMsg6ConversionWithLoss4 = "6.4"
|
||||
SeMsg6ConversionFailed5 = "6.5"
|
||||
SeMsg6NonASCIIAddrNotPermitted7 = "6.7" // ../rfc/6531:735
|
||||
SeMsg6UTF8ReplyRequired8 = "6.8" // ../rfc/6531:746
|
||||
SeMsg6UTF8CannotTransfer9 = "6.9" // ../rfc/6531:758
|
||||
|
||||
// 7.x - Security/policy.
|
||||
// ../rfc/3463:628
|
||||
SePol7Other0 = "7.0"
|
||||
SePol7DeliveryUnauth1 = "7.1"
|
||||
SePol7ExpnProhibited2 = "7.2"
|
||||
SePol7ConversionImpossible3 = "7.3"
|
||||
SePol7Unsupported4 = "7.4"
|
||||
SePol7CryptoFailure5 = "7.5"
|
||||
SePol7CryptoUnsupported6 = "7.6"
|
||||
SePol7MsgIntegrity7 = "7.7"
|
||||
SePol7AuthBadCreds8 = "7.8" // ../rfc/4954:600
|
||||
SePol7AuthWeakMech9 = "7.9" // ../rfc/4954:593
|
||||
SePol7EncNeeded10 = "7.10" // ../rfc/5248:359
|
||||
SePol7EncReqForAuth11 = "7.11" // ../rfc/4954:630
|
||||
SePol7PasswdTransitionReq12 = "7.12" // ../rfc/4954:578
|
||||
SePol7AccountDisabled13 = "7.13" // ../rfc/5248:399
|
||||
SePol7TrustReq14 = "7.14" // ../rfc/5248:418
|
||||
SePol7NoDKIMPass20 = "7.20" // ../rfc/7372:137
|
||||
SePol7NoDKIMAccept21 = "7.21" // ../rfc/7372:148
|
||||
SePol7NoDKIMAuthorMatch22 = "7.22" // ../rfc/7372:175
|
||||
SePol7SPFResultFail23 = "7.23" // ../rfc/7372:192
|
||||
SePol7SPFError24 = "7.24" // ../rfc/7372:204
|
||||
SePol7RevDNSFail25 = "7.25" // ../rfc/7372:233
|
||||
SePol7MultiAuthFails26 = "7.26" // ../rfc/7372:246
|
||||
SePol7SenderHasNullMX27 = "7.27" // ../rfc/7505:246
|
||||
SePol7ARCFail = "7.29" // ../rfc/8617:1438
|
||||
SePol7MissingReqTLS = "7.30" // ../rfc/8689:448
|
||||
)
|
138
smtp/data.go
Normal file
138
smtp/data.go
Normal file
@ -0,0 +1,138 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
var errMissingCRLF = errors.New("missing crlf at end of message")
|
||||
|
||||
// DataWrite reads data (a mail message) from r, and writes it to smtp
|
||||
// connection w with dot stuffing, as required by the SMTP data command.
|
||||
func DataWrite(w io.Writer, r io.Reader) error {
|
||||
// ../rfc/5321:2003
|
||||
|
||||
var prevlast, last byte = '\r', '\n' // Start on a new line, so we insert a dot if the first byte is a dot.
|
||||
// todo: at least for smtp submission we should probably set a max line length, eg 1000 octects including crlf. ../rfc/5321:3512
|
||||
// todo: at least for smtp submission or a pedantic mode, we should refuse messages with bare \r or bare \n.
|
||||
buf := make([]byte, 8*1024)
|
||||
for {
|
||||
nr, err := r.Read(buf)
|
||||
if nr > 0 {
|
||||
// Process buf by writing a line at a time, and checking if the next character
|
||||
// after the line starts with a dot. Insert an extra dot if so.
|
||||
p := buf[:nr]
|
||||
for len(p) > 0 {
|
||||
if p[0] == '.' && prevlast == '\r' && last == '\n' {
|
||||
if _, err := w.Write([]byte{'.'}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Look for the next newline, or end of buffer.
|
||||
n := 0
|
||||
for n < len(p) {
|
||||
c := p[n]
|
||||
n++
|
||||
if c == '\n' {
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, err := w.Write(p[:n]); err != nil {
|
||||
return err
|
||||
}
|
||||
// Keep track of the last two bytes we've written.
|
||||
if n == 1 {
|
||||
prevlast, last = last, p[0]
|
||||
} else {
|
||||
prevlast, last = p[n-2], p[n-1]
|
||||
}
|
||||
p = p[n:]
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if prevlast != '\r' || last != '\n' {
|
||||
return errMissingCRLF
|
||||
}
|
||||
if _, err := w.Write(dotcrlf); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var dotcrlf = []byte(".\r\n")
|
||||
|
||||
// DataReader is an io.Reader that reads data from an SMTP DATA command, doing dot
|
||||
// unstuffing and returning io.EOF when a bare dot is received. Use NewDataReader.
|
||||
type DataReader struct {
|
||||
// ../rfc/5321:2003
|
||||
r *bufio.Reader
|
||||
plast, last byte
|
||||
buf []byte // From previous read.
|
||||
err error // Read error, for after r.buf is exhausted.
|
||||
}
|
||||
|
||||
// NewDataReader returns an initialized DataReader.
|
||||
func NewDataReader(r *bufio.Reader) *DataReader {
|
||||
return &DataReader{
|
||||
r: r,
|
||||
// Set up initial state to accept a message that is only "." and CRLF.
|
||||
plast: '\r',
|
||||
last: '\n',
|
||||
}
|
||||
}
|
||||
|
||||
// Read implements io.Reader.
|
||||
func (r *DataReader) Read(p []byte) (int, error) {
|
||||
wrote := 0
|
||||
for len(p) > 0 {
|
||||
// Read until newline as long as it fits in the buffer.
|
||||
if len(r.buf) == 0 {
|
||||
if r.err != nil {
|
||||
break
|
||||
}
|
||||
// todo: set a max length, eg 1000 octets including crlf excluding potential leading dot. ../rfc/5321:3512
|
||||
r.buf, r.err = r.r.ReadSlice('\n')
|
||||
if r.err == bufio.ErrBufferFull {
|
||||
r.err = nil
|
||||
} else if r.err == io.EOF {
|
||||
// Mark EOF as bad for now. If we see the ending dotcrlf below, err becomes regular
|
||||
// io.EOF again.
|
||||
r.err = io.ErrUnexpectedEOF
|
||||
}
|
||||
}
|
||||
if len(r.buf) > 0 {
|
||||
// We require crlf. A bare LF is not a line ending. ../rfc/5321:2032
|
||||
// todo: we could return an error for a bare \n.
|
||||
if r.plast == '\r' && r.last == '\n' {
|
||||
if bytes.Equal(r.buf, dotcrlf) {
|
||||
r.buf = nil
|
||||
r.err = io.EOF
|
||||
break
|
||||
} else if r.buf[0] == '.' {
|
||||
r.buf = r.buf[1:]
|
||||
}
|
||||
}
|
||||
n := len(r.buf)
|
||||
if n > len(p) {
|
||||
n = len(p)
|
||||
}
|
||||
copy(p, r.buf[:n])
|
||||
if n == 1 {
|
||||
r.plast, r.last = r.last, r.buf[0]
|
||||
} else if n > 1 {
|
||||
r.plast, r.last = r.buf[n-2], r.buf[n-1]
|
||||
}
|
||||
p = p[n:]
|
||||
r.buf = r.buf[n:]
|
||||
wrote += n
|
||||
}
|
||||
}
|
||||
return wrote, r.err
|
||||
}
|
91
smtp/data_test.go
Normal file
91
smtp/data_test.go
Normal file
@ -0,0 +1,91 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDataWrite(t *testing.T) {
|
||||
if err := DataWrite(io.Discard, strings.NewReader("bad")); err == nil || !errors.Is(err, errMissingCRLF) {
|
||||
t.Fatalf("got err %v, expected errMissingCRLF", err)
|
||||
}
|
||||
if err := DataWrite(io.Discard, strings.NewReader(".")); err == nil || !errors.Is(err, errMissingCRLF) {
|
||||
t.Fatalf("got err %v, expected errMissingCRLF", err)
|
||||
}
|
||||
|
||||
check := func(msg, want string) {
|
||||
t.Helper()
|
||||
w := &strings.Builder{}
|
||||
if err := DataWrite(w, strings.NewReader(msg)); err != nil {
|
||||
t.Fatalf("writing smtp data: %s", err)
|
||||
}
|
||||
got := w.String()
|
||||
if got != want {
|
||||
t.Fatalf("got %q, expected %q, for msg %q", got, want, msg)
|
||||
}
|
||||
}
|
||||
|
||||
check("", ".\r\n")
|
||||
check(".\r\n", "..\r\n.\r\n")
|
||||
check("header: abc\r\n\r\nmessage\r\n", "header: abc\r\n\r\nmessage\r\n.\r\n")
|
||||
}
|
||||
|
||||
func TestDataReader(t *testing.T) {
|
||||
// Copy with a 1 byte buffer for reading.
|
||||
smallCopy := func(d io.Writer, r io.Reader) (int, error) {
|
||||
var wrote int
|
||||
buf := make([]byte, 1)
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
nn, err := d.Write(buf)
|
||||
if nn > 0 {
|
||||
wrote += nn
|
||||
}
|
||||
if err != nil {
|
||||
return wrote, err
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return wrote, err
|
||||
}
|
||||
}
|
||||
return wrote, nil
|
||||
}
|
||||
|
||||
check := func(data, want string) {
|
||||
t.Helper()
|
||||
|
||||
s := &strings.Builder{}
|
||||
dr := NewDataReader(bufio.NewReader(strings.NewReader(data)))
|
||||
if _, err := io.Copy(s, dr); err != nil {
|
||||
t.Fatalf("got err %v", err)
|
||||
} else if got := s.String(); got != want {
|
||||
t.Fatalf("got %q, expected %q, for %q", got, want, data)
|
||||
}
|
||||
|
||||
s = &strings.Builder{}
|
||||
dr = NewDataReader(bufio.NewReader(strings.NewReader(data)))
|
||||
if _, err := smallCopy(s, dr); err != nil {
|
||||
t.Fatalf("got err %v", err)
|
||||
} else if got := s.String(); got != want {
|
||||
t.Fatalf("got %q, expected %q, for %q", got, want, data)
|
||||
}
|
||||
}
|
||||
|
||||
check("test\r\n.\r\n", "test\r\n")
|
||||
check(".\r\n", "")
|
||||
check(".test\r\n.\r\n", "test\r\n") // Unnecessary dot, but valid in SMTP.
|
||||
check("..test\r\n.\r\n", ".test\r\n")
|
||||
|
||||
s := &strings.Builder{}
|
||||
dr := NewDataReader(bufio.NewReader(strings.NewReader("no end")))
|
||||
if _, err := io.Copy(s, dr); err != io.ErrUnexpectedEOF {
|
||||
t.Fatalf("got err %v, expected io.ErrUnexpectedEOF", err)
|
||||
}
|
||||
}
|
2
smtp/doc.go
Normal file
2
smtp/doc.go
Normal file
@ -0,0 +1,2 @@
|
||||
// Package smtp provides SMTP definitions and functions shared between smtpserver and smtpclient.
|
||||
package smtp
|
17
smtp/ehlo.go
Normal file
17
smtp/ehlo.go
Normal file
@ -0,0 +1,17 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
)
|
||||
|
||||
// Ehlo is the remote identification of an incoming SMTP connection.
|
||||
type Ehlo struct {
|
||||
Name dns.IPDomain // Name from EHLO/HELO line. Can be an IP or host name.
|
||||
ConnIP net.IP // Address of connection.
|
||||
}
|
||||
|
||||
func (e Ehlo) IsZero() bool {
|
||||
return e.Name.IsZero() && e.ConnIP == nil
|
||||
}
|
67
smtp/path.go
Normal file
67
smtp/path.go
Normal file
@ -0,0 +1,67 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
)
|
||||
|
||||
// Path is an SMTP forward/reverse path, as used in MAIL FROM and RCPT TO
|
||||
// commands.
|
||||
type Path struct {
|
||||
Localpart Localpart
|
||||
IPDomain dns.IPDomain
|
||||
}
|
||||
|
||||
func (p Path) IsZero() bool {
|
||||
return p.Localpart == "" && p.IPDomain.IsZero()
|
||||
}
|
||||
|
||||
// String returns a string representation with ASCII-only domain name.
|
||||
func (p Path) String() string {
|
||||
return p.XString(false)
|
||||
}
|
||||
|
||||
// XString is like String, but returns unicode UTF-8 domain names if utf8 is
|
||||
// true.
|
||||
func (p Path) XString(utf8 bool) string {
|
||||
if p.Localpart == "" && p.IPDomain.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return p.Localpart.String() + "@" + p.IPDomain.XString(utf8)
|
||||
}
|
||||
|
||||
// ASCIIExtra returns an ascii-only path if utf8 is true and the ipdomain is a
|
||||
// unicode domain. Otherwise returns an empty string.
|
||||
//
|
||||
// For use in comments in message headers added during SMTP.
|
||||
func (p Path) ASCIIExtra(utf8 bool) string {
|
||||
if utf8 && p.IPDomain.Domain.Unicode != "" {
|
||||
return p.XString(false)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DSNString returns a string representation as used with DSN with/without
|
||||
// UTF-8 support.
|
||||
//
|
||||
// If utf8 is false, the domain is represented as US-ASCII (IDNA), and the
|
||||
// localpart is encoded with in 7bit according to RFC 6533.
|
||||
func (p Path) DSNString(utf8 bool) string {
|
||||
if utf8 {
|
||||
return p.XString(utf8)
|
||||
}
|
||||
return p.Localpart.DSNString(utf8) + "@" + p.IPDomain.XString(utf8)
|
||||
}
|
||||
|
||||
func (p Path) Equal(o Path) bool {
|
||||
if p.Localpart != o.Localpart {
|
||||
return false
|
||||
}
|
||||
d0 := p.IPDomain
|
||||
d1 := o.IPDomain
|
||||
if len(d0.IP) > 0 || len(d1.IP) > 0 {
|
||||
return d0.IP.Equal(d1.IP)
|
||||
}
|
||||
return strings.EqualFold(d0.Domain.ASCII, d1.Domain.ASCII)
|
||||
}
|
Reference in New Issue
Block a user