Skip to content

Commit

Permalink
Client: Implementing DSN extension (RFC 3461, RFC 6533)
Browse files Browse the repository at this point in the history
  • Loading branch information
ikedas committed Oct 18, 2023
1 parent 3445227 commit fd8acd9
Show file tree
Hide file tree
Showing 3 changed files with 187 additions and 12 deletions.
72 changes: 64 additions & 8 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,34 +379,53 @@ func (c *Client) Mail(from string, opts *MailOptions) error {
if err := c.hello(); err != nil {
return err
}
cmdStr := "MAIL FROM:<%s>"

var sb strings.Builder
sb.Grow(510 + 14 + 26 + 11 + 9 + 9 + 39 + 500)
fmt.Fprintf(&sb, "MAIL FROM:<%s>", from)
if _, ok := c.ext["8BITMIME"]; ok {
cmdStr += " BODY=8BITMIME"
sb.WriteString(" BODY=8BITMIME")
}
if _, ok := c.ext["SIZE"]; ok && opts != nil && opts.Size != 0 {
cmdStr += fmt.Sprintf(" SIZE=%v", opts.Size)
fmt.Fprintf(&sb, " SIZE=%v", opts.Size)
}
if opts != nil && opts.RequireTLS {
if _, ok := c.ext["REQUIRETLS"]; ok {
cmdStr += " REQUIRETLS"
sb.WriteString(" REQUIRETLS")
} else {
return errors.New("smtp: server does not support REQUIRETLS")
}
}
if opts != nil && opts.UTF8 {
if _, ok := c.ext["SMTPUTF8"]; ok {
cmdStr += " SMTPUTF8"
sb.WriteString(" SMTPUTF8")
} else {
return errors.New("smtp: server does not support SMTPUTF8")
}
}
if _, ok := c.ext["DSN"]; ok && opts != nil {
switch opts.Return {
case ReturnFull, ReturnHeaders:
fmt.Fprintf(&sb, " RET=%s", string(opts.Return))
case "":
// This space is intentionally left blank
default:
return errors.New("smtp: Unknown RET parameter value")
}
if opts.EnvelopeID != "" {
if !isPrintableASCII(opts.EnvelopeID) {
return errors.New("smtp: Malformed ENVID parameter value")
}
fmt.Fprintf(&sb, " ENVID=%s", encodeXtext(opts.EnvelopeID))
}
}
if opts != nil && opts.Auth != nil {
if _, ok := c.ext["AUTH"]; ok {
cmdStr += " AUTH=" + encodeXtext(*opts.Auth)
fmt.Fprintf(&sb, " AUTH=%s", encodeXtext(*opts.Auth))
}
// We can safely discard parameter if server does not support AUTH.
}
_, _, err := c.cmd(250, cmdStr, from)
_, _, err := c.cmd(250, "%s", sb.String())
return err
}

Expand All @@ -422,7 +441,44 @@ func (c *Client) Rcpt(to string, opts *RcptOptions) error {
if err := validateLine(to); err != nil {
return err
}
if _, _, err := c.cmd(25, "RCPT TO:<%s>", to); err != nil {

var sb strings.Builder
sb.Grow(510 + 29 + 501)
fmt.Fprintf(&sb, "RCPT TO:<%s>", to)
if _, ok := c.ext["DSN"]; ok && opts != nil {
if opts.Notify != nil && len(opts.Notify) != 0 {
sb.WriteString(" NOTIFY=")
if err := checkNotifySet(opts.Notify); err != nil {
return errors.New("smtp: Malformed NOTIFY parameter value")
}
for i, v := range opts.Notify {
if i != 0 {
sb.WriteString(",")
}
sb.WriteString(string(v))
}
}
if opts.OriginalRecipient != "" {
var enc string
switch opts.OriginalRecipientType {
case AddressTypeRFC822:
if !isPrintableASCII(opts.OriginalRecipient) {
return errors.New("smtp: Illegal address")
}
enc = encodeXtext(opts.OriginalRecipient)
case AddressTypeUTF8:
if _, ok := c.ext["SMTPUTF8"]; ok {
enc = encodeUTF8AddrUnitext(opts.OriginalRecipient)
} else {
enc = encodeUTF8AddrXtext(opts.OriginalRecipient)
}
default:
return errors.New("smtp: Unknown address type")
}
fmt.Fprintf(&sb, " ORCPT=%s;%s", string(opts.OriginalRecipientType), enc)
}
}
if _, _, err := c.cmd(25, "%s", sb.String()); err != nil {
return err
}
c.rcpts = append(c.rcpts, to)
Expand Down
81 changes: 77 additions & 4 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -932,9 +932,15 @@ Goodbye.`
}
}

var xtextClient = `MAIL FROM:<[email protected]> [email protected]
RCPT TO:<[email protected]> ORCPT=UTF-8;e\x{3D}[email protected]
`

func TestClientXtext(t *testing.T) {
server := "220 hello world\r\n" +
"200 some more"
"250 ok\r\n" +
"250 ok"
client := strings.Join(strings.Split(xtextClient, "\n"), "\r\n")
var wrote bytes.Buffer
var fake faker
fake.ReadWriter = struct {
Expand All @@ -949,11 +955,78 @@ func TestClientXtext(t *testing.T) {
t.Fatalf("NewClient: %v", err)
}
c.didHello = true
c.ext = map[string]string{"AUTH": "PLAIN"}
c.ext = map[string]string{"AUTH": "PLAIN", "DSN": ""}
email := "[email protected]"
c.Mail(email, &MailOptions{Auth: &email})
c.Rcpt(email, &RcptOptions{
OriginalRecipientType: AddressTypeUTF8,
OriginalRecipient: email,
})
c.Close()
if got, want := wrote.String(), "MAIL FROM:<[email protected]> [email protected]\r\n"; got != want {
t.Errorf("wrote %q; want %q", got, want)
if got := wrote.String(); got != client {
t.Errorf("wrote %q; want %q", got, client)
}
}

const (
dsnEnvelopeID = "e=mc2"
dsnEmailRFC822 = "[email protected]"
dsnEmailUTF8 = "e=mc2@ドメイン名例.jp"
)

var dsnServer = `220 hello world
250 ok
250 ok
250 ok
250 ok
`

var dsnClient = `MAIL FROM:<[email protected]> RET=HDRS ENVID=e+3Dmc2
RCPT TO:<[email protected]> NOTIFY=NEVER ORCPT=RFC822;[email protected]
RCPT TO:<[email protected]> NOTIFY=FAILURE,DELAY ORCPT=UTF-8;e\x{3D}mc2@\x{30C9}\x{30E1}\x{30A4}\x{30F3}\x{540D}\x{4F8B}.jp
RCPT TO:<e=mc2@ドメイン名例.jp> ORCPT=UTF-8;e\x{3D}mc2@ドメイン名例.jp
`

func TestClientDSN(t *testing.T) {
server := strings.Join(strings.Split(dsnServer, "\n"), "\r\n")
client := strings.Join(strings.Split(dsnClient, "\n"), "\r\n")

var wrote bytes.Buffer
var fake faker
fake.ReadWriter = struct {
io.Reader
io.Writer
}{
strings.NewReader(server),
&wrote,
}
c, err := NewClient(fake, "fake.host")
if err != nil {
t.Fatalf("NewClient: %v", err)
}
c.didHello = true
c.ext = map[string]string{"DSN": ""}
c.Mail(dsnEmailRFC822, &MailOptions{
Return: ReturnHeaders,
EnvelopeID: dsnEnvelopeID,
})
c.Rcpt(dsnEmailRFC822, &RcptOptions{
OriginalRecipientType: AddressTypeRFC822,
OriginalRecipient: dsnEmailRFC822,
Notify: []DSNNotify{NotifyNever},
})
c.Rcpt(dsnEmailRFC822, &RcptOptions{
OriginalRecipientType: AddressTypeUTF8,
OriginalRecipient: dsnEmailUTF8,
Notify: []DSNNotify{NotifyFailure, NotifyDelayed},
})
c.ext["SMTPUTF8"] = ""
c.Rcpt(dsnEmailUTF8, &RcptOptions{
OriginalRecipientType: AddressTypeUTF8,
OriginalRecipient: dsnEmailUTF8,
})
c.Close()
if actualcmds := wrote.String(); client != actualcmds {
t.Errorf("wrote %q; want %q", actualcmds, client)
}
}
46 changes: 46 additions & 0 deletions conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,52 @@ func encodeXtext(raw string) string {
return out.String()
}

// Encodes raw string to the utf-8-addr-xtext form in RFC 6533.
func encodeUTF8AddrXtext(raw string) string {
var out strings.Builder
out.Grow(len(raw))

for _, ch := range raw {
switch {
case ch >= '!' && ch <= '~' && ch != '+' && ch != '=':
// printable non-space US-ASCII except '+' and '='
out.WriteRune(ch)
default:
out.WriteRune('\\')
out.WriteRune('x')
out.WriteRune('{')
out.WriteString(strings.ToUpper(strconv.FormatInt(int64(ch), 16)))
out.WriteRune('}')
}
}
return out.String()
}

// Encodes raw string to the utf-8-addr-unitext form in RFC 6533.
func encodeUTF8AddrUnitext(raw string) string {
var out strings.Builder
out.Grow(len(raw))

for _, ch := range raw {
switch {
case ch >= '!' && ch <= '~' && ch != '+' && ch != '=':
// printable non-space US-ASCII except '+' and '='
out.WriteRune(ch)
case ch <= '\x7F':
// other ASCII: CTLs, space and specials
out.WriteRune('\\')
out.WriteRune('x')
out.WriteRune('{')
out.WriteString(strings.ToUpper(strconv.FormatInt(int64(ch), 16)))
out.WriteRune('}')
default:
// UTF-8 non-ASCII
out.WriteRune(ch)
}
}
return out.String()
}

func isPrintableASCII(val string) bool {
for _, ch := range val {
if ch < ' ' || '~' < ch {
Expand Down

0 comments on commit fd8acd9

Please sign in to comment.