Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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 .
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ _cgo_export.*
_testmain.go

*.exe

# IDEs
.idea
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
212 changes: 198 additions & 14 deletions email.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand All @@ -246,14 +291,29 @@ 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.
//
// "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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
)

Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)))
}
Expand Down
Loading