package mail import ( "bytes" "crypto/tls" "encoding/base64" "errors" "fmt" "io/ioutil" "mime" "net" "net/mail" "net/textproto" "path/filepath" "time" ) // 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 } /* 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 ConnectTimeout time.Duration SendTimeout time.Duration Host string Port int KeepAlive bool } //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 } // file represents the files that can be added to the email message. type file struct { filename string mimeType string data []byte } type encryption int const ( // EncryptionNone uses no encryption when sending email EncryptionNone encryption = iota // EncryptionSSL sets encryption type to SSL when sending email EncryptionSSL // EncryptionTLS sets encryption type to TLS when sending email EncryptionTLS ) var encryptionTypes = [...]string{"None", "SSL", "TLS"} 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 ) var contentTypes = [...]string{"text/plain", "text/html"} 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 ) // 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, } return server } // 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 { address, err := mail.ParseAddress(addresses[i]) if err != nil { email.Error = errors.New("Mail Error: " + err.Error() + "; Header: [" + header + "] Address: [" + addresses[i] + "]") return email } // check for more than one address switch { case header == "From" && len(email.from) > 0: fallthrough 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": 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 } // 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 } // 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 } 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]) 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 } // AddAttachment allows you to add an attachment to the email message. // You can optionally provide a different name for the file. func (email *Email) AddAttachment(file string, name ...string) *Email { if email.Error != nil { return email } if len(name) > 1 { email.Error = errors.New("Mail Error: Attach can only have a file and an optional name") return email } email.Error = email.attach(file, false, name...) return email } // AddAttachmentBase64 allows you to add an attachment in base64 to the email message. // You need provide a name for the file. func (email *Email) AddAttachmentBase64(b64File string, name string) *Email { if email.Error != nil { return email } if len(name) < 1 || len(b64File) < 1 { email.Error = errors.New("Mail Error: Attach Base64 need have a base64 string and name") return email } email.Error = email.attachB64(b64File, name) return email } // AddInline allows you to add an inline attachment to the email message. // You can optionally provide a different name for the file. func (email *Email) AddInline(file string, name ...string) *Email { if email.Error != nil { return email } if len(name) > 1 { email.Error = errors.New("Mail Error: Inline can only have a file and an optional name") return email } email.Error = email.attach(file, true, name...) return email } // attach does the low level attaching of the files func (email *Email) attach(f string, inline bool, name ...string) error { // Get the file data data, err := ioutil.ReadFile(f) if err != nil { return errors.New("Mail Error: Failed to add file with following error: " + err.Error()) } // get the file mime type mimeType := mime.TypeByExtension(filepath.Ext(f)) if mimeType == "" { mimeType = "application/octet-stream" } // get the filename _, filename := filepath.Split(f) // if an alternative filename was provided, use that instead if len(name) == 1 { filename = name[0] } if inline { email.inlines = append(email.inlines, &file{ filename: filename, mimeType: mimeType, data: data, }) } else { email.attachments = append(email.attachments, &file{ filename: filename, mimeType: mimeType, data: data, }) } return nil } // attachB64 does the low level attaching of the files but decoding base64 instead have a filepath func (email *Email) attachB64(b64File string, name string) error { // decode the string dec, err := base64.StdEncoding.DecodeString(b64File) if err != nil { return errors.New("Mail Error: Failed to decode base64 attachment with following error: " + err.Error()) } // get the file mime type mimeType := mime.TypeByExtension(name) if mimeType == "" { mimeType = "application/octet-stream" } email.attachments = append(email.attachments, &file{ filename: name, mimeType: mimeType, data: dec, }) return nil } // 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 } 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 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 { if email.Error != nil { return email.Error } if len(email.recipients) < 1 { return errors.New("Mail Error: No recipient specified") } msg := email.GetMessage() return send(email.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 { case EncryptionSSL: conn, err = tls.Dial("tcp", address, config) default: conn, err = net.Dial("tcp", address) } if err != nil { return nil, errors.New("Mail Error on dailing with encryption type " + encryption.string() + ": " + err.Error()) } c, err := newClient(conn, host) if err != nil { return nil, errors.New("Mail Error on smtp dial: " + err.Error()) } return c, err } // smtpConnect connects to the smtp server and starts TLS and passes auth // if necessary func smtpConnect(host string, port string, a auth, 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 } // send Hello if err = c.hi("localhost"); err != nil { c.close() return nil, errors.New("Mail Error on Hello: " + err.Error()) } // start TLS if necessary if encryption == EncryptionTLS { if ok, _ := c.extension("STARTTLS"); ok { if config.ServerName == "" { config = &tls.Config{ServerName: host} } if err = c.startTLS(config); err != nil { c.close() return nil, errors.New("Mail Error on Start TLS: " + err.Error()) } } } // 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, errors.New("Mail Error on Auth: " + err.Error()) } } } 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 // 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), a, server.Encryption, new(tls.Config)) // send the result smtpConnectChannel <- err }() } if server.ConnectTimeout == 0 { // no ConnectTimeout, just fire the connect c, err = smtpConnect(server.Host, fmt.Sprintf("%d", server.Port), a, server.Encryption, new(tls.Config)) } else { // get the connect result or timeout result, which ever happens first select { case err = <-smtpConnectChannel: if err != nil { return nil, errors.New(err.Error()) } case <-time.After(server.ConnectTimeout): return nil, errors.New("Mail Error: SMTP Connection timed out") } } 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() } // 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 { // Set the sender if err := c.mail(from); 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() } }