Skip to content

Commit

Permalink
add inline (embedded) images support
Browse files Browse the repository at this point in the history
  • Loading branch information
s0x90 authored and umputun committed Apr 24, 2022
1 parent df53159 commit f81e515
Show file tree
Hide file tree
Showing 3 changed files with 125 additions and 20 deletions.
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ Usage example:
```go
client := email.NewSender("localhost", email.ContentType("text/html"), email.Auth("user", "pass"))
err := client.Send("<html>some content, foo bar</html>",
email.Params{From: "me@example.com", To: []string{"to@example.com"}, Subject: "Hello world!", Attachments: []string{"/path/to/file1.txt", "/path/to/file2.txt"}})
email.Params{From: "me@example.com", To: []string{"to@example.com"}, Subject: "Hello world!",
Attachments: []string{"/path/to/file1.txt", "/path/to/file2.txt"},
InlineImages: []string{"/path/to/image1.png", "/path/to/image2.png"},
})
```

## options
Expand Down Expand Up @@ -40,10 +43,11 @@ To send email user need to create a sender first and then use `Send` method. The
- parameters (`email.Params`)
```go
type Params struct {
From string // From email field
To []string // From email field
Subject string // Email subject
Attachments []string // Attachments
From string // From email field
To []string // From email field
Subject string // Email subject
Attachments []string // Attachments
InlineImages []string // Embedding directly to email body. Autogenerated Content-Id (cid) equals to file name
}
```

Expand Down
47 changes: 35 additions & 12 deletions email.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@ type Sender struct {

// Params contains all user-defined parameters to send emails
type Params struct {
From string // From email field
To []string // From email field
Subject string // Email subject
Attachments []string // Attachments path
From string // From email field
To []string // From email field
Subject string // Email subject
Attachments []string // Attachments path
InlineImages []string // InlineImages images path
}

// Logger is used to log errors and debug messages
Expand Down Expand Up @@ -206,21 +207,29 @@ func (em *Sender) buildMessage(text string, params Params) (message string, err
message = addHeader(message, "Subject", params.Subject)

withAttachments := len(params.Attachments) > 0
withInlineImg := len(params.InlineImages) > 0

if em.contentType != "" || withAttachments {
if em.contentType != "" || withAttachments || withInlineImg {
message = addHeader(message, "MIME-version", "1.0")
}

message = addHeader(message, "Date", em.timeNow().Format(time.RFC1123Z))

buff := &bytes.Buffer{}
qp := quotedprintable.NewWriter(buff)
mp := multipart.NewWriter(buff)
boundary := mp.Boundary()
mpMixed := multipart.NewWriter(buff)
boundaryMixed := mpMixed.Boundary()
mpRelated := multipart.NewWriter(buff)
boundaryRelated := mpRelated.Boundary()

if withAttachments {
message = addHeader(message, "Content-Type", fmt.Sprintf("multipart/mixed; boundary=%q\r\n\r\n%s\r",
boundary, "--"+boundary))
boundaryMixed, "--"+boundaryMixed))
}

if withInlineImg {
message = addHeader(message, "Content-Type", fmt.Sprintf("multipart/related; boundary=%q\r\n\r\n%s\r",
boundaryRelated, "--"+boundaryRelated))
}

if em.contentType != "" {
Expand All @@ -233,9 +242,16 @@ func (em *Sender) buildMessage(text string, params Params) (message string, err
return "", fmt.Errorf("failed to write body: %w", err)
}

if withInlineImg {
buff.WriteString("\r\n\r\n")
if err := em.writeFiles(mpRelated, params.InlineImages, "inline"); err != nil {
return "", fmt.Errorf("failed to write inline images: %w", err)
}
}

if withAttachments {
buff.WriteString("\r\n\r\n")
if err := em.writeAttachments(mp, params.Attachments); err != nil {
if err := em.writeFiles(mpMixed, params.Attachments, "attachment"); err != nil {
return "", fmt.Errorf("failed to write attachments: %w", err)
}
}
Expand All @@ -256,8 +272,8 @@ func (em *Sender) writeBody(wc io.WriteCloser, text string) error {
return nil
}

func (em *Sender) writeAttachments(mp *multipart.Writer, attachments []string) error {
for _, attachment := range attachments {
func (em *Sender) writeFiles(mp *multipart.Writer, files []string, disposition string) error {
for _, attachment := range files {
file, err := os.Open(filepath.Clean(attachment))
if err != nil {
return err
Expand All @@ -276,7 +292,14 @@ func (em *Sender) writeAttachments(mp *multipart.Writer, attachments []string) e
header := textproto.MIMEHeader{}
header.Set("Content-Type", http.DetectContentType(fTypeBuff)+"; name=\""+fName+"\"")
header.Set("Content-Transfer-Encoding", "base64")
header.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fName))

switch disposition {
case "attachment":
header.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fName))
case "inline":
header.Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", fName))
header.Set("Content-ID", fmt.Sprintf("<%s>", fName))
}

writer, err := mp.CreatePart(header)
if err != nil {
Expand Down
84 changes: 81 additions & 3 deletions email_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,12 +383,88 @@ func TestEmail_buildMessageWithMIMEAndWrongAttachments(t *testing.T) {
require.Equal(t, "", msg)
}

func TestEmail_buildMessageWithMIMEAndInlineImages(t *testing.T) {
l := &mocks.LoggerMock{LogfFunc: func(format string, args ...interface{}) {
fmt.Printf(format, args...)
fmt.Printf("\n")
}}

e := NewSender("localhost", ContentType("text/html"),
Port(2525),
Log(l))

msg, err := e.buildMessage("<div>this is a test mail with inline images</div><div><img src=\"cid:image.jpg\"></div>\n", Params{
From: "from@example.com",
To: []string{"to@example.com"},
Subject: "test email with attachments",
InlineImages: []string{"testdata/image.jpg"},
})
require.NoError(t, err)
assert.Contains(t, msg, "MIME-version: 1.0", msg)
assert.Contains(t, msg, "Content-Type", "multipart/related; boundary=", msg)
assert.Contains(t, msg, "Content-Disposition: inline; filename=\"image.jpg\"", msg)
assert.Contains(t, msg, "Content-Id: <image.jpg>", msg)
assert.Contains(t, msg, "Content-Transfer-Encoding: base64", msg)
fData, err := os.ReadFile("testdata/image.jpg")
require.NoError(t, err)

b := make([]byte, base64.StdEncoding.EncodedLen(len(fData)))
base64.StdEncoding.Encode(b, fData)
assert.Contains(t, msg, string(b), msg)
}

func TestEmail_buildMessageWithMIMEAndAttachmentsAndInlineImages(t *testing.T) {
l := &mocks.LoggerMock{LogfFunc: func(format string, args ...interface{}) {
fmt.Printf(format, args...)
fmt.Printf("\n")
}}

e := NewSender("localhost", ContentType("text/html"),
Port(2525),
Log(l))

msg, err := e.buildMessage("<div>this is a test mail with inline images</div><div><img src=\"cid:image.jpg\"></div>\n", Params{
From: "from@example.com",
To: []string{"to@example.com"},
Subject: "test email with attachments",
Attachments: []string{"testdata/1.txt", "testdata/2.txt", "testdata/image.jpg"},
InlineImages: []string{"testdata/image.jpg"},
})
require.NoError(t, err)
assert.Contains(t, msg, "MIME-version: 1.0", msg)
assert.Contains(t, msg, "Content-Type", "multipart/mixed; boundary=", msg)
assert.Contains(t, msg, "Content-Disposition: attachment; filename=\"1.txt\"", msg)
assert.Contains(t, msg, "Content-Disposition: attachment; filename=\"2.txt\"", msg)
assert.Contains(t, msg, "Content-Disposition: attachment; filename=\"image.jpg\"", msg)
assert.Contains(t, msg, "Content-Type", "multipart/related; boundary=", msg)
assert.Contains(t, msg, "Content-Disposition: inline; filename=\"image.jpg\"", msg)
assert.Contains(t, msg, "Content-Id: <image.jpg>", msg)
assert.Contains(t, msg, "Content-Transfer-Encoding: base64", msg)

fData1, err := os.ReadFile("testdata/1.txt")
require.NoError(t, err)
fData2, err := os.ReadFile("testdata/2.txt")
require.NoError(t, err)
fData3, err := os.ReadFile("testdata/image.jpg")
require.NoError(t, err)

b1 := make([]byte, base64.StdEncoding.EncodedLen(len(fData1)))
base64.StdEncoding.Encode(b1, fData1)
b2 := make([]byte, base64.StdEncoding.EncodedLen(len(fData2)))
base64.StdEncoding.Encode(b2, fData2)
b3 := make([]byte, base64.StdEncoding.EncodedLen(len(fData3)))
base64.StdEncoding.Encode(b3, fData3)
assert.Contains(t, msg, string(b1), msg)
assert.Contains(t, msg, string(b2), msg)
assert.Contains(t, msg, string(b3), msg)
}

func TestWriteAttachmentsFailed(t *testing.T) {

e := NewSender("localhost", ContentType("text/html"))
wc := &fakeWriterCloser{fail: true}
mp := multipart.NewWriter(wc)
err := e.writeAttachments(mp, []string{"testdata/1.txt"})
err := e.writeFiles(mp, []string{"testdata/1.txt"}, "attachment")
require.Error(t, err)
}

Expand All @@ -410,9 +486,11 @@ func TestWriteBodyFail(t *testing.T) {
// uncomment to debug with real smtp server
// func TestSendIntegration(t *testing.T) {
// client := NewSender("localhost", ContentType("text/html"), Port(2525))
// err := client.Send("<html>some content, foo bar</html>",
// err := client.Send("<html><div>some content, foo bar</div>\n<div><img src=\"cid:image.jpg\"/>\n</div></html>",
// Params{From: "me@example.com", To: []string{"to@example.com"}, Subject: "Hello world!",
// Attachments: []string{"testdata/1.txt", "testdata/2.txt", "testdata/image.jpg"}})
// Attachments: []string{"testdata/1.txt", "testdata/2.txt", "testdata/image.jpg"},
// InlineImages: []string{"testdata/image.jpg"},
// })
// require.NoError(t, err)
//}

Expand Down

0 comments on commit f81e515

Please sign in to comment.