go-simple-mail/email.go

972 lines
23 KiB
Go
Raw Permalink Normal View History

2023-06-11 20:30:12 +00:00
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()
}
}