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 Sep 10, 2023
1 parent e13f80c commit bdf6b38
Show file tree
Hide file tree
Showing 3 changed files with 189 additions and 6 deletions.
78 changes: 75 additions & 3 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ func (c *Client) Mail(from string, opts *MailOptions) error {
return err
}
cmdStr := "MAIL FROM:<%s>"
params := []interface{}{from}
if _, ok := c.ext["8BITMIME"]; ok {
cmdStr += " BODY=8BITMIME"
}
Expand All @@ -400,13 +401,32 @@ func (c *Client) Mail(from string, opts *MailOptions) error {
return errors.New("smtp: server does not support SMTPUTF8")
}
}
if _, ok := c.ext["DSN"]; ok && opts != nil {
switch opts.Return {
case ReturnFull, ReturnHeaders:
cmdStr += "%s"
params = append(params, " RET="+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")
}
cmdStr += "%s"
params = append(params, " ENVID="+encodeXtext(opts.EnvelopeID))
}
}
if opts != nil && opts.Auth != nil {
if _, ok := c.ext["AUTH"]; ok {
cmdStr += " AUTH=" + encodeXtext(*opts.Auth)
cmdStr += "%s"
params = append(params, " AUTH="+encodeXtext(*opts.Auth))
}
// We can safely discard parameter if server does not support AUTH.
}
_, _, err := c.cmd(250, cmdStr, from)
_, _, err := c.cmd(250, cmdStr, params...)
return err
}

Expand All @@ -422,7 +442,59 @@ 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 {
cmdStr := "RCPT TO:<%s>"
params := []interface{}{to}
if _, ok := c.ext["DSN"]; ok && opts != nil {
if opts.Notify != nil && len(opts.Notify) != 0 {
val := " NOTIFY="
seen := map[DSNNotify]struct{}{}
for i, v := range opts.Notify {
switch v {
case NotifyNever:
if len(seen) != 0 {
return errors.New("smtp: Malformed NOTIFY parameter value")
}
case NotifyDelayed, NotifyFailure, NotifySuccess:
if _, ok := seen[NotifyNever]; ok {
return errors.New("smtp: Malformed NOTIFY parameter value")
}
if _, ok := seen[v]; ok {
return errors.New("smtp: Malformed NOTIFY parameter value")
}
default:
return errors.New("smtp: Unknown NOTIFY parameter value")
}
if i != 0 {
val += ","
}
val += string(v)
seen[v] = struct{}{}
}
cmdStr += "%s"
params = append(params, val)
}
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")
}
cmdStr += "%s"
params = append(params, " ORCPT="+string(opts.OriginalRecipientType)+";"+enc)
}
}
if _, _, err := c.cmd(25, cmdStr, params...); err != nil {
return err
}
c.rcpts = append(c.rcpts, to)
Expand Down
71 changes: 68 additions & 3 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -934,7 +934,8 @@ Goodbye.`

func TestClientXtext(t *testing.T) {
server := "220 hello world\r\n" +
"200 some more"
"250 ok\r\n" +
"250 ok"
var wrote bytes.Buffer
var fake faker
fake.ReadWriter = struct {
Expand All @@ -949,11 +950,75 @@ 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 {
if got, want := wrote.String(), "MAIL FROM:<[email protected]> [email protected]\r\nRCPT TO:<[email protected]> ORCPT=UTF-8;e\\x{3D}[email protected]\r\n"; got != want {
t.Errorf("wrote %q; want %q", got, want)
}
}

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 bdf6b38

Please sign in to comment.