diff --git a/README.md b/README.md index 1ec5605..53039b1 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Some of the features of this library: * [X] Support for attachments and inline embeds (from file system, `io.Reader` or `embed.FS`) * [X] Support for different encodings * [X] Support sending mails via a local sendmail command -* [X] Support for requestng MDNs +* [X] Support for requestng MDNs (RFC 8098) and DSNs (RFC 1891) * [X] Message object satisfies `io.WriteTo` and `io.Reader` interfaces * [X] Support for Go's `html/template` and `text/template` (as message body, alternative part or attachment/emebed) * [X] Output to file support which allows storing mail messages as e. g. `.eml` files to disk to open them in a MUA diff --git a/client.go b/client.go index e7a3e75..df0f8ea 100644 --- a/client.go +++ b/client.go @@ -33,14 +33,89 @@ const ( DefaultTLSMinVersion = tls.VersionTLS12 ) +// DSNMailReturnOption is a type to define which MAIL RET option is used when a DSN +// is requested +type DSNMailReturnOption string + +// DSNRcptNotifyOption is a type to define which RCPT NOTIFY option is used when a DSN +// is requested +type DSNRcptNotifyOption string + +const ( + // DSNMailReturnHeadersOnly requests that only the headers of the message be returned. + // See: https://www.rfc-editor.org/rfc/rfc1891#section-5.3 + DSNMailReturnHeadersOnly DSNMailReturnOption = "HDRS" + + // DSNMailReturnFull requests that the entire message be returned in any "failed" + // delivery status notification issued for this recipient + // See: https://www.rfc-editor.org/rfc/rfc1891#section-5.3 + DSNMailReturnFull DSNMailReturnOption = "FULL" + + // DSNRcptNotifyNever requests that a DSN not be returned to the sender under + // any conditions. + // See: https://www.rfc-editor.org/rfc/rfc1891#section-5.1 + DSNRcptNotifyNever DSNRcptNotifyOption = "NEVER" + + // DSNRcptNotifySuccess requests that a DSN be issued on successful delivery + // See: https://www.rfc-editor.org/rfc/rfc1891#section-5.1 + DSNRcptNotifySuccess DSNRcptNotifyOption = "SUCCESS" + + // DSNRcptNotifyFailure requests that a DSN be issued on delivery failure + // See: https://www.rfc-editor.org/rfc/rfc1891#section-5.1 + DSNRcptNotifyFailure DSNRcptNotifyOption = "FAILURE" + + // DSNRcptNotifyDelay indicates the sender's willingness to receive + // "delayed" DSNs. Delayed DSNs may be issued if delivery of a message has + // been delayed for an unusual amount of time (as determined by the MTA at + // which the message is delayed), but the final delivery status (whether + // successful or failure) cannot be determined. The absence of the DELAY + // keyword in a NOTIFY parameter requests that a "delayed" DSN NOT be + // issued under any conditions. + // See: https://www.rfc-editor.org/rfc/rfc1891#section-5.1 + DSNRcptNotifyDelay DSNRcptNotifyOption = "DELAY" +) + // Client is the SMTP client struct type Client struct { + // co is the net.Conn that the smtp.Client is based on + co net.Conn + + // Timeout for the SMTP server connection + cto time.Duration + + // dsn indicates that we want to use DSN for the Client + dsn bool + + // dsnmrtype defines the DSNMailReturnOption in case DSN is enabled + dsnmrtype DSNMailReturnOption + + // dsnrntype defines the DSNRcptNotifyOption in case DSN is enabled + dsnrntype []string + + // enc indicates if a Client connection is encrypted or not + enc bool + + // HELO/EHLO string for the greeting the target SMTP server + helo string + // Hostname of the target SMTP server cto connect cto host string + // pass is the corresponding SMTP AUTH password + pass string + // Port of the SMTP server cto connect cto port int + // sa is a pointer to smtp.Auth + sa smtp.Auth + + // satype represents the authentication type for SMTP AUTH + satype SMTPAuthType + + // sc is the smtp.Client that is set up when using the Dial*() methods + sc *smtp.Client + // Use SSL for the connection ssl bool @@ -50,32 +125,8 @@ type Client struct { // tlsconfig represents the tls.Config setting for the STARTTLS connection tlsconfig *tls.Config - // Timeout for the SMTP server connection - cto time.Duration - - // HELO/EHLO string for the greeting the target SMTP server - helo string - - // enc indicates if a Client connection is encrypted or not - enc bool - // user is the SMTP AUTH username user string - - // pass is the corresponding SMTP AUTH password - pass string - - // satype represents the authentication type for SMTP AUTH - satype SMTPAuthType - - // co is the net.Conn that the smtp.Client is based on - co net.Conn - - // sa is a pointer to smtp.Auth - sa smtp.Auth - - // sc is the smtp.Client that is set up when using the Dial*() methods - sc *smtp.Client } // Option returns a function that can be used for grouping Client options @@ -107,6 +158,20 @@ var ( // ErrServerNoUnencoded should be used when 8BIT encoding is selected for a message, but // the server does not offer 8BITMIME mode ErrServerNoUnencoded = errors.New("message is 8bit unencoded, but server does not support 8BITMIME") + + // ErrInvalidDSNMailReturnOption should be used when an invalid option is provided for the + // DSNMailReturnOption in WithDSN + ErrInvalidDSNMailReturnOption = errors.New("DSN mail return option can only be HDRS or FULL") + + // ErrInvalidDSNRcptNotifyOption should be used when an invalid option is provided for the + // DSNRcptNotifyOption in WithDSN + ErrInvalidDSNRcptNotifyOption = errors.New("DSN rcpt notify option can only be: NEVER, " + + "SUCCESS, FAILURE or DELAY") + + // ErrInvalidDSNRcptNotifyCombination should be used when an invalid option is provided for the + // DSNRcptNotifyOption in WithDSN + ErrInvalidDSNRcptNotifyCombination = errors.New("DSN rcpt notify option NEVER cannot be " + + "combined with any of SUCCESS, FAILURE or DELAY") ) // NewClient returns a new Session client object @@ -234,6 +299,72 @@ func WithPassword(p string) Option { } } +// WithDSN enables the Client to request DSNs (if the server supports it) +// as described in the RFC 1891 and set defaults for DSNMailReturnOption +// to DSNMailReturnFull and DSNRcptNotifyOption to DSNRcptNotifySuccess +// and DSNRcptNotifyFailure +func WithDSN() Option { + return func(c *Client) error { + c.dsn = true + c.dsnmrtype = DSNMailReturnFull + c.dsnrntype = []string{string(DSNRcptNotifyFailure), string(DSNRcptNotifySuccess)} + return nil + } +} + +// WithDSNMailReturnType enables the Client to request DSNs (if the server supports it) +// as described in the RFC 1891 and set the MAIL FROM Return option type to the +// given DSNMailReturnOption +// See: https://www.rfc-editor.org/rfc/rfc1891 +func WithDSNMailReturnType(mro DSNMailReturnOption) Option { + return func(c *Client) error { + switch mro { + case DSNMailReturnHeadersOnly: + case DSNMailReturnFull: + default: + return ErrInvalidDSNMailReturnOption + } + + c.dsn = true + c.dsnmrtype = mro + return nil + } +} + +// WithDSNRcptNotifyType enables the Client to request DSNs as described in the RFC 1891 +// and sets the RCPT TO notify options to the given list of DSNRcptNotifyOption +// See: https://www.rfc-editor.org/rfc/rfc1891 +func WithDSNRcptNotifyType(rno ...DSNRcptNotifyOption) Option { + return func(c *Client) error { + var rnol []string + var ns, nns bool + if len(rno) > 0 { + for _, crno := range rno { + switch crno { + case DSNRcptNotifyNever: + ns = true + case DSNRcptNotifySuccess: + nns = true + case DSNRcptNotifyFailure: + nns = true + case DSNRcptNotifyDelay: + nns = true + default: + return ErrInvalidDSNRcptNotifyOption + } + rnol = append(rnol, string(crno)) + } + } + if ns && nns { + return ErrInvalidDSNRcptNotifyCombination + } + + c.dsn = true + c.dsnrntype = rnol + return nil + } +} + // TLSPolicy returns the currently set TLSPolicy as string func (c *Client) TLSPolicy() string { return c.tlspolicy.String() @@ -351,11 +482,11 @@ func (c *Client) Send(ml ...*Msg) error { return err } - if err := c.sc.Mail(f); err != nil { + if err := c.mail(f); err != nil { return fmt.Errorf("sending MAIL FROM command failed: %w", err) } for _, r := range rl { - if err := c.sc.Rcpt(r); err != nil { + if err := c.rcpt(r); err != nil { return fmt.Errorf("sending RCPT TO command failed: %w", err) } } @@ -507,3 +638,91 @@ func (c *Client) auth() error { } return nil } + +// mail is an extension to the Go std library mail method. It decideds wether to call the +// original mail method from the std library or in case DSN is enabled on the Client to +// call our own method instead +func (c *Client) mail(f string) error { + ok, _ := c.sc.Extension("DSN") + if ok && c.dsn { + return c.dsnMail(f) + } + return c.sc.Mail(f) +} + +// rcpt is an extension to the Go std library rcpt method. It decideds wether to call +// original rcpt method from the std library or in case DSN is enabled on the Client to +// call our own method instead +func (c *Client) rcpt(t string) error { + ok, _ := c.sc.Extension("DSN") + if ok && c.dsn { + return c.dsnRcpt(t) + } + return c.sc.Rcpt(t) +} + +// dsnRcpt issues a RCPT command to the server using the provided email address. +// A call to rcpt must be preceded by a call to mail and may be followed by +// a Data call or another rcpt call. +// +// This is a copy of the original Go std library net/smtp function with additions +// for the DSN extension +func (c *Client) dsnRcpt(t string) error { + if err := validateLine(t); err != nil { + return err + } + if len(c.dsnrntype) <= 0 { + return c.sc.Rcpt(t) + } + + rno := strings.Join(c.dsnrntype, ",") + _, _, err := c.cmd(25, "RCPT TO:<%s> NOTIFY=%s", t, rno) + return err +} + +// dsnMail issues a MAIL command to the server using the provided email address. +// If the server supports the 8BITMIME extension, mail adds the BODY=8BITMIME +// parameter. If the server supports the SMTPUTF8 extension, mail adds the +// SMTPUTF8 parameter. +// This initiates a mail transaction and is followed by one or more rcpt calls. +// +// This is a copy of the original Go std library net/smtp function with additions +// for the DSN extension +func (c *Client) dsnMail(f string) error { + if err := validateLine(f); err != nil { + return err + } + cmdStr := "MAIL FROM:<%s>" + if ok, _ := c.sc.Extension("8BITMIME"); ok { + cmdStr += " BODY=8BITMIME" + } + if ok, _ := c.sc.Extension("SMTPUTF8"); ok { + cmdStr += " SMTPUTF8" + } + cmdStr += fmt.Sprintf(" RET=%s", c.dsnmrtype) + + _, _, err := c.cmd(250, cmdStr, f) + return err +} + +// validateLine checks to see if a line has CR or LF as per RFC 5321 +// This is a 1:1 copy of the method from the original Go std library net/smtp +func validateLine(line string) error { + if strings.ContainsAny(line, "\n\r") { + return errors.New("smtp: A line must not contain CR or LF") + } + return nil +} + +// cmd is a convenience function that sends a command and returns the response +// This is a 1:1 copy of the method from the original Go std library net/smtp +func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, error) { + id, err := c.sc.Text.Cmd(format, args...) + if err != nil { + return 0, "", err + } + c.sc.Text.StartResponse(id) + defer c.sc.Text.EndResponse(id) + code, msg, err := c.sc.Text.ReadResponse(expectCode) + return code, msg, err +} diff --git a/client_test.go b/client_test.go index 8b67f88..045ab65 100644 --- a/client_test.go +++ b/client_test.go @@ -94,6 +94,13 @@ func TestNewClientWithOptions(t *testing.T) { false}, {"WithUsername()", WithUsername("test"), false}, {"WithPassword()", WithPassword("test"), false}, + {"WithDSN()", WithDSN(), false}, + {"WithDSNMailReturnType()", WithDSNMailReturnType(DSNMailReturnFull), false}, + {"WithDSNMailReturnType() wrong option", WithDSNMailReturnType("FAIL"), true}, + {"WithDSNRcptNotifyType()", WithDSNRcptNotifyType(DSNRcptNotifySuccess), false}, + {"WithDSNRcptNotifyType() wrong option", WithDSNRcptNotifyType("FAIL"), true}, + {"WithDSNRcptNotifyType() NEVER combination", + WithDSNRcptNotifyType(DSNRcptNotifySuccess, DSNRcptNotifyNever), true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -367,6 +374,87 @@ func TestSetSMTPAuth(t *testing.T) { } } +// TestWithDSN tests the WithDSN method for the Client object +func TestWithDSN(t *testing.T) { + c, err := NewClient(DefaultHost, WithDSN()) + if err != nil { + t.Errorf("failed to create new client: %s", err) + return + } + if !c.dsn { + t.Errorf("WithDSN failed. c.dsn expected to be: %t, got: %t", true, c.dsn) + } + if c.dsnmrtype != DSNMailReturnFull { + t.Errorf("WithDSN failed. c.dsnmrtype expected to be: %s, got: %s", DSNMailReturnFull, + c.dsnmrtype) + } + if c.dsnrntype[0] != string(DSNRcptNotifyFailure) { + t.Errorf("WithDSN failed. c.dsnrntype[0] expected to be: %s, got: %s", DSNRcptNotifyFailure, + c.dsnrntype[0]) + } + if c.dsnrntype[1] != string(DSNRcptNotifySuccess) { + t.Errorf("WithDSN failed. c.dsnrntype[1] expected to be: %s, got: %s", DSNRcptNotifySuccess, + c.dsnrntype[1]) + } +} + +// TestWithDSNMailReturnType tests the WithDSNMailReturnType method for the Client object +func TestWithDSNMailReturnType(t *testing.T) { + tests := []struct { + name string + value DSNMailReturnOption + want string + sf bool + }{ + {"WithDSNMailReturnType: FULL", DSNMailReturnFull, "FULL", false}, + {"WithDSNMailReturnType: HDRS", DSNMailReturnHeadersOnly, "HDRS", false}, + {"WithDSNMailReturnType: INVALID", "INVALID", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, err := NewClient(DefaultHost, WithDSNMailReturnType(tt.value)) + if err != nil && !tt.sf { + t.Errorf("failed to create new client: %s", err) + return + } + if string(c.dsnmrtype) != tt.want { + t.Errorf("WithDSNMailReturnType failed. Expected %s, got: %s", tt.want, string(c.dsnmrtype)) + } + }) + } +} + +// TestWithDSNRcptNotifyType tests the WithDSNRcptNotifyType method for the Client object +func TestWithDSNRcptNotifyType(t *testing.T) { + tests := []struct { + name string + value DSNRcptNotifyOption + want string + sf bool + }{ + {"WithDSNRcptNotifyType: NEVER", DSNRcptNotifyNever, "NEVER", false}, + {"WithDSNRcptNotifyType: SUCCESS", DSNRcptNotifySuccess, "SUCCESS", false}, + {"WithDSNRcptNotifyType: FAILURE", DSNRcptNotifyFailure, "FAILURE", false}, + {"WithDSNRcptNotifyType: DELAY", DSNRcptNotifyDelay, "DELAY", false}, + {"WithDSNRcptNotifyType: INVALID", "INVALID", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, err := NewClient(DefaultHost, WithDSNRcptNotifyType(tt.value)) + if err != nil && !tt.sf { + t.Errorf("failed to create new client: %s", err) + return + } + if len(c.dsnrntype) <= 0 && !tt.sf { + t.Errorf("WithDSNRcptNotifyType failed. Expected at least one DSNRNType but got none") + } + if !tt.sf && c.dsnrntype[0] != tt.want { + t.Errorf("WithDSNRcptNotifyType failed. Expected %s, got: %s", tt.want, c.dsnrntype[0]) + } + }) + } +} + // TestSetSMTPAuthCustom tests the SetSMTPAuthCustom method for the Client object func TestSetSMTPAuthCustom(t *testing.T) { tests := []struct { @@ -589,6 +677,30 @@ func TestClient_DialAndSend(t *testing.T) { } } +// TestClient_DialAndSendWithDSN tests the DialAndSend() method of Client with DSN enabled +func TestClient_DialAndSendWithDSN(t *testing.T) { + if os.Getenv("TEST_ALLOW_SEND") == "" { + t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") + } + m := NewMsg() + _ = m.FromFormat("go-mail Test Mailer", os.Getenv("TEST_FROM")) + _ = m.To(TestRcpt) + m.Subject(fmt.Sprintf("This is a test mail from go-mail/v%s", VERSION)) + m.SetBulk() + m.SetDate() + m.SetMessageID() + m.SetBodyString(TypeTextPlain, "This is a test mail from the go-mail library") + + c, err := getTestConnectionWithDSN(true) + if err != nil { + t.Skipf("failed to create test client: %s. Skipping tests", err) + } + + if err := c.DialAndSend(m); err != nil { + t.Errorf("DialAndSend() failed: %s", err) + } +} + // TestClient_DialSendCloseBroken tests the Dial(), Send() and Close() method of Client with broken settings func TestClient_DialSendCloseBroken(t *testing.T) { if os.Getenv("TEST_ALLOW_SEND") == "" { @@ -649,6 +761,67 @@ func TestClient_DialSendCloseBroken(t *testing.T) { } +// TestClient_DialSendCloseBrokenWithDSN tests the Dial(), Send() and Close() method of Client with +// broken settings and DSN enabled +func TestClient_DialSendCloseBrokenWithDSN(t *testing.T) { + if os.Getenv("TEST_ALLOW_SEND") == "" { + t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") + } + tests := []struct { + name string + from string + to string + closestart bool + closeearly bool + sf bool + }{ + {"Invalid FROM", "foo@foo", TestRcpt, false, false, true}, + {"Invalid TO", os.Getenv("TEST_FROM"), "foo@foo", false, false, true}, + {"No FROM", "", TestRcpt, false, false, true}, + {"No TO", os.Getenv("TEST_FROM"), "", false, false, true}, + {"Close early", os.Getenv("TEST_FROM"), TestRcpt, false, true, true}, + {"Close start", os.Getenv("TEST_FROM"), TestRcpt, true, false, true}, + {"Close start/early", os.Getenv("TEST_FROM"), TestRcpt, true, true, true}, + } + + m := NewMsg(WithEncoding(NoEncoding)) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m.SetAddrHeaderIgnoreInvalid(HeaderFrom, tt.from) + m.SetAddrHeaderIgnoreInvalid(HeaderTo, tt.to) + + c, err := getTestConnectionWithDSN(true) + if err != nil { + t.Skipf("failed to create test client: %s. Skipping tests", err) + } + + ctx, cfn := context.WithTimeout(context.Background(), time.Second*10) + defer cfn() + if err := c.DialWithContext(ctx); err != nil && !tt.sf { + t.Errorf("Dail() failed: %s", err) + return + } + if tt.closestart { + _ = c.sc.Close() + _ = c.co.Close() + } + if err := c.Send(m); err != nil && !tt.sf { + t.Errorf("Send() failed: %s", err) + return + } + if tt.closeearly { + _ = c.sc.Close() + _ = c.co.Close() + } + if err := c.Close(); err != nil && !tt.sf { + t.Errorf("Close() failed: %s", err) + return + } + }) + } + +} + // TestClient_auth tests the Dial(), Send() and Close() method of Client with broken settings func TestClient_auth(t *testing.T) { tests := []struct { @@ -687,6 +860,27 @@ func TestClient_auth(t *testing.T) { } } +// TestValidateLine tests the validateLine() method +func TestValidateLine(t *testing.T) { + tests := []struct { + name string + value string + sf bool + }{ + {"valid line", "valid line", false}, + {`invalid line: \n`, "invalid line\n", true}, + {`invalid line: \r`, "invalid line\r", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateLine(tt.value); err != nil && !tt.sf { + t.Errorf("validateLine failed: %s", err) + } + }) + } +} + // getTestConnection takes environment variables to establish a connection to a real // SMTP server to test all functionality that requires a connection func getTestConnection(auth bool) (*Client, error) { @@ -723,3 +917,40 @@ func getTestConnection(auth bool) (*Client, error) { } return c, nil } + +// getTestConnectionWithDSN takes environment variables to establish a connection to a real +// SMTP server to test all functionality that requires a connection. It also enables DSN +func getTestConnectionWithDSN(auth bool) (*Client, error) { + if os.Getenv("TEST_SKIP_ONLINE") != "" { + return nil, fmt.Errorf("env variable TEST_SKIP_ONLINE is set. Skipping online tests") + } + th := os.Getenv("TEST_HOST") + if th == "" { + return nil, fmt.Errorf("no TEST_HOST set") + } + c, err := NewClient(th, WithDSN()) + if err != nil { + return c, err + } + if auth { + st := os.Getenv("TEST_SMTPAUTH_TYPE") + if st != "" { + c.SetSMTPAuth(SMTPAuthType(st)) + } + u := os.Getenv("TEST_SMTPAUTH_USER") + if u != "" { + c.SetUsername(u) + } + p := os.Getenv("TEST_SMTPAUTH_PASS") + if p != "" { + c.SetPassword(p) + } + } + if err := c.DialWithContext(context.Background()); err != nil { + return c, fmt.Errorf("connection to test server failed: %s", err) + } + if err := c.Close(); err != nil { + return c, fmt.Errorf("disconnect from test server failed: %s", err) + } + return c, nil +}