git.sr.ht/~pingoo/stdx@v0.0.0-20240218134121-094174641f6e/email/email.go (about)

     1  // Package email provides an easy to use and hard to misuse email API
     2  package email
     3  
     4  import (
     5  	"bytes"
     6  	"encoding/base64"
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  	"mime"
    11  	"mime/multipart"
    12  	"mime/quotedprintable"
    13  	"net/mail"
    14  	"net/smtp"
    15  	"net/textproto"
    16  	"strings"
    17  	"time"
    18  
    19  	"git.sr.ht/~pingoo/stdx/crypto"
    20  )
    21  
    22  const (
    23  	// MaxLineLength is the maximum line length per RFC 2045
    24  	MaxLineLength = 76
    25  	// defaultContentType is the default Content-Type according to RFC 2045, section 5.2
    26  	defaultContentType = "text/plain; charset=us-ascii"
    27  )
    28  
    29  // ErrMissingBoundary is returned when there is no boundary given for a multipart entity
    30  var ErrMissingBoundary = errors.New("No boundary found for multipart entity")
    31  
    32  // ErrMissingContentType is returned when there is no "Content-Type" header for a MIME entity
    33  var ErrMissingContentType = errors.New("No Content-Type found for MIME entity")
    34  
    35  var defaultMailer *SMTPMailer
    36  
    37  // Email is an email...
    38  // either Text or HTML must be provided
    39  type Email struct {
    40  	ReplyTo     []mail.Address
    41  	From        mail.Address
    42  	To          []mail.Address
    43  	Bcc         []mail.Address
    44  	Cc          []mail.Address
    45  	Subject     string
    46  	Text        []byte // Plaintext message
    47  	HTML        []byte // Html message
    48  	Headers     textproto.MIMEHeader
    49  	Attachments []Attachment
    50  	// ReadReceipt []string
    51  }
    52  
    53  // Bytes returns the content of the email in the bytes form
    54  func (email *Email) Bytes() ([]byte, error) {
    55  	buffer := bytes.NewBuffer([]byte{})
    56  	hasAttachements := len(email.Attachments) > 0
    57  	isAlternative := len(email.Text) > 0 && len(email.HTML) > 0
    58  	var multipartWriter *multipart.Writer
    59  
    60  	headers, err := email.headers()
    61  	if err != nil {
    62  		return nil, err
    63  	}
    64  
    65  	if hasAttachements || isAlternative {
    66  		multipartWriter = multipart.NewWriter(buffer)
    67  	}
    68  	switch {
    69  	case hasAttachements:
    70  		headers.Set("Content-Type", "multipart/mixed;\r\n boundary="+multipartWriter.Boundary())
    71  	case isAlternative:
    72  		headers.Set("Content-Type", "multipart/alternative;\r\n boundary="+multipartWriter.Boundary())
    73  	case len(email.HTML) > 0:
    74  		headers.Set("Content-Type", "text/html; charset=UTF-8")
    75  		headers.Set("Content-Transfer-Encoding", "quoted-printable")
    76  	default:
    77  		headers.Set("Content-Type", "text/plain; charset=UTF-8")
    78  		headers.Set("Content-Transfer-Encoding", "quoted-printable")
    79  	}
    80  	headersToBytes(buffer, headers)
    81  	_, err = io.WriteString(buffer, "\r\n")
    82  	if err != nil {
    83  		return nil, err
    84  	}
    85  
    86  	// Check to see if there is a Text or HTML field
    87  	if len(email.Text) > 0 || len(email.HTML) > 0 {
    88  		var subWriter *multipart.Writer
    89  
    90  		if hasAttachements && isAlternative {
    91  			// Create the multipart alternative part
    92  			subWriter = multipart.NewWriter(buffer)
    93  			header := textproto.MIMEHeader{
    94  				"Content-Type": {"multipart/alternative;\r\n boundary=" + subWriter.Boundary()},
    95  			}
    96  			if _, err := multipartWriter.CreatePart(header); err != nil {
    97  				return nil, err
    98  			}
    99  		} else {
   100  			subWriter = multipartWriter
   101  		}
   102  		// Create the body sections
   103  		if len(email.Text) > 0 {
   104  			// Write the text
   105  			if err := writeMessage(buffer, email.Text, hasAttachements || isAlternative, "text/plain", subWriter); err != nil {
   106  				return nil, err
   107  			}
   108  		}
   109  		if len(email.HTML) > 0 {
   110  			// Write the HTML
   111  			if err := writeMessage(buffer, email.HTML, hasAttachements || isAlternative, "text/html", subWriter); err != nil {
   112  				return nil, err
   113  			}
   114  		}
   115  		if hasAttachements && isAlternative {
   116  			if err := subWriter.Close(); err != nil {
   117  				return nil, err
   118  			}
   119  		}
   120  	}
   121  	// Create attachment part, if necessary
   122  	for _, a := range email.Attachments {
   123  		ap, err := multipartWriter.CreatePart(a.Header)
   124  		if err != nil {
   125  			return nil, err
   126  		}
   127  		// Write the base64Wrapped content to the part
   128  		base64Wrap(ap, a.Content)
   129  	}
   130  	if hasAttachements || isAlternative {
   131  		if err := multipartWriter.Close(); err != nil {
   132  			return nil, err
   133  		}
   134  	}
   135  	return buffer.Bytes(), nil
   136  }
   137  
   138  func (email *Email) headers() (textproto.MIMEHeader, error) {
   139  	res := textproto.MIMEHeader{}
   140  
   141  	// Set default headers
   142  	if len(email.ReplyTo) > 0 {
   143  		res.Set("Reply-To", strings.Join(mailAddressesToStrings(email.ReplyTo), ", "))
   144  	}
   145  	if len(email.To) > 0 {
   146  		res.Set("To", strings.Join(mailAddressesToStrings(email.To), ", "))
   147  	}
   148  	if len(email.Cc) > 0 {
   149  		res.Set("Cc", strings.Join(mailAddressesToStrings(email.Cc), ", "))
   150  	}
   151  
   152  	res.Set("Subject", email.Subject)
   153  
   154  	id, err := generateMessageID()
   155  	if err != nil {
   156  		return nil, err
   157  	}
   158  	res.Set("Message-Id", id)
   159  
   160  	// Set required headers.
   161  	res.Set("From", email.From.String())
   162  	res.Set("Date", time.Now().Format(time.RFC1123Z))
   163  	res.Set("MIME-Version", "1.0")
   164  
   165  	// overwrite with user provided headers
   166  	for key, value := range email.Headers {
   167  		res[key] = value
   168  	}
   169  	return res, nil
   170  }
   171  
   172  // Attachment is an email attachment.
   173  // Based on the mime/multipart.FileHeader struct, Attachment contains the name, MIMEHeader, and content of the attachment in question
   174  type Attachment struct {
   175  	Filename string
   176  	Header   textproto.MIMEHeader
   177  	Content  []byte
   178  }
   179  
   180  // Mailer are used to send email
   181  type SMTPMailer struct {
   182  	smtpAuth    smtp.Auth
   183  	smtpAddress string
   184  }
   185  
   186  // SMTPConfig is used to configure an email
   187  type SMTPConfig struct {
   188  	Host     string
   189  	Port     uint16
   190  	Username string
   191  	Password string
   192  }
   193  
   194  // Send an email
   195  func (mailer *SMTPMailer) Send(email Email) error {
   196  	if len(email.HTML) == 0 && len(email.Text) == 0 {
   197  		return errors.New("email: either HTML or Text must be provided")
   198  	}
   199  
   200  	// Merge the To, Cc, and Bcc fields
   201  	to := make([]mail.Address, 0, len(email.To)+len(email.Cc)+len(email.Bcc))
   202  	to = append(to, email.To...)
   203  	to = append(to, email.Bcc...)
   204  	to = append(to, email.Cc...)
   205  
   206  	// Check to make sure there is at least one recipient
   207  	if len(to) == 0 {
   208  		return errors.New("email: Must specify at least one From address and one To address")
   209  	}
   210  
   211  	rawEmail, err := email.Bytes()
   212  	if err != nil {
   213  		return err
   214  	}
   215  
   216  	toAddresses := make([]string, len(to))
   217  	for i, recipient := range to {
   218  		toAddresses[i] = recipient.Address
   219  	}
   220  
   221  	return smtp.SendMail(mailer.smtpAddress, mailer.smtpAuth, email.From.Address, toAddresses, rawEmail)
   222  }
   223  
   224  // NewMailer returns a new mailer
   225  func NewSMTPMailer(config SMTPConfig) SMTPMailer {
   226  	smtpAuth := smtp.PlainAuth("", config.Username, config.Password, config.Host)
   227  	return SMTPMailer{
   228  		smtpAuth:    smtpAuth,
   229  		smtpAddress: fmt.Sprintf("%s:%d", config.Host, config.Port),
   230  	}
   231  }
   232  
   233  // InitDefaultMailer set the default, global mailer
   234  func InitDefaultMailer(config SMTPConfig) {
   235  	mailer := NewSMTPMailer(config)
   236  	defaultMailer = &mailer
   237  }
   238  
   239  // Send an email using the default mailer
   240  func Send(email Email) error {
   241  	if defaultMailer == nil {
   242  		return errors.New("email: defaultMailer has not been initialized")
   243  	}
   244  	return defaultMailer.Send(email)
   245  }
   246  
   247  // headersToBytes renders "header" to "buff". If there are multiple values for a
   248  // field, multiple "Field: value\r\n" lines will be emitted.
   249  func headersToBytes(buff io.Writer, headers textproto.MIMEHeader) {
   250  	for field, vals := range headers {
   251  		for _, subval := range vals {
   252  			// bytes.Buffer.Write() never returns an error.
   253  			io.WriteString(buff, field)
   254  			io.WriteString(buff, ": ")
   255  			// Write the encoded header if needed
   256  			switch {
   257  			case field == "Content-Type" || field == "Content-Disposition":
   258  				buff.Write([]byte(subval))
   259  			default:
   260  				buff.Write([]byte(mime.QEncoding.Encode("UTF-8", subval)))
   261  			}
   262  			io.WriteString(buff, "\r\n")
   263  		}
   264  	}
   265  }
   266  
   267  // generateMessageID generates and returns a string suitable for an RFC 2822
   268  // compliant Message-ID, email.g.:
   269  // <1444789264909237300.3464.1819418242800517193@DESKTOP01>
   270  //
   271  // The following parameters are used to generate a Message-ID:
   272  // - The nanoseconds since Epoch
   273  // - The calling PID
   274  // - A cryptographically random int64
   275  // - The sending hostname
   276  func generateMessageID() (string, error) {
   277  	t := time.Now().UnixNano()
   278  	pid, err := crypto.RandInt64(999)
   279  	if err != nil {
   280  		return "", err
   281  	}
   282  	rint, err := crypto.RandInt64(999)
   283  	if err != nil {
   284  		return "", err
   285  	}
   286  	if err != nil {
   287  		return "", err
   288  	}
   289  	hostname := "localhost.localdomain"
   290  	msgid := fmt.Sprintf("<%d.%d.%d@%s>", t, pid, rint, hostname)
   291  	return msgid, nil
   292  }
   293  
   294  func writeMessage(buffer io.Writer, msg []byte, multipart bool, mediaType string, w *multipart.Writer) error {
   295  	if multipart {
   296  		header := textproto.MIMEHeader{
   297  			"Content-Type":              {mediaType + "; charset=UTF-8"},
   298  			"Content-Transfer-Encoding": {"quoted-printable"},
   299  		}
   300  		if _, err := w.CreatePart(header); err != nil {
   301  			return err
   302  		}
   303  	}
   304  
   305  	qp := quotedprintable.NewWriter(buffer)
   306  	// Write the text
   307  	if _, err := qp.Write(msg); err != nil {
   308  		return err
   309  	}
   310  	return qp.Close()
   311  }
   312  
   313  // base64Wrap encodes the attachment content, and wraps it according to RFC 2045 standards (every 76 chars)
   314  // The output is then written to the specified io.Writer
   315  func base64Wrap(writer io.Writer, b []byte) {
   316  	// 57 raw bytes per 76-byte base64 linemail.
   317  	const maxRaw = 57
   318  	// Buffer for each line, including trailing CRLF.
   319  	buffer := make([]byte, MaxLineLength+len("\r\n"))
   320  	copy(buffer[MaxLineLength:], "\r\n")
   321  	// Process raw chunks until there's no longer enough to fill a linemail.
   322  	for len(b) >= maxRaw {
   323  		base64.StdEncoding.Encode(buffer, b[:maxRaw])
   324  		writer.Write(buffer)
   325  		b = b[maxRaw:]
   326  	}
   327  	// Handle the last chunk of bytes.
   328  	if len(b) > 0 {
   329  		out := buffer[:base64.StdEncoding.EncodedLen(len(b))]
   330  		base64.StdEncoding.Encode(out, b)
   331  		out = append(out, "\r\n"...)
   332  		writer.Write(out)
   333  	}
   334  }
   335  
   336  func mailAddressesToStrings(addresses []mail.Address) []string {
   337  	ret := make([]string, len(addresses))
   338  
   339  	for i, address := range addresses {
   340  		ret[i] = address.String()
   341  	}
   342  	return ret
   343  }