Skip to content

Commit 495c409

Browse files
committed
client: introduce DataCommand
Closes: #189
1 parent f9e8d24 commit 495c409

File tree

2 files changed

+142
-74
lines changed

2 files changed

+142
-74
lines changed

client.go

Lines changed: 124 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"io"
1313
"net"
1414
"net/textproto"
15+
"sort"
1516
"strconv"
1617
"strings"
1718
"time"
@@ -540,85 +541,152 @@ func (c *Client) Rcpt(to string, opts *RcptOptions) error {
540541
return nil
541542
}
542543

543-
type dataCloser struct {
544-
c *Client
545-
io.WriteCloser
546-
statusCb func(rcpt string, status *SMTPError)
547-
closed bool
544+
// DataCommand is a pending DATA command. DataCommand is an io.WriteCloser.
545+
// See Client.Data.
546+
type DataCommand struct {
547+
client *Client
548+
wc io.WriteCloser
549+
550+
closeErr error
551+
}
552+
553+
var _ io.WriteCloser = (*DataCommand)(nil)
554+
555+
// Write implements io.Writer.
556+
func (cmd *DataCommand) Write(b []byte) (int, error) {
557+
return cmd.wc.Write(b)
558+
}
559+
560+
// Close implements io.Closer.
561+
func (cmd *DataCommand) Close() error {
562+
var err error
563+
if cmd.client.lmtp {
564+
_, err = cmd.CloseWithLMTPResponse()
565+
} else {
566+
_, err = cmd.CloseWithResponse()
567+
}
568+
return err
548569
}
549570

550-
func (d *dataCloser) Close() error {
551-
if d.closed {
552-
return fmt.Errorf("smtp: data writer closed twice")
571+
// CloseWithResponse is equivalent to Close, but also returns the server
572+
// response. It cannot be called when the LMTP protocol is used.
573+
//
574+
// If server returns an error, it will be of type *SMTPError.
575+
func (cmd *DataCommand) CloseWithResponse() (*DataResponse, error) {
576+
if cmd.client.lmtp {
577+
return nil, errors.New("smtp: CloseWithResponse used with an LMTP client")
553578
}
554579

555-
if err := d.WriteCloser.Close(); err != nil {
556-
return err
580+
if err := cmd.close(); err != nil {
581+
return nil, err
557582
}
558583

559-
d.c.conn.SetDeadline(time.Now().Add(d.c.SubmissionTimeout))
560-
defer d.c.conn.SetDeadline(time.Time{})
584+
cmd.client.conn.SetDeadline(time.Now().Add(cmd.client.SubmissionTimeout))
585+
defer cmd.client.conn.SetDeadline(time.Time{})
561586

562-
expectedResponses := len(d.c.rcpts)
563-
if d.c.lmtp {
564-
for expectedResponses > 0 {
565-
rcpt := d.c.rcpts[len(d.c.rcpts)-expectedResponses]
566-
if _, _, err := d.c.readResponse(250); err != nil {
567-
if smtpErr, ok := err.(*SMTPError); ok {
568-
if d.statusCb != nil {
569-
d.statusCb(rcpt, smtpErr)
570-
}
571-
} else {
572-
return err
587+
_, msg, err := cmd.client.readResponse(250)
588+
if err != nil {
589+
cmd.closeErr = err
590+
return nil, err
591+
}
592+
593+
return &DataResponse{StatusText: msg}, nil
594+
}
595+
596+
// CloseWithLMTPResponse is equivalent to Close, but also returns per-recipient
597+
// server responses. It can only be called when the LMTP protocol is used.
598+
//
599+
// If server returns an error, it will be of type LMTPDataError.
600+
func (cmd *DataCommand) CloseWithLMTPResponse() (map[string]*DataResponse, error) {
601+
if !cmd.client.lmtp {
602+
return nil, errors.New("smtp: CloseWithLMTPResponse used without an LMTP client")
603+
}
604+
605+
if err := cmd.close(); err != nil {
606+
return nil, err
607+
}
608+
609+
cmd.client.conn.SetDeadline(time.Now().Add(cmd.client.SubmissionTimeout))
610+
defer cmd.client.conn.SetDeadline(time.Time{})
611+
612+
resp := make(map[string]*DataResponse, len(cmd.client.rcpts))
613+
lmtpErr := make(LMTPDataError, len(cmd.client.rcpts))
614+
for i := 0; i < len(cmd.client.rcpts); i++ {
615+
rcpt := cmd.client.rcpts[i]
616+
_, msg, err := cmd.client.readResponse(250)
617+
if err != nil {
618+
if smtpErr, ok := err.(*SMTPError); ok {
619+
lmtpErr[rcpt] = smtpErr
620+
} else {
621+
if len(lmtpErr) > 0 {
622+
return resp, errors.Join(err, lmtpErr)
573623
}
574-
} else if d.statusCb != nil {
575-
d.statusCb(rcpt, nil)
624+
return resp, err
576625
}
577-
expectedResponses--
578-
}
579-
} else {
580-
_, _, err := d.c.readResponse(250)
581-
if err != nil {
582-
return err
626+
} else {
627+
resp[rcpt] = &DataResponse{StatusText: msg}
583628
}
584629
}
585630

586-
d.closed = true
587-
return nil
631+
if len(lmtpErr) > 0 {
632+
return resp, lmtpErr
633+
}
634+
return resp, nil
588635
}
589636

590-
// Data issues a DATA command to the server and returns a writer that
591-
// can be used to write the mail headers and body. The caller should
592-
// close the writer before calling any more methods on c. A call to
593-
// Data must be preceded by one or more calls to Rcpt.
594-
//
595-
// If server returns an error, it will be of type *SMTPError.
596-
func (c *Client) Data() (io.WriteCloser, error) {
597-
_, _, err := c.cmd(354, "DATA")
598-
if err != nil {
599-
return nil, err
637+
func (cmd *DataCommand) close() error {
638+
if cmd.closeErr != nil {
639+
return cmd.closeErr
600640
}
601-
return &dataCloser{c: c, WriteCloser: c.text.DotWriter()}, nil
641+
642+
if err := cmd.wc.Close(); err != nil {
643+
cmd.closeErr = err
644+
return err
645+
}
646+
647+
cmd.closeErr = errors.New("smtp: data writer closed twice")
648+
return nil
602649
}
603650

604-
// LMTPData is the LMTP-specific version of the Data method. It accepts a callback
605-
// that will be called for each status response received from the server.
606-
//
607-
// Status callback will receive a SMTPError argument for each negative server
608-
// reply and nil for each positive reply. I/O errors will not be reported using
609-
// callback and instead will be returned by the Close method of io.WriteCloser.
610-
// Callback will be called for each successfull Rcpt call done before in the
611-
// same order.
612-
func (c *Client) LMTPData(statusCb func(rcpt string, status *SMTPError)) (io.WriteCloser, error) {
613-
if !c.lmtp {
614-
return nil, errors.New("smtp: not a LMTP client")
651+
// DataResponse is the response returned by a DATA command. See
652+
// DataCommand.CloseWithResponse.
653+
type DataResponse struct {
654+
// StatusText is the status text returned by the server. It may contain
655+
// tracking information.
656+
StatusText string
657+
}
658+
659+
// LMTPDataError is a collection of errors returned by an LMTP server for a
660+
// DATA command. It holds per-recipient errors.
661+
type LMTPDataError map[string]*SMTPError
662+
663+
// Error implements error.
664+
func (lmtpErr LMTPDataError) Error() string {
665+
return errors.Join(lmtpErr.Unwrap()...).Error()
666+
}
667+
668+
// Unwrap returns all per-recipient errors returned by the server.
669+
func (lmtpErr LMTPDataError) Unwrap() []error {
670+
l := make([]error, 0, len(lmtpErr))
671+
for rcpt, smtpErr := range lmtpErr {
672+
l = append(l, fmt.Errorf("<%v>: %w", rcpt, smtpErr))
615673
}
674+
sort.Slice(l, func(i, j int) bool {
675+
return l[i].Error() < l[j].Error()
676+
})
677+
return l
678+
}
616679

680+
// Data issues a DATA command to the server and returns a writer that
681+
// can be used to write the mail headers and body. The caller should
682+
// close the writer before calling any more methods on c. A call to
683+
// Data must be preceded by one or more calls to Rcpt.
684+
func (c *Client) Data() (*DataCommand, error) {
617685
_, _, err := c.cmd(354, "DATA")
618686
if err != nil {
619687
return nil, err
620688
}
621-
return &dataCloser{c: c, WriteCloser: c.text.DotWriter(), statusCb: statusCb}, nil
689+
return &DataCommand{client: c, wc: c.text.DotWriter()}, nil
622690
}
623691

624692
// SendMail will use an existing connection to send an email from

client_test.go

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,10 @@ Goodbye.`
150150
if _, err := w.Write([]byte(msg)); err != nil {
151151
t.Fatalf("Data write failed: %s", err)
152152
}
153-
if err := w.Close(); err != nil {
153+
if resp, err := w.CloseWithResponse(); err != nil {
154154
t.Fatalf("Bad data response: %s", err)
155+
} else if want := "Data OK"; resp.StatusText != want {
156+
t.Errorf("Bad data status text: got %q, want %q", resp.StatusText, want)
155157
}
156158

157159
if err := c.Quit(); err != nil {
@@ -916,35 +918,33 @@ Line 1
916918
.Leading dot line .
917919
Goodbye.`
918920

919-
rcpts := []string{}
920-
errors := []*SMTPError{}
921-
922-
w, err := c.LMTPData(func(rcpt string, status *SMTPError) {
923-
rcpts = append(rcpts, rcpt)
924-
errors = append(errors, status)
925-
})
921+
w, err := c.Data()
926922
if err != nil {
927923
t.Fatalf("DATA failed: %s", err)
928924
}
929925
if _, err := w.Write([]byte(msg)); err != nil {
930926
t.Fatalf("Data write failed: %s", err)
931927
}
932-
if err := w.Close(); err != nil {
933-
t.Fatalf("Bad data response: %s", err)
928+
resp, err := w.CloseWithLMTPResponse()
929+
930+
var lmtpErr LMTPDataError
931+
if !errors.As(err, &lmtpErr) {
932+
t.Fatalf("Want error of type LMTPDataError")
934933
}
935934

936-
if !reflect.DeepEqual(rcpts, []string{"golang-nuts@googlegroups.com", "golang-not-nuts@googlegroups.com"}) {
937-
t.Fatal("Status callbacks called for wrong recipients:", rcpts)
935+
wantResp := map[string]*DataResponse{
936+
"golang-nuts@googlegroups.com": {StatusText: "This recipient is fine"},
938937
}
939938

940-
if len(errors) != 2 {
941-
t.Fatalf("Wrong amount of status callback calls: %v", len(errors))
939+
if !reflect.DeepEqual(resp, wantResp) {
940+
t.Fatalf("resp = %v, want %v", resp, wantResp)
942941
}
943-
if errors[0] != nil {
944-
t.Fatalf("Unexpected error status for the first recipient: %v", errors[0])
942+
943+
if len(lmtpErr) != 1 {
944+
t.Fatalf("len(lmtpErr) = %v, want 1", len(lmtpErr))
945945
}
946-
if errors[1] == nil {
947-
t.Fatalf("Unexpected success status for the second recipient")
946+
if lmtpErr["golang-not-nuts@googlegroups.com"] == nil {
947+
t.Fatalf("Want error for second recipient")
948948
}
949949

950950
if err := c.Quit(); err != nil {

0 commit comments

Comments
 (0)