diff --git a/client.go b/client.go index bcb22ec..2cb7bab 100644 --- a/client.go +++ b/client.go @@ -379,34 +379,54 @@ 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 + // A high enough power of 2 than 510+14+26+11+9+9+39+500 + sb.Grow(2048) + 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 DSNReturnFull, DSNReturnHeaders: + 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 } @@ -422,7 +442,45 @@ 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 + // A high enough power of 2 than 510+29+501 + sb.Grow(2048) + 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 DSNAddressTypeRFC822: + if !isPrintableASCII(opts.OriginalRecipient) { + return errors.New("smtp: Illegal address") + } + enc = encodeXtext(opts.OriginalRecipient) + case DSNAddressTypeUTF8: + 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) diff --git a/client_test.go b/client_test.go index ba8dfe3..f074ac0 100644 --- a/client_test.go +++ b/client_test.go @@ -932,9 +932,15 @@ Goodbye.` } } +var xtextClient = `MAIL FROM: AUTH=e+3Dmc2@example.com +RCPT TO: ORCPT=UTF-8;e\x{3D}mc2@example.com +` + 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 { @@ -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 := "e=mc2@example.com" c.Mail(email, &MailOptions{Auth: &email}) + c.Rcpt(email, &RcptOptions{ + OriginalRecipientType: DSNAddressTypeUTF8, + OriginalRecipient: email, + }) c.Close() - if got, want := wrote.String(), "MAIL FROM: AUTH=e+3Dmc2@example.com\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 = "e=mc2@example.com" + dsnEmailUTF8 = "e=mc2@ドメイン名例.jp" +) + +var dsnServer = `220 hello world +250 ok +250 ok +250 ok +250 ok +` + +var dsnClient = `MAIL FROM: RET=HDRS ENVID=e+3Dmc2 +RCPT TO: NOTIFY=NEVER ORCPT=RFC822;e+3Dmc2@example.com +RCPT TO: 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: 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: DSNReturnHeaders, + EnvelopeID: dsnEnvelopeID, + }) + c.Rcpt(dsnEmailRFC822, &RcptOptions{ + OriginalRecipientType: DSNAddressTypeRFC822, + OriginalRecipient: dsnEmailRFC822, + Notify: []DSNNotify{DSNNotifyNever}, + }) + c.Rcpt(dsnEmailRFC822, &RcptOptions{ + OriginalRecipientType: DSNAddressTypeUTF8, + OriginalRecipient: dsnEmailUTF8, + Notify: []DSNNotify{DSNNotifyFailure, DSNNotifyDelayed}, + }) + c.ext["SMTPUTF8"] = "" + c.Rcpt(dsnEmailUTF8, &RcptOptions{ + OriginalRecipientType: DSNAddressTypeUTF8, + OriginalRecipient: dsnEmailUTF8, + }) + c.Close() + if actualcmds := wrote.String(); client != actualcmds { + t.Errorf("wrote %q; want %q", actualcmds, client) } } diff --git a/conn.go b/conn.go index 38010f9..4e708a9 100644 --- a/conn.go +++ b/conn.go @@ -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 {