package mail import ( "bytes" "crypto/tls" "errors" "fmt" "net" "net/mail" "net/textproto" "strconv" "time" "github.com/m4x1202/go-smime/smime" "github.com/toorop/go-dkim" ) // Email represents an email message. type Email struct { from string sender string replyTo string returnPath string recipients []string headers textproto.MIMEHeader parts []part attachments []*File inlines []*File Charset string Encoding encoding Error error SMTPServer *smtpClient DkimMsg string SmimeCertificate *tls.Certificate } /* SMTPServer represents a SMTP Server If authentication is CRAM-MD5 then the Password is the Secret */ type SMTPServer struct { Authentication AuthType Encryption Encryption Username string Password string Helo string ConnectTimeout time.Duration SendTimeout time.Duration Host string Port int KeepAlive bool TLSConfig *tls.Config } // SMTPClient represents a SMTP Client for send email type SMTPClient struct { Client *smtpClient KeepAlive bool SendTimeout time.Duration } // part represents the different content parts of an email body. type part struct { contentType string body *bytes.Buffer } // Encryption type to enum encryption types (None, SSL/TLS, STARTTLS) type Encryption int // TODO: Remove EncryptionSSL and EncryptionTLS before launch v3 const ( // EncryptionNone uses no encryption when sending email EncryptionNone Encryption = iota // EncryptionSSL: DEPRECATED. Use EncryptionSSLTLS. Sets encryption type to SSL/TLS when sending email EncryptionSSL // EncryptionTLS: DEPRECATED. Use EncryptionSTARTTLS. sets encryption type to STARTTLS when sending email EncryptionTLS // EncryptionSSLTLS sets encryption type to SSL/TLS when sending email EncryptionSSLTLS // EncryptionSTARTTLS sets encryption type to STARTTLS when sending email EncryptionSTARTTLS ) // TODO: Remove last two indexes var encryptionTypes = [...]string{"None", "SSL/TLS", "STARTTLS", "SSL/TLS", "STARTTLS"} func (encryption Encryption) String() string { return encryptionTypes[encryption] } type encoding int const ( // EncodingNone turns off encoding on the message body EncodingNone encoding = iota // EncodingBase64 sets the message body encoding to base64 EncodingBase64 // EncodingQuotedPrintable sets the message body encoding to quoted-printable EncodingQuotedPrintable ) var encodingTypes = [...]string{"binary", "base64", "quoted-printable"} func (encoding encoding) string() string { return encodingTypes[encoding] } type ContentType int const ( // TextPlain sets body type to text/plain in message body TextPlain ContentType = iota // TextHTML sets body type to text/html in message body TextHTML // TextCalendar sets body type to text/calendar in message body TextCalendar ) var contentTypes = [...]string{"text/plain", "text/html", "text/calendar"} func (contentType ContentType) string() string { return contentTypes[contentType] } type AuthType int const ( // AuthPlain implements the PLAIN authentication AuthPlain AuthType = iota // AuthLogin implements the LOGIN authentication AuthLogin // AuthCRAMMD5 implements the CRAM-MD5 authentication AuthCRAMMD5 // AuthNone for SMTP servers without authentication AuthNone ) // NewMSG creates a new email. It uses UTF-8 by default. All charsets: http://webcheatsheet.com/HTML/character_sets_list.php func NewMSG() *Email { email := &Email{ headers: make(textproto.MIMEHeader), Charset: "UTF-8", Encoding: EncodingQuotedPrintable, } email.AddHeader("MIME-Version", "1.0") return email } // NewSMTPClient returns the client for send email func NewSMTPClient() *SMTPServer { server := &SMTPServer{ Authentication: AuthPlain, Encryption: EncryptionNone, ConnectTimeout: 10 * time.Second, SendTimeout: 10 * time.Second, Helo: "localhost", } return server } // GetEncryptionType returns the encryption type used to connect to SMTP server func (server *SMTPServer) GetEncryptionType() Encryption { return server.Encryption } // GetError returns the first email error encountered func (email *Email) GetError() error { return email.Error } // SetFrom sets the From address. func (email *Email) SetFrom(address string) *Email { if email.Error != nil { return email } email.AddAddresses("From", address) return email } // SetSender sets the Sender address. func (email *Email) SetSender(address string) *Email { if email.Error != nil { return email } email.AddAddresses("Sender", address) return email } // SetReplyTo sets the Reply-To address. func (email *Email) SetReplyTo(address string) *Email { if email.Error != nil { return email } email.AddAddresses("Reply-To", address) return email } // SetReturnPath sets the Return-Path address. This is most often used // to send bounced emails to a different email address. func (email *Email) SetReturnPath(address string) *Email { if email.Error != nil { return email } email.AddAddresses("Return-Path", address) return email } // AddTo adds a To address. You can provide multiple // addresses at the same time. func (email *Email) AddTo(addresses ...string) *Email { if email.Error != nil { return email } email.AddAddresses("To", addresses...) return email } // AddCc adds a Cc address. You can provide multiple // addresses at the same time. func (email *Email) AddCc(addresses ...string) *Email { if email.Error != nil { return email } email.AddAddresses("Cc", addresses...) return email } // AddBcc adds a Bcc address. You can provide multiple // addresses at the same time. func (email *Email) AddBcc(addresses ...string) *Email { if email.Error != nil { return email } email.AddAddresses("Bcc", addresses...) return email } // AddAddresses allows you to add addresses to the specified address header. func (email *Email) AddAddresses(header string, addresses ...string) *Email { if email.Error != nil { return email } found := false // check for a valid address header for _, h := range []string{"To", "Cc", "Bcc", "From", "Sender", "Reply-To", "Return-Path"} { if header == h { found = true } } if !found { email.Error = errors.New("Mail Error: Invalid address header; Header: [" + header + "]") return email } // check to see if the addresses are valid for i := range addresses { var address = new(mail.Address) var err error // ignore parse the address if empty if len(addresses[i]) > 0 { address, err = mail.ParseAddress(addresses[i]) if err != nil { email.Error = errors.New("Mail Error: " + err.Error() + "; Header: [" + header + "] Address: [" + addresses[i] + "]") return email } } else { continue } // check for more than one address switch { case header == "Sender" && len(email.sender) > 0: fallthrough case header == "Reply-To" && len(email.replyTo) > 0: fallthrough case header == "Return-Path" && len(email.returnPath) > 0: email.Error = errors.New("Mail Error: There can only be one \"" + header + "\" address; Header: [" + header + "] Address: [" + addresses[i] + "]") return email default: // other address types can have more than one address } // save the address switch header { case "From": // delete the current "From" to set the new // when "From" need to be changed in the message if len(email.from) > 0 && header == "From" { email.headers.Del("From") } email.from = address.Address case "Sender": email.sender = address.Address case "Reply-To": email.replyTo = address.Address case "Return-Path": email.returnPath = address.Address default: // check that the address was added to the recipients list email.recipients, err = addAddress(email.recipients, address.Address) if err != nil { email.Error = errors.New("Mail Error: " + err.Error() + "; Header: [" + header + "] Address: [" + addresses[i] + "]") return email } } // make sure the from and sender addresses are different if email.from != "" && email.sender != "" && email.from == email.sender { email.sender = "" email.headers.Del("Sender") email.Error = errors.New("Mail Error: From and Sender should not be set to the same address") return email } // add all addresses to the headers except for Bcc and Return-Path if header != "Bcc" && header != "Return-Path" { // add the address to the headers email.headers.Add(header, address.String()) } } return email } // addAddress adds an address to the address list if it hasn't already been added func addAddress(addressList []string, address string) ([]string, error) { // loop through the address list to check for dups for _, a := range addressList { if address == a { return addressList, errors.New("Mail Error: Address: [" + address + "] has already been added") } } return append(addressList, address), nil } type priority int const ( // PriorityLow sets the email priority to Low PriorityLow priority = iota // PriorityHigh sets the email priority to High PriorityHigh ) // SetPriority sets the email message priority. Use with // either "High" or "Low". func (email *Email) SetPriority(priority priority) *Email { if email.Error != nil { return email } switch priority { case PriorityLow: email.AddHeaders(textproto.MIMEHeader{ "X-Priority": {"5 (Lowest)"}, "X-MSMail-Priority": {"Low"}, "Importance": {"Low"}, }) case PriorityHigh: email.AddHeaders(textproto.MIMEHeader{ "X-Priority": {"1 (Highest)"}, "X-MSMail-Priority": {"High"}, "Importance": {"High"}, }) default: } return email } // SetDate sets the date header to the provided date/time. // The format of the string should be YYYY-MM-DD HH:MM:SS Time Zone. // // Example: SetDate("2015-04-28 10:32:00 CDT") func (email *Email) SetDate(dateTime string) *Email { if email.Error != nil { return email } const dateFormat = "2006-01-02 15:04:05 MST" // Try to parse the provided date/time dt, err := time.Parse(dateFormat, dateTime) if err != nil { email.Error = errors.New("Mail Error: Setting date failed with: " + err.Error()) return email } email.headers.Set("Date", dt.Format(time.RFC1123Z)) return email } // SetSubject sets the subject of the email message. func (email *Email) SetSubject(subject string) *Email { if email.Error != nil { return email } email.AddHeader("Subject", subject) return email } // SetListUnsubscribe sets the Unsubscribe address. func (email *Email) SetListUnsubscribe(address string) *Email { if email.Error != nil { return email } email.AddHeader("List-Unsubscribe", address) return email } // SetDkim adds DomainKey signature to the email message (header+body) func (email *Email) SetDkim(options dkim.SigOptions) *Email { if email.Error != nil { return email } msg := []byte(email.GetMessage()) err := dkim.Sign(&msg, options) if err != nil { email.Error = errors.New("Mail Error: cannot dkim sign message due: %s" + err.Error()) return email } email.DkimMsg = string(msg) return email } // SetBody sets the body of the email message. func (email *Email) SetBody(contentType ContentType, body string) *Email { if email.Error != nil { return email } email.parts = []part{ { contentType: contentType.string(), body: bytes.NewBufferString(body), }, } return email } // SetBodyData sets the body of the email message from []byte func (email *Email) SetBodyData(contentType ContentType, body []byte) *Email { if email.Error != nil { return email } email.parts = []part{ { contentType: contentType.string(), body: bytes.NewBuffer(body), }, } return email } // AddHeader adds the given "header" with the passed "value". func (email *Email) AddHeader(header string, values ...string) *Email { if email.Error != nil { return email } // check that there is actually a value if len(values) < 1 { email.Error = errors.New("Mail Error: no value provided; Header: [" + header + "]") return email } if header != "MIME-Version" { // Set header to correct canonical Mime header = textproto.CanonicalMIMEHeaderKey(header) } switch header { case "Sender": fallthrough case "From": fallthrough case "To": fallthrough case "Bcc": fallthrough case "Cc": fallthrough case "Reply-To": fallthrough case "Return-Path": email.AddAddresses(header, values...) case "Date": if len(values) > 1 { email.Error = errors.New("Mail Error: To many dates provided") return email } email.SetDate(values[0]) case "List-Unsubscribe": fallthrough default: email.headers[header] = values } return email } // AddHeaders is used to add multiple headers at once func (email *Email) AddHeaders(headers textproto.MIMEHeader) *Email { if email.Error != nil { return email } for header, values := range headers { email.AddHeader(header, values...) } return email } // AddAlternative allows you to add alternative parts to the body // of the email message. This is most commonly used to add an // html version in addition to a plain text version that was // already added with SetBody. func (email *Email) AddAlternative(contentType ContentType, body string) *Email { if email.Error != nil { return email } email.parts = append(email.parts, part{ contentType: contentType.string(), body: bytes.NewBufferString(body), }, ) return email } // AddAlternativeData allows you to add alternative parts to the body // of the email message. This is most commonly used to add an // html version in addition to a plain text version that was // already added with SetBody. func (email *Email) AddAlternativeData(contentType ContentType, body []byte) *Email { if email.Error != nil { return email } email.parts = append(email.parts, part{ contentType: contentType.string(), body: bytes.NewBuffer(body), }, ) return email } // SetSmimeCertificate gives possibility to sign email message with SMIME // certificate func (email *Email) SetSmimeCertificate(certificate tls.Certificate) *Email { if email.Error != nil { return email } email.SmimeCertificate = &certificate return email } // GetFrom returns the sender of the email, if any func (email *Email) GetFrom() string { from := email.returnPath if from == "" { from = email.sender if from == "" { from = email.from if from == "" { from = email.replyTo } } } return from } // GetRecipients returns a slice of recipients emails func (email *Email) GetRecipients() []string { return email.recipients } func (email *Email) hasMixedPart() bool { return (len(email.parts) > 0 && len(email.attachments) > 0) || len(email.attachments) > 1 } func (email *Email) hasRelatedPart() bool { return (len(email.parts) > 0 && len(email.inlines) > 0) || len(email.inlines) > 1 } func (email *Email) hasAlternativePart() bool { return len(email.parts) > 1 } // GetMessage builds and returns the email message (RFC822 formatted message) func (email *Email) GetMessage() string { msg := newMessage(email) if email.hasMixedPart() { msg.openMultipart("mixed") } if email.hasRelatedPart() { msg.openMultipart("related") } if email.hasAlternativePart() { msg.openMultipart("alternative") } for _, part := range email.parts { msg.addBody(part.contentType, part.body.Bytes()) } if email.hasAlternativePart() { msg.closeMultipart() } msg.addFiles(email.inlines, true) if email.hasRelatedPart() { msg.closeMultipart() } msg.addFiles(email.attachments, false) if email.hasMixedPart() { msg.closeMultipart() } return msg.getHeaders() + msg.body.String() } // Send sends the composed email func (email *Email) Send(client *SMTPClient) error { return email.SendEnvelopeFrom(email.from, client) } // SendEnvelopeFrom sends the composed email with envelope // sender. 'from' must be an email address. func (email *Email) SendEnvelopeFrom(from string, client *SMTPClient) error { if email.Error != nil { return email.Error } if from == "" { from = email.from } if len(email.recipients) < 1 { return errors.New("Mail Error: No recipient specified") } var msg string if email.DkimMsg != "" { msg = email.DkimMsg } else { msg = email.GetMessage() } if email.SmimeCertificate != nil { SMIME, err := smime.New(*email.SmimeCertificate) if err != nil { return err } signedMsg, err := SMIME.Sign([]byte(msg)) if err != nil { return err } msg = string(signedMsg) } return send(from, email.recipients, msg, client) } // dial connects to the smtp server with the request encryption type func dial(host string, port string, encryption Encryption, config *tls.Config) (*smtpClient, error) { var conn net.Conn var err error address := host + ":" + port // do the actual dial switch encryption { // TODO: Remove EncryptionSSL check before launch v3 case EncryptionSSL, EncryptionSSLTLS: conn, err = tls.Dial("tcp", address, config) default: conn, err = net.Dial("tcp", address) } if err != nil { return nil, errors.New("Mail Error on dialing with encryption type " + encryption.String() + ": " + err.Error()) } c, err := newClient(conn, host) if err != nil { return nil, fmt.Errorf("Mail Error on smtp dial: %w", err) } return c, err } // smtpConnect connects to the smtp server and starts TLS and passes auth // if necessary func smtpConnect(host, port, helo string, a auth, at AuthType, encryption Encryption, config *tls.Config) (*smtpClient, error) { // connect to the mail server c, err := dial(host, port, encryption, config) if err != nil { return nil, err } if helo == "" { helo = "localhost" } // send Helo if err = c.hi(helo); err != nil { c.close() return nil, fmt.Errorf("Mail Error on Hello: %w", err) } // STARTTLS if necessary // TODO: Remove EncryptionTLS check before launch v3 if encryption == EncryptionTLS || encryption == EncryptionSTARTTLS { if ok, _ := c.extension("STARTTLS"); ok { if err = c.startTLS(config); err != nil { c.close() return nil, fmt.Errorf("Mail Error on STARTTLS: %w", err) } } } // only pass authentication if defined if at != AuthNone { // pass the authentication if necessary if a != nil { if ok, _ := c.extension("AUTH"); ok { if err = c.authenticate(a); err != nil { c.close() return nil, fmt.Errorf("Mail Error on Auth: %w", err) } } } } return c, nil } // Connect returns the smtp client func (server *SMTPServer) Connect() (*SMTPClient, error) { var a auth switch server.Authentication { case AuthPlain: if server.Username != "" || server.Password != "" { a = plainAuthfn("", server.Username, server.Password, server.Host) } case AuthLogin: if server.Username != "" || server.Password != "" { a = loginAuthfn("", server.Username, server.Password, server.Host) } case AuthCRAMMD5: if server.Username != "" || server.Password != "" { a = cramMD5Authfn(server.Username, server.Password) } } var smtpConnectChannel chan error var c *smtpClient var err error tlsConfig := server.TLSConfig if tlsConfig == nil { tlsConfig = &tls.Config{ServerName: server.Host} } // if there is a ConnectTimeout, setup the channel and do the connect under a goroutine if server.ConnectTimeout != 0 { smtpConnectChannel = make(chan error, 2) go func() { c, err = smtpConnect(server.Host, fmt.Sprintf("%d", server.Port), server.Helo, a, server.Authentication, server.Encryption, tlsConfig) // send the result smtpConnectChannel <- err }() // get the connect result or timeout result, which ever happens first select { case err = <-smtpConnectChannel: if err != nil { return nil, err } case <-time.After(server.ConnectTimeout): return nil, errors.New("Mail Error: SMTP Connection timed out") } } else { // no ConnectTimeout, just fire the connect c, err = smtpConnect(server.Host, fmt.Sprintf("%d", server.Port), server.Helo, a, server.Authentication, server.Encryption, tlsConfig) if err != nil { return nil, err } } return &SMTPClient{ Client: c, KeepAlive: server.KeepAlive, SendTimeout: server.SendTimeout, }, nil } // Reset send RSET command to smtp client func (smtpClient *SMTPClient) Reset() error { return smtpClient.Client.reset() } // Noop send NOOP command to smtp client func (smtpClient *SMTPClient) Noop() error { return smtpClient.Client.noop() } // Quit send QUIT command to smtp client func (smtpClient *SMTPClient) Quit() error { return smtpClient.Client.quit() } // Close closes the connection func (smtpClient *SMTPClient) Close() error { return smtpClient.Client.close() } // SendMessage sends a message (a RFC822 formatted message) // 'from' must be an email address, recipients must be a slice of email address func SendMessage(from string, recipients []string, msg string, client *SMTPClient) error { if from == "" { return errors.New("Mail Error: No From email specifier") } if len(recipients) < 1 { return errors.New("Mail Error: No recipient specified") } return send(from, recipients, msg, client) } // send does the low level sending of the email func send(from string, to []string, msg string, client *SMTPClient) error { //Check if client struct is not nil if client != nil { //Check if client is not nil if client.Client != nil { var smtpSendChannel chan error // if there is a SendTimeout, setup the channel and do the send under a goroutine if client.SendTimeout != 0 { smtpSendChannel = make(chan error, 1) go func(from string, to []string, msg string, c *smtpClient) { smtpSendChannel <- sendMailProcess(from, to, msg, c) }(from, to, msg, client.Client) } if client.SendTimeout == 0 { // no SendTimeout, just fire the sendMailProcess return sendMailProcess(from, to, msg, client.Client) } // get the send result or timeout result, which ever happens first select { case sendError := <-smtpSendChannel: checkKeepAlive(client) return sendError case <-time.After(client.SendTimeout): checkKeepAlive(client) return errors.New("Mail Error: SMTP Send timed out") } } } return errors.New("Mail Error: No SMTP Client Provided") } func sendMailProcess(from string, to []string, msg string, c *smtpClient) error { cmdArgs := make(map[string]string) if _, ok := c.ext["SIZE"]; ok { cmdArgs["SIZE"] = strconv.Itoa(len(msg)) } // Set the sender if err := c.mail(from, cmdArgs); err != nil { return err } // Set the recipients for _, address := range to { if err := c.rcpt(address); err != nil { return err } } // Send the data command w, err := c.data() if err != nil { return err } // write the message _, err = fmt.Fprint(w, msg) if err != nil { return err } err = w.Close() if err != nil { return err } return nil } // check if keepAlive for close or reset func checkKeepAlive(client *SMTPClient) { if client.KeepAlive { client.Client.reset() } else { client.Client.quit() client.Client.close() } }