From 77970e3c916e362c348b3d3460fcdb49fb104237 Mon Sep 17 00:00:00 2001 From: miajio Date: Mon, 18 Dec 2023 09:58:15 +0800 Subject: [PATCH] code writing and migration --- doc.go | 4 + go.mod | 3 + mail/attachment.go | 40 ++++ mail/email.go | 582 +++++++++++++++++++++++++++++++++++++++++++++ mail/parse.go | 91 +++++++ mail/pool.go | 356 +++++++++++++++++++++++++++ mail/tool.go | 135 +++++++++++ mail/trim.go | 39 +++ 8 files changed, 1250 insertions(+) create mode 100644 doc.go create mode 100644 go.mod create mode 100644 mail/attachment.go create mode 100644 mail/email.go create mode 100644 mail/parse.go create mode 100644 mail/pool.go create mode 100644 mail/tool.go create mode 100644 mail/trim.go diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..0eee5d6 --- /dev/null +++ b/doc.go @@ -0,0 +1,4 @@ +/* +Package gomail basic jordan-wright/email wrapper +*/ +package mail // import "github.com/miajio/goemail" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2acfc41 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/miajio/goemail + +go 1.21.3 diff --git a/mail/attachment.go b/mail/attachment.go new file mode 100644 index 0000000..1664ac4 --- /dev/null +++ b/mail/attachment.go @@ -0,0 +1,40 @@ +package mail + +import ( + "fmt" + "net/textproto" +) + +// Attachment +// is email attachement param type +// Based on the mime/multipart.FileHeader struct +// Attachment contains the name, MIMEHeader, and content of the attachment in question +type Attachment struct { + FileName string // fileName + ContentType string // content type + Header textproto.MIMEHeader // header + Content []byte // content + HTMLRelated bool +} + +func (at *Attachment) setDefaultHeaders() { + contentType := "application/octet-stream" + if len(at.ContentType) > 0 { + contentType = at.ContentType + } + at.Header.Set("Content-Type", contentType) + + if len(at.Header.Get("Content-Disposition")) == 0 { + disposition := "attachment" + if at.HTMLRelated { + disposition = "inline" + } + at.Header.Set("Content-Disposition", fmt.Sprintf("%s;\r\n filename=\"%s\"", disposition, at.FileName)) + } + if len(at.Header.Get("Content-ID")) == 0 { + at.Header.Set("Content-ID", fmt.Sprintf("<%s>", at.FileName)) + } + if len(at.Header.Get("Content-Transfer-Encoding")) == 0 { + at.Header.Set("Content-Transfer-Encoding", "base64") + } +} diff --git a/mail/email.go b/mail/email.go new file mode 100644 index 0000000..8f0a4dc --- /dev/null +++ b/mail/email.go @@ -0,0 +1,582 @@ +package mail + +import ( + "bytes" + "crypto/tls" + "errors" + "io" + "mime" + "mime/multipart" + "net/mail" + "net/smtp" + "net/textproto" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + MaxLineLength = 76 // MaxLineLength is the maximum line length pre RFC 2045 + DefaultContentType = "text/plain; charset=us-ascii" // email.ContentType is email default Content-Type according to RFC 2045, section 5.2 + + StrFileNameParam = "filename" // mime request file name param + + CONTENT_TYPE = "Content-Type" + CONTENT_TRANSFER_ENCODING = "Content-Transfer-Encoding" + BASE_64 = "base64" + QUOTED_PRINTABLE = "quoted-printable" + MULTIPART = "multipart/" + BOUNDARY = "boundary" + StrContentDisposition = "Content-Disposition" + StrReplyTo = "Reply-To" + StrSubject = "Subject" + StrTo = "To" + StrCc = "Cc" + StrBcc = "Bcc" + StrFrom = "From" + StrDate = "Date" + StrMessageId = "Message-Id" + StrMimeVersion = "MIME-Version" +) + +var ( + // ErrMissingBoundary is returned when there is no boundary given for multipart entity + ErrMissingBoundary = errors.New("no boundary found for multipart entity") + + // ErrMissingContentType is returned when there is no "Content-Type" header for a MIME entity + ErrMissingContentType = errors.New("no Content-Type found for MIME entity") + + // ErrMustSpecifyMessage is returned when there is email param exist empty + ErrMustSpecifyMessage = errors.New("must specify at least one from address and one to address") +) + +// Email +// used for email message type +type Email struct { + ReplyTo []string // reply to + From string //from + To []string // to + Bcc []string // bcc + Cc []string // cc + Subject string // subject + Text []byte // text plaintext message (optional) + Html []byte // html message (optional) + Sender string // override from as SMTP envelope sender (optional) + Headers textproto.MIMEHeader // headers + Attachments []*Attachment // attachments + ReadReceipt []string // read receipt + +} + +// New +// create an Email, and returns the pointer to it. +func New() *Email { + return &Email{ + Headers: textproto.MIMEHeader{}, + } +} + +// NewEmailFromReader reads a stream of bytes from an io.Reader, r, +// and returns an email struct containing the parsed data. +// This function expects the data in RFC 5322 format. +func NewEmailFromReader(r io.Reader) (*Email, error) { + em := New() + tp := TpReader(r) + // parse tp headers + hdrs, err := tp.ReadMIMEHeader() + if err != nil { + return nil, err + } + // set subject, to, cc, bcc and from + for h, v := range hdrs { + switch h { + case StrSubject: + em.Subject = v[0] + subj, err := (&mime.WordDecoder{}).DecodeHeader(em.Subject) + if err == nil && len(subj) > 0 { + em.Subject = subj + } + case StrTo: + em.To = handleAddressList(v) + case StrCc: + em.Cc = handleAddressList(v) + case StrBcc: + em.Bcc = handleAddressList(v) + case StrReplyTo: + em.ReplyTo = handleAddressList(v) + case StrFrom: + em.From = v[0] + fr, err := (&mime.WordDecoder{}).DecodeHeader(em.From) + if err == nil && len(fr) > 0 { + em.From = fr + } + } + delete(hdrs, h) + } + em.Headers = hdrs + body := tp.R + // recursively parse the MIME parts + ps, err := parseMIMEParts(em.Headers, body) + if err != nil { + return em, err + } + for _, p := range ps { + headerContentType := p.header.Get(CONTENT_TYPE) + if headerContentType == "" { + return em, ErrMissingContentType + } + ct, _, err := mime.ParseMediaType(headerContentType) + if err != nil { + return em, err + } + + if cd := p.header.Get(StrContentDisposition); cd != "" { + cd, params, err := mime.ParseMediaType(p.header.Get(StrContentDisposition)) + if err != nil { + return em, err + } + fileName, fileNameDefined := params[StrFileNameParam] + if cd == "attachment" || (cd == "inline" && fileNameDefined) { + _, err := em.Attach(bytes.NewReader(p.body), fileName, ct) + if err != nil { + return em, err + } + continue + } + } + + switch ct { + case "text/plain": + em.Text = p.body + case "text/html": + em.Html = p.body + } + } + return em, nil +} + +// Attach is used to attach content from an io.Reader to the email. +// Required params include an io.Reader, the desired fileName for the attachment, and the Content-Type +// the func will return the create Attachment for reference, as well as nil for the error, if successful. +func (e *Email) Attach(r io.Reader, fileName string, contentType string) (a *Attachment, err error) { + return e.AttachWithHeaders(r, fileName, contentType, textproto.MIMEHeader{}) +} + +// AttachWithHeaders is used to attach content from an io.Reader to the email. Required parameters include an io.Reader, +// the desired filename for the attachment, the Content-Type and the original MIME headers. +// The function will return the created Attachment for reference, as well as nil for the error, if successful. +func (e *Email) AttachWithHeaders(r io.Reader, fileName string, contentType string, headers textproto.MIMEHeader) (a *Attachment, err error) { + var buffer bytes.Buffer + if _, err = io.Copy(&buffer, r); err != nil { + return + } + at := &Attachment{ + FileName: fileName, + ContentType: contentType, + Header: headers, + Content: buffer.Bytes(), + } + e.Attachments = append(e.Attachments, at) + return at, nil +} + +// AttachFile is used to attach content to the email. +// it attempts to open the file referenced by fileName and, if successful, creates an attachment. +// this attachment is then appended to the slice of e.Attachments. +// the func will then return the Attachment for reference, as well as nil for the error, if successful. +func (e *Email) AttachFile(fileName string) (*Attachment, error) { + f, err := os.Open(fileName) + if err != nil { + return nil, err + } + defer f.Close() + + ct := mime.TypeByExtension(filepath.Ext(fileName)) + basename := filepath.Base(fileName) + return e.Attach(f, basename, ct) +} + +// messageHeaders merges the email's various fields and custom headers together in a standards +// create a MIMEHeader to be used in the result message. +// it does not alter e.Headers. +// "e"'s fields To, Cc, From, Subject will be used unless they are present in e.Headers. +// Unless set in e.Headers, "Date" will filled with the current time. +func (e *Email) messageHeaders() (textproto.MIMEHeader, error) { + res := make(textproto.MIMEHeader, len(e.Headers)+6) + if e.Headers != nil { + for _, h := range []string{StrReplyTo, StrTo, StrCc, StrFrom, StrSubject, StrDate, StrMessageId, StrMimeVersion} { + if v, ok := e.Headers[h]; ok { + res[h] = v + } + } + } + // Set headers if there are values. + if _, ok := res[StrReplyTo]; !ok && len(e.ReplyTo) > 0 { + res.Set(StrReplyTo, strings.Join(e.ReplyTo, ", ")) + } + if _, ok := res[StrTo]; !ok && len(e.To) > 0 { + res.Set(StrTo, strings.Join(e.To, ", ")) + } + if _, ok := res[StrCc]; !ok && len(e.Cc) > 0 { + res.Set(StrCc, strings.Join(e.Cc, ", ")) + } + if _, ok := res[StrSubject]; !ok && e.Subject != "" { + res.Set(StrSubject, e.Subject) + } + if _, ok := res[StrMessageId]; !ok { + id, err := generateMessageID() + if err != nil { + return nil, err + } + res.Set(StrMessageId, id) + } + // Date and From are required headers. + if _, ok := res[StrFrom]; !ok { + res.Set(StrFrom, e.From) + } + if _, ok := res[StrDate]; !ok { + res.Set(StrDate, time.Now().Format(time.RFC1123Z)) + } + if _, ok := res[StrMimeVersion]; !ok { + res.Set(StrMimeVersion, "1.0") + } + for field, vals := range e.Headers { + if _, ok := res[field]; !ok { + res[field] = vals + } + } + return res, nil +} + +func (e *Email) categorizeAttachments() (htmlRelated, others []*Attachment) { + for _, a := range e.Attachments { + if a.HTMLRelated { + htmlRelated = append(htmlRelated, a) + } else { + others = append(others, a) + } + } + return +} + +// Bytes converts the Email object to []byte +// including all needed MIMEHeaders, boundaries, etc. +func (e *Email) Bytes() ([]byte, error) { + // better guess buffer size + buff := bytes.NewBuffer(make([]byte, 0, 4096)) + + headers, err := e.messageHeaders() + if err != nil { + return nil, err + } + + htmlAttachments, otherAttachments := e.categorizeAttachments() + if len(e.Html) == 0 && len(htmlAttachments) > 0 { + return nil, errors.New("there are HTML attachments, but no HTML body") + } + + var ( + isMixed = len(otherAttachments) > 0 + isAlternative = len(e.Text) > 0 && len(e.Html) > 0 + isRelated = len(e.Html) > 0 && len(htmlAttachments) > 0 + ) + + var w *multipart.Writer + if isMixed || isAlternative || isRelated { + w = multipart.NewWriter(buff) + } + switch { + case isMixed: + headers.Set(CONTENT_TYPE, "multipart/mixed;\r\n boundary="+w.Boundary()) + case isAlternative: + headers.Set(CONTENT_TYPE, "multipart/alternative;\r\n boundary="+w.Boundary()) + case isRelated: + headers.Set(CONTENT_TYPE, "multipart/related;\r\n boundary="+w.Boundary()) + case len(e.Html) > 0: + headers.Set(CONTENT_TYPE, "text/html; charset=UTF-8") + headers.Set(CONTENT_TRANSFER_ENCODING, QUOTED_PRINTABLE) + default: + headers.Set(CONTENT_TYPE, "text/plain; charset=UTF-8") + headers.Set(CONTENT_TRANSFER_ENCODING, QUOTED_PRINTABLE) + } + headerToBytes(buff, headers) + _, err = io.WriteString(buff, "\r\n") + if err != nil { + return nil, err + } + + // Check to see if there is a Text or HTML field + if len(e.Text) > 0 || len(e.Html) > 0 { + var subWriter *multipart.Writer + + if isMixed && isAlternative { + // Create the multipart alternative part + subWriter = multipart.NewWriter(buff) + header := textproto.MIMEHeader{ + CONTENT_TYPE: {"multipart/alternative;\r\n boundary=" + subWriter.Boundary()}, + } + if _, err := w.CreatePart(header); err != nil { + return nil, err + } + } else { + subWriter = w + } + // Create the body sections + if len(e.Text) > 0 { + // Write the text + if err := writeMessage(buff, e.Text, isMixed || isAlternative, "text/plain", subWriter); err != nil { + return nil, err + } + } + if len(e.Html) > 0 { + messageWriter := subWriter + var relatedWriter *multipart.Writer + if (isMixed || isAlternative) && len(htmlAttachments) > 0 { + relatedWriter = multipart.NewWriter(buff) + header := textproto.MIMEHeader{ + CONTENT_TYPE: {"multipart/related;\r\n boundary=" + relatedWriter.Boundary()}, + } + if _, err := subWriter.CreatePart(header); err != nil { + return nil, err + } + + messageWriter = relatedWriter + } else if isRelated && len(htmlAttachments) > 0 { + relatedWriter = w + messageWriter = w + } + // Write the HTML + if err := writeMessage(buff, e.Html, isMixed || isAlternative || isRelated, "text/html", messageWriter); err != nil { + return nil, err + } + if len(htmlAttachments) > 0 { + for _, a := range htmlAttachments { + a.setDefaultHeaders() + ap, err := relatedWriter.CreatePart(a.Header) + if err != nil { + return nil, err + } + // Write the base64Wrapped content to the part + base64Wrap(ap, a.Content) + } + + if isMixed || isAlternative { + relatedWriter.Close() + } + } + } + if isMixed && isAlternative { + if err := subWriter.Close(); err != nil { + return nil, err + } + } + } + // Create attachment part, if necessary + for _, a := range otherAttachments { + a.setDefaultHeaders() + ap, err := w.CreatePart(a.Header) + if err != nil { + return nil, err + } + // Write the base64Wrapped content to the part + base64Wrap(ap, a.Content) + } + if isMixed || isAlternative || isRelated { + if err := w.Close(); err != nil { + return nil, err + } + } + return buff.Bytes(), nil +} + +// Select and parse an SMTP envelope sender address. Choose Email.Sender if set, or fallback to Email.From. +func (e *Email) parseSender() (string, error) { + if e.Sender != "" { + sender, err := mail.ParseAddress(e.Sender) + if err != nil { + return "", err + } + return sender.Address, nil + } else { + from, err := mail.ParseAddress(e.From) + if err != nil { + return "", err + } + return from.Address, nil + } +} + +// Send an email using the given host and SMTP auth (optional), returns any error thrown by smtp.SendMail +// This function merges the To, Cc, and Bcc fields and calls the smtp.SendMail function using the Email.Bytes() output as the message +func (e *Email) Send(addr string, a smtp.Auth) error { + // Merge the To, Cc, and Bcc fields + to := make([]string, 0, len(e.To)+len(e.Cc)+len(e.Bcc)) + to = append(append(append(to, e.To...), e.Cc...), e.Bcc...) + for i := 0; i < len(to); i++ { + addr, err := mail.ParseAddress(to[i]) + if err != nil { + return err + } + to[i] = addr.Address + } + // Check to make sure there is at least one recipient and one "From" address + if e.From == "" || len(to) == 0 { + return ErrMustSpecifyMessage + } + sender, err := e.parseSender() + if err != nil { + return err + } + raw, err := e.Bytes() + if err != nil { + return err + } + return smtp.SendMail(addr, a, sender, to, raw) +} + +// SendWithTLS sends an email with an optional TLS config. +// This is helpful if you need to connect to a host that is used an untrusted +// certificate. +func (e *Email) SendWithTLS(addr string, auth smtp.Auth, tlsConfig *tls.Config) error { + // Merge the To, Cc, and Bcc fields + to := make([]string, 0, len(e.To)+len(e.Cc)+len(e.Bcc)) + to = append(append(append(to, e.To...), e.Cc...), e.Bcc...) + for i := 0; i < len(to); i++ { + addr, err := mail.ParseAddress(to[i]) + if err != nil { + return err + } + to[i] = addr.Address + } + // Check to make sure there is at least one recipient and one "From" address + if e.From == "" || len(to) == 0 { + return ErrMustSpecifyMessage + } + sender, err := e.parseSender() + if err != nil { + return err + } + raw, err := e.Bytes() + if err != nil { + return err + } + + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return err + } + + c, err := smtp.NewClient(conn, tlsConfig.ServerName) + if err != nil { + return err + } + defer c.Close() + if err = c.Hello("localhost"); err != nil { + return err + } + + if auth != nil { + if ok, _ := c.Extension("AUTH"); ok { + if err = c.Auth(auth); err != nil { + return err + } + } + } + if err = c.Mail(sender); err != nil { + return err + } + for _, addr := range to { + if err = c.Rcpt(addr); err != nil { + return err + } + } + w, err := c.Data() + if err != nil { + return err + } + _, err = w.Write(raw) + if err != nil { + return err + } + err = w.Close() + if err != nil { + return err + } + return c.Quit() +} + +// SendWithStartTLS sends an email over TLS using STARTTLS with an optional TLS config. +// +// The TLS Config is helpful if you need to connect to a host that is used an untrusted +// certificate. +func (e *Email) SendWithStartTLS(addr string, auth smtp.Auth, tlsConfig *tls.Config) error { + // Merge the To, Cc, and Bcc fields + to := make([]string, 0, len(e.To)+len(e.Cc)+len(e.Bcc)) + to = append(append(append(to, e.To...), e.Cc...), e.Bcc...) + for i := 0; i < len(to); i++ { + addr, err := mail.ParseAddress(to[i]) + if err != nil { + return err + } + to[i] = addr.Address + } + // Check to make sure there is at least one recipient and one "From" address + if e.From == "" || len(to) == 0 { + return ErrMustSpecifyMessage + } + sender, err := e.parseSender() + if err != nil { + return err + } + raw, err := e.Bytes() + if err != nil { + return err + } + + // Taken from the standard library + // https://github.com/golang/go/blob/master/src/net/smtp/smtp.go#L328 + c, err := smtp.Dial(addr) + if err != nil { + return err + } + defer c.Close() + if err = c.Hello("localhost"); err != nil { + return err + } + // Use TLS if available + if ok, _ := c.Extension("STARTTLS"); ok { + if err = c.StartTLS(tlsConfig); err != nil { + return err + } + } + + if auth != nil { + if ok, _ := c.Extension("AUTH"); ok { + if err = c.Auth(auth); err != nil { + return err + } + } + } + if err = c.Mail(sender); err != nil { + return err + } + for _, addr := range to { + if err = c.Rcpt(addr); err != nil { + return err + } + } + w, err := c.Data() + if err != nil { + return err + } + _, err = w.Write(raw) + if err != nil { + return err + } + err = w.Close() + if err != nil { + return err + } + return c.Quit() +} diff --git a/mail/parse.go b/mail/parse.go new file mode 100644 index 0000000..215d85c --- /dev/null +++ b/mail/parse.go @@ -0,0 +1,91 @@ +package mail + +import ( + "bytes" + "encoding/base64" + "io" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/textproto" + "strings" +) + +// part +// copyable representation of a multipart.Part +type part struct { + header textproto.MIMEHeader + body []byte +} + +// parseMIMEParts will recursively walk a MIME entity and return a []mime.Part containing +// each (flattened) mime.Part found. +// note: there are no restrictions on recursion +func parseMIMEParts(hs textproto.MIMEHeader, b io.Reader) ([]*part, error) { + var ps []*part + // If no content type is given, set it to the default + if _, ok := hs[CONTENT_TYPE]; !ok { + hs.Set(CONTENT_TYPE, DefaultContentType) + } + ct, params, err := mime.ParseMediaType(hs.Get(CONTENT_TYPE)) + if err != nil { + return ps, err + } + // If it's a multipart email, recursively parse the parts + if strings.HasPrefix(ct, MULTIPART) { + if _, ok := params[BOUNDARY]; !ok { + return ps, ErrMissingBoundary + } + mr := multipart.NewReader(b, params[BOUNDARY]) + for { + var buf bytes.Buffer + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + return ps, err + } + if _, ok := p.Header[CONTENT_TYPE]; !ok { + p.Header.Set(CONTENT_TYPE, DefaultContentType) + } + subct, _, err := mime.ParseMediaType(p.Header.Get(CONTENT_TYPE)) + if err != nil { + return ps, err + } + if strings.HasPrefix(subct, MULTIPART) { + sps, err := parseMIMEParts(p.Header, p) + if err != nil { + return ps, err + } + ps = append(ps, sps...) + } else { + var reader io.Reader + reader = p + if p.Header.Get(CONTENT_TRANSFER_ENCODING) == BASE_64 { + reader = base64.NewDecoder(base64.StdEncoding, reader) + } + // Otherwise, just append the part to the list + // Copy the part data into the buffer + if _, err := io.Copy(&buf, reader); err != nil { + return ps, err + } + ps = append(ps, &part{body: buf.Bytes(), header: p.Header}) + } + } + } else { + // If it is not a multipart email, parse the body content as a single "part" + switch hs.Get(CONTENT_TRANSFER_ENCODING) { + case QUOTED_PRINTABLE: + b = quotedprintable.NewReader(b) + case BASE_64: + b = base64.NewDecoder(base64.StdEncoding, b) + } + var buf bytes.Buffer + if _, err := io.Copy(&buf, b); err != nil { + return ps, err + } + ps = append(ps, &part{body: buf.Bytes(), header: hs}) + } + return ps, nil +} diff --git a/mail/pool.go b/mail/pool.go new file mode 100644 index 0000000..e4f9e54 --- /dev/null +++ b/mail/pool.go @@ -0,0 +1,356 @@ +package mail + +import ( + "crypto/tls" + "errors" + "io" + "net" + "net/mail" + "net/smtp" + "net/textproto" + "sync/atomic" + "syscall" + "time" +) + +type Pool struct { + addr string + auth smtp.Auth + max int + created int32 + clients chan *client + rebuild chan struct{} + lastBuildErr *timestampedErr + closing chan struct{} + tlsConfig *tls.Config + helloHostname string +} + +type client struct { + *smtp.Client + failCount int +} + +type timestampedErr struct { + err error + ts time.Time +} + +const maxFails = 4 + +var ( + ErrClosed = errors.New("pool closed") + ErrTimeout = errors.New("timed out") +) + +func NewPool(address string, count int, auth smtp.Auth, optTlsConfig ...*tls.Config) (pool *Pool, err error) { + pool = &Pool{ + addr: address, + auth: auth, + max: count, + clients: make(chan *client, count), + rebuild: make(chan struct{}), + closing: make(chan struct{}), + } + if len(optTlsConfig) == 1 { + pool.tlsConfig = optTlsConfig[0] + } else if host, _, e := net.SplitHostPort(address); e != nil { + return nil, e + } else { + pool.tlsConfig = &tls.Config{ServerName: host} + } + return +} + +// go1.1 didn't have this method +func (c *client) Close() error { + return c.Text.Close() +} + +// SetHelloHostname optionally sets the hostname that the Go smtp.Client will +// use when doing a HELLO with the upstream SMTP server. By default, Go uses +// "localhost" which may not be accepted by certain SMTP servers that demand +// an FQDN. +func (p *Pool) SetHelloHostname(h string) { + p.helloHostname = h +} + +func (p *Pool) get(timeout time.Duration) *client { + select { + case c := <-p.clients: + return c + default: + } + + if int(atomic.LoadInt32(&p.created)) < p.max { + p.makeOne() + } + + var deadline <-chan time.Time + if timeout >= 0 { + deadline = time.After(timeout) + } + + for { + select { + case c := <-p.clients: + return c + case <-p.rebuild: + p.makeOne() + case <-deadline: + return nil + case <-p.closing: + return nil + } + } +} + +func shouldReuse(err error) bool { + // certainly not perfect, but might be close: + // - EOF: clearly, the connection went down + // - textproto.Errors were valid SMTP over a valid connection, + // but resulted from an SMTP error response + // - textproto.ProtocolErrors result from connections going down, + // invalid SMTP, that sort of thing + // - syscall.Errno is probably down connection/bad pipe, but + // passed straight through by textproto instead of becoming a + // ProtocolError + // - if we don't recognize the error, don't reuse the connection + // A false positive will probably fail on the Reset(), and even if + // not will eventually hit maxFails. + // A false negative will knock over (and trigger replacement of) a + // conn that might have still worked. + if err == io.EOF { + return false + } + switch err.(type) { + case *textproto.Error: + return true + case *textproto.ProtocolError, textproto.ProtocolError: + return false + case syscall.Errno: + return false + default: + return false + } +} + +func (p *Pool) replace(c *client) { + p.clients <- c +} + +func (p *Pool) inc() bool { + if int(atomic.LoadInt32(&p.created)) >= p.max { + return false + } + atomic.AddInt32(&p.created, 1) + return true +} + +func (p *Pool) dec() { + atomic.AddInt32(&p.created, -1) + + select { + case p.rebuild <- struct{}{}: + default: + } +} + +func (p *Pool) makeOne() { + go func() { + if p.inc() { + if c, err := p.build(); err == nil { + p.clients <- c + } else { + p.lastBuildErr = ×tampedErr{err, time.Now()} + p.dec() + } + } + }() +} + +func startTLS(c *client, t *tls.Config) (bool, error) { + if ok, _ := c.Extension("STARTTLS"); !ok { + return false, nil + } + + if err := c.StartTLS(t); err != nil { + return false, err + } + + return true, nil +} + +func addAuth(c *client, auth smtp.Auth) (bool, error) { + if ok, _ := c.Extension("AUTH"); !ok { + return false, nil + } + + if err := c.Auth(auth); err != nil { + return false, err + } + + return true, nil +} + +func (p *Pool) build() (*client, error) { + cl, err := smtp.Dial(p.addr) + if err != nil { + return nil, err + } + + // Is there a custom hostname for doing a HELLO with the SMTP server? + if p.helloHostname != "" { + cl.Hello(p.helloHostname) + } + + c := &client{cl, 0} + + if _, err := startTLS(c, p.tlsConfig); err != nil { + c.Close() + return nil, err + } + + if p.auth != nil { + if _, err := addAuth(c, p.auth); err != nil { + c.Close() + return nil, err + } + } + + return c, nil +} + +func (p *Pool) maybeReplace(err error, c *client) { + if err == nil { + c.failCount = 0 + p.replace(c) + return + } + + c.failCount++ + if c.failCount >= maxFails { + goto shutdown + } + + if !shouldReuse(err) { + goto shutdown + } + + if err := c.Reset(); err != nil { + goto shutdown + } + + p.replace(c) + return + +shutdown: + p.dec() + c.Close() +} + +func (p *Pool) failedToGet(startTime time.Time) error { + select { + case <-p.closing: + return ErrClosed + default: + } + + if p.lastBuildErr != nil && startTime.Before(p.lastBuildErr.ts) { + return p.lastBuildErr.err + } + + return ErrTimeout +} + +// Send sends an email via a connection pulled from the Pool. The timeout may +// be <0 to indicate no timeout. Otherwise reaching the timeout will produce +// and error building a connection that occurred while we were waiting, or +// otherwise ErrTimeout. +func (p *Pool) Send(e *Email, timeout time.Duration) (err error) { + start := time.Now() + c := p.get(timeout) + if c == nil { + return p.failedToGet(start) + } + + defer func() { + p.maybeReplace(err, c) + }() + + recipients, err := addressLists(e.To, e.Cc, e.Bcc) + if err != nil { + return + } + + msg, err := e.Bytes() + if err != nil { + return + } + + from, err := emailOnly(e.From) + if err != nil { + return + } + if err = c.Mail(from); err != nil { + return + } + + for _, recip := range recipients { + if err = c.Rcpt(recip); err != nil { + return + } + } + + w, err := c.Data() + if err != nil { + return + } + if _, err = w.Write(msg); err != nil { + return + } + + err = w.Close() + + return +} + +func emailOnly(full string) (string, error) { + addr, err := mail.ParseAddress(full) + if err != nil { + return "", err + } + return addr.Address, nil +} + +func addressLists(lists ...[]string) ([]string, error) { + length := 0 + for _, lst := range lists { + length += len(lst) + } + combined := make([]string, 0, length) + + for _, lst := range lists { + for _, full := range lst { + addr, err := emailOnly(full) + if err != nil { + return nil, err + } + combined = append(combined, addr) + } + } + + return combined, nil +} + +// Close immediately changes the pool's state so no new connections will be +// created, then gets and closes the existing ones as they become available. +func (p *Pool) Close() { + close(p.closing) + + for atomic.LoadInt32(&p.created) > 0 { + c := <-p.clients + c.Quit() + p.dec() + } +} diff --git a/mail/tool.go b/mail/tool.go new file mode 100644 index 0000000..b7dd9f8 --- /dev/null +++ b/mail/tool.go @@ -0,0 +1,135 @@ +package mail + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "fmt" + "io" + "math" + "math/big" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/mail" + "net/textproto" + "os" + "strings" + "time" +) + +var maxBigInt = big.NewInt(math.MaxInt64) + +// generateMessageID generates and returns a string suitable for an RFC 2822 +// compliant Message-ID, e.g.: +// <1444789264909237300.3464.1819418242800517193@DESKTOP01> +// +// The following parameters are used to generate a Message-ID: +// - The nanoseconds since Epoch +// - The calling PID +// - A cryptographically random int64 +// - The sending hostname +func generateMessageID() (string, error) { + t := time.Now().UnixNano() + pid := os.Getpid() + rint, err := rand.Int(rand.Reader, maxBigInt) + if err != nil { + return "", err + } + h, err := os.Hostname() + // If we can't get the hostname, we'll use localhost + if err != nil { + h = "localhost.localdomain" + } + msgid := fmt.Sprintf("<%d.%d.%d@%s>", t, pid, rint, h) + return msgid, nil +} + +// headerToBytes renders "header" to "buff". If there are multiple values for a +// field, multiple "Field: value\r\n" lines will be emitted. +func headerToBytes(buff *bytes.Buffer, header textproto.MIMEHeader) { + for field, vals := range header { + for _, subval := range vals { + // bytes.Buffer.Write() never returns an error. + io.WriteString(buff, field) + io.WriteString(buff, ": ") + // Write the encoded header if needed + switch { + case field == "Content-Type" || field == "Content-Disposition": + buff.Write([]byte(subval)) + case field == StrFrom || field == StrTo || field == StrCc || field == StrBcc || field == StrReplyTo: + participants := strings.Split(subval, ",") + for i, v := range participants { + addr, err := mail.ParseAddress(v) + if err != nil { + continue + } + participants[i] = addr.String() + } + buff.Write([]byte(strings.Join(participants, ", "))) + default: + buff.Write([]byte(mime.QEncoding.Encode("UTF-8", subval))) + } + io.WriteString(buff, "\r\n") + } + } +} + +// base64Wrap encodes the attachment content, and wraps it according to RFC 2045 standards (every 76 chars) +// The output is then written to the specified io.Writer +func base64Wrap(w io.Writer, b []byte) { + // 57 raw bytes per 76-byte base64 line. + const maxRaw = 57 + // Buffer for each line, including trailing CRLF. + buffer := make([]byte, MaxLineLength+len("\r\n")) + copy(buffer[MaxLineLength:], "\r\n") + // Process raw chunks until there's no longer enough to fill a line. + for len(b) >= maxRaw { + base64.StdEncoding.Encode(buffer, b[:maxRaw]) + w.Write(buffer) + b = b[maxRaw:] + } + // Handle the last chunk of bytes. + if len(b) > 0 { + out := buffer[:base64.StdEncoding.EncodedLen(len(b))] + base64.StdEncoding.Encode(out, b) + out = append(out, "\r\n"...) + w.Write(out) + } +} + +// handleAddressList +func handleAddressList(v []string) []string { + res := []string{} + for _, a := range v { + w := strings.Split(a, ",") + for _, addr := range w { + decodeAddr, err := (&mime.WordDecoder{}).DecodeHeader(strings.TrimSpace(addr)) + if err == nil { + res = append(res, decodeAddr) + } else { + res = append(res, addr) + } + } + } + return res +} + +func writeMessage(buff io.Writer, msg []byte, multipart bool, mediaType string, w *multipart.Writer) error { + if multipart { + header := textproto.MIMEHeader{ + "Content-Type": {mediaType + "; charset=UTF-8"}, + "Content-Transfer-Encoding": {"quoted-printable"}, + } + if _, err := w.CreatePart(header); err != nil { + return err + } + } + + qp := quotedprintable.NewWriter(buff) + // Write the text + if _, err := qp.Write(msg); err != nil { + return err + } + return qp.Close() +} diff --git a/mail/trim.go b/mail/trim.go new file mode 100644 index 0000000..5592381 --- /dev/null +++ b/mail/trim.go @@ -0,0 +1,39 @@ +package mail + +import ( + "bufio" + "bytes" + "io" + "net/textproto" + "unicode" +) + +// trimReader +// a custom io.Reader that will trim any leading +// whitespace, as this can cause email imports to fail. +type trimReader struct { + rd io.Reader + trimmed bool +} + +// Read +// trims off any unicode whitespace from the originating reader +func (tr *trimReader) Read(buf []byte) (int, error) { + n, err := tr.rd.Read(buf) + if err != nil { + return n, err + } + if !tr.trimmed { + t := bytes.TrimLeftFunc(buf[:n], unicode.IsSpace) + tr.trimmed = true + n = copy(buf, t) + } + return n, err +} + +// TpReader +// textproto reader io +func TpReader(r io.Reader) *textproto.Reader { + s := &trimReader{rd: r} + return textproto.NewReader(bufio.NewReader(s)) +}