diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..280d268 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: Build and Test + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + + build: + name: Build + runs-on: ubuntu-latest + steps: + + - name: Set up Go 1.x + uses: actions/setup-go@v2 + with: + go-version: ^1.13 + id: go + + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + + - name: Get dependencies + run: | + go get -v -t -d ./... + if [ -f Gopkg.toml ]; then + curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh + dep ensure + fi + + - name: Build + run: go build -v . + + - name: Test + run: go test -v . diff --git a/.gitignore b/.gitignore index 0026861..7de90cf 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ _cgo_export.* _testmain.go *.exe + +# IDEs +.idea diff --git a/README.md b/README.md index 55d501a..47c15f7 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,12 @@ e := NewEmail() e.AttachFile("test.txt") ``` +#### Attaching a File with custom file name +```go +e := NewEmail() +e.AttachFileWithName("internalName.txt", "publicName.txt") +``` + #### A Pool of Reusable Connections ```go (var ch <-chan *email.Email) diff --git a/email.go b/email.go index a8069a3..d296fc1 100644 --- a/email.go +++ b/email.go @@ -95,18 +95,47 @@ func NewEmailFromReader(r io.Reader) (*Email, error) { switch { case h == "Subject": e.Subject = v[0] + subj, err := (&mime.WordDecoder{}).DecodeHeader(e.Subject) + if err == nil && len(subj) > 0 { + e.Subject = subj + } delete(hdrs, h) case h == "To": - e.To = v + for _, to := range v { + tt, err := (&mime.WordDecoder{}).DecodeHeader(to) + if err == nil { + e.To = append(e.To, tt) + } else { + e.To = append(e.To, to) + } + } delete(hdrs, h) case h == "Cc": - e.Cc = v + for _, cc := range v { + tcc, err := (&mime.WordDecoder{}).DecodeHeader(cc) + if err == nil { + e.Cc = append(e.Cc, tcc) + } else { + e.Cc = append(e.Cc, cc) + } + } delete(hdrs, h) case h == "Bcc": - e.Bcc = v + for _, bcc := range v { + tbcc, err := (&mime.WordDecoder{}).DecodeHeader(bcc) + if err == nil { + e.Bcc = append(e.Bcc, tbcc) + } else { + e.Bcc = append(e.Bcc, bcc) + } + } delete(hdrs, h) case h == "From": e.From = v[0] + fr, err := (&mime.WordDecoder{}).DecodeHeader(e.From) + if err == nil && len(fr) > 0 { + e.From = fr + } delete(hdrs, h) } } @@ -125,6 +154,20 @@ func NewEmailFromReader(r io.Reader) (*Email, error) { if err != nil { return e, err } + // Check if part is an attachment based on the existence of the Content-Disposition header with a value of "attachment". + if cd := p.header.Get("Content-Disposition"); cd != "" { + cd, params, err := mime.ParseMediaType(p.header.Get("Content-Disposition")) + if err != nil { + return e, err + } + if cd == "attachment" { + _, err = e.Attach(bytes.NewReader(p.body), params["filename"], ct) + if err != nil { + return e, err + } + continue + } + } switch { case ct == "text/plain": e.Text = p.body @@ -194,6 +237,10 @@ func parseMIMEParts(hs textproto.MIMEHeader, b io.Reader) ([]*part, error) { } } else { // If it is not a multipart email, parse the body content as a single "part" + if hs.Get("Content-Transfer-Encoding") == "quoted-printable" { + b = quotedprintable.NewReader(b) + + } var buf bytes.Buffer if _, err := io.Copy(&buf, b); err != nil { return ps, err @@ -216,11 +263,9 @@ func (e *Email) Attach(r io.Reader, filename string, c string) (a *Attachment, e Header: textproto.MIMEHeader{}, Content: buffer.Bytes(), } - // Get the Content-Type to be used in the MIMEHeader if c != "" { at.Header.Set("Content-Type", c) } else { - // If the Content-Type is blank, set the Content-Type to "application/octet-stream" at.Header.Set("Content-Type", "application/octet-stream") } at.Header.Set("Content-Disposition", fmt.Sprintf("attachment;\r\n filename=\"%s\"", filename)) @@ -246,6 +291,21 @@ func (e *Email) AttachFile(filename string) (a *Attachment, err error) { return e.Attach(f, basename, ct) } +// AttachFileWithName is used to attach content to the email. +// It attempts to open the file referenced by filePath and, if successful, creates an Attachment with the provided fileName. +// This Attachment is then appended to the slice of Email.Attachments. +// The function will then return the Attachment for reference, as well as nil for the error, if successful. +func (e *Email) AttachFileWithName(filePath, fileName string) (a *Attachment, err error) { + f, err := os.Open(filePath) + if err != nil { + return + } + defer f.Close() + + ct := mime.TypeByExtension(filepath.Ext(filePath)) + return e.Attach(f, fileName, ct) +} + // msgHeaders merges the Email's various fields and custom headers together in a // standards compliant way to create a MIMEHeader to be used in the resulting // message. It does not alter e.Headers. @@ -253,7 +313,7 @@ func (e *Email) AttachFile(filename string) (a *Attachment, err error) { // "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) msgHeaders() (textproto.MIMEHeader, error) { - res := make(textproto.MIMEHeader, len(e.Headers)+4) + res := make(textproto.MIMEHeader, len(e.Headers)+6) if e.Headers != nil { for _, h := range []string{"Reply-To", "To", "Cc", "From", "Subject", "Date", "Message-Id", "MIME-Version"} { if v, ok := e.Headers[h]; ok { @@ -318,6 +378,17 @@ func writeMessage(buff io.Writer, msg []byte, multipart bool, mediaType string, return qp.Close() } +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 a []byte representation, including all needed MIMEHeaders, boundaries, etc. func (e *Email) Bytes() ([]byte, error) { // TODO: better guess buffer size @@ -328,8 +399,13 @@ func (e *Email) Bytes() ([]byte, error) { 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(e.Attachments) > 0 + isMixed = len(otherAttachments) > 0 isAlternative = len(e.Text) > 0 && len(e.HTML) > 0 ) @@ -379,10 +455,35 @@ func (e *Email) Bytes() ([]byte, error) { } } if len(e.HTML) > 0 { + messageWriter := subWriter + var relatedWriter *multipart.Writer + if 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 + } // Write the HTML - if err := writeMessage(buff, e.HTML, isMixed || isAlternative, "text/html", subWriter); err != nil { + if err := writeMessage(buff, e.HTML, isMixed || isAlternative, "text/html", messageWriter); err != nil { return nil, err } + if len(htmlAttachments) > 0 { + for _, a := range htmlAttachments { + ap, err := relatedWriter.CreatePart(a.Header) + if err != nil { + return nil, err + } + // Write the base64Wrapped content to the part + base64Wrap(ap, a.Content) + } + + relatedWriter.Close() + } } if isMixed && isAlternative { if err := subWriter.Close(); err != nil { @@ -391,7 +492,7 @@ func (e *Email) Bytes() ([]byte, error) { } } // Create attachment part, if necessary - for _, a := range e.Attachments { + for _, a := range otherAttachments { ap, err := w.CreatePart(a.Header) if err != nil { return nil, err @@ -452,8 +553,9 @@ func (e *Email) parseSender() (string, error) { } } -// 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 +// SendWithTLS sends an email over tls 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) SendWithTLS(addr string, a smtp.Auth, t *tls.Config) error { // Merge the To, Cc, and Bcc fields @@ -492,6 +594,75 @@ func (e *Email) SendWithTLS(addr string, a smtp.Auth, t *tls.Config) error { if err = c.Hello("localhost"); err != nil { return err } + + if a != nil { + if ok, _ := c.Extension("AUTH"); ok { + if err = c.Auth(a); 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, a smtp.Auth, t *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 errors.New("Must specify at least one From address and one To address") + } + 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(t); err != nil { @@ -532,9 +703,10 @@ func (e *Email) SendWithTLS(addr string, a smtp.Auth, t *tls.Config) error { // Attachment is a struct representing an email attachment. // Based on the mime/multipart.FileHeader struct, Attachment contains the name, MIMEHeader, and content of the attachment in question type Attachment struct { - Filename string - Header textproto.MIMEHeader - Content []byte + Filename string + Header textproto.MIMEHeader + Content []byte + HTMLRelated bool } // base64Wrap encodes the attachment content, and wraps it according to RFC 2045 standards (every 76 chars) @@ -572,6 +744,18 @@ func headerToBytes(buff io.Writer, header textproto.MIMEHeader) { switch { case field == "Content-Type" || field == "Content-Disposition": buff.Write([]byte(subval)) + case field == "From" || field == "To" || field == "Cc" || field == "Bcc": + participants := strings.Split(subval, ",") + for i, v := range participants { + addr, err := mail.ParseAddress(v) + if err != nil { + continue + } + if addr.Name != "" { + participants[i] = fmt.Sprintf("%s <%s>", mime.QEncoding.Encode("UTF-8", addr.Name), addr.Address) + } + } + buff.Write([]byte(strings.Join(participants, ", "))) default: buff.Write([]byte(mime.QEncoding.Encode("UTF-8", subval))) } diff --git a/email_test.go b/email_test.go index a95bf07..1e721bd 100644 --- a/email_test.go +++ b/email_test.go @@ -1,8 +1,11 @@ package email import ( + "fmt" + "strings" "testing" + "bufio" "bytes" "crypto/rand" "io" @@ -67,6 +70,74 @@ func TestEmailText(t *testing.T) { } } +func TestEmailWithHTMLAttachments(t *testing.T) { + e := prepareEmail() + + // Set plain text to exercise "mime/alternative" + e.Text = []byte("Text Body is, of course, supported!\n") + + e.HTML = []byte("
This is a text.") + + // Set HTML attachment to exercise "mime/related". + attachment, err := e.Attach(bytes.NewBufferString("Rad attachment"), "rad.txt", "image/png; charset=utf-8") + if err != nil { + t.Fatal("Could not add an attachment to the message: ", err) + } + attachment.HTMLRelated = true + + b, err := e.Bytes() + if err != nil { + t.Fatal("Could not serialize e-mail:", err) + } + + // Print the bytes for ocular validation and make sure no errors. + //fmt.Println(string(b)) + + // TODO: Verify the attachments. + s := trimReader{rd: bytes.NewBuffer(b)} + tp := textproto.NewReader(bufio.NewReader(s)) + // Parse the main headers + hdrs, err := tp.ReadMIMEHeader() + if err != nil { + t.Fatal("Could not parse the headers:", err) + } + // Recursively parse the MIME parts + ps, err := parseMIMEParts(hdrs, tp.R) + if err != nil { + t.Fatal("Could not parse the MIME parts recursively:", err) + } + + plainTextFound := false + htmlFound := false + imageFound := false + if expected, actual := 3, len(ps); actual != expected { + t.Error("Unexpected number of parts. Expected:", expected, "Was:", actual) + } + for _, part := range ps { + // part has "header" and "body []byte" + ct := part.header.Get("Content-Type") + if strings.Contains(ct, "image/png") { + imageFound = true + } + if strings.Contains(ct, "text/html") { + htmlFound = true + } + if strings.Contains(ct, "text/plain") { + plainTextFound = true + } + } + + if !plainTextFound { + t.Error("Did not find plain text part.") + } + if !htmlFound { + t.Error("Did not find HTML part.") + } + if !imageFound { + t.Error("Did not find image part.") + } +} + func TestEmailHTML(t *testing.T) { e := prepareEmail() e.HTML = []byte("