From 16138d14f8aec5ada1fe24578a09c9337590f01e Mon Sep 17 00:00:00 2001 From: Vitaliy Utkin Date: Tue, 19 Apr 2022 14:10:33 +0500 Subject: [PATCH] add inline (embedded) images support --- README.md | 14 ++++++--- email.go | 47 ++++++++++++++++++++-------- email_test.go | 84 +++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 125 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index b13dc0f..a8300ab 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,10 @@ Usage example: ```go client := email.NewSender("localhost", email.ContentType("text/html"), email.Auth("user", "pass")) err := client.Send("some content, foo bar", - 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 @@ -39,10 +42,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 } ``` diff --git a/email.go b/email.go index d3ab1b7..b7a6dd3 100644 --- a/email.go +++ b/email.go @@ -40,10 +40,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 @@ -197,8 +198,9 @@ 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") } @@ -206,12 +208,19 @@ func (em *Sender) buildMessage(text string, params Params) (message string, err 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 != "" { @@ -224,9 +233,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) } } @@ -247,8 +263,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 @@ -267,7 +283,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 { diff --git a/email_test.go b/email_test.go index 608de53..1e803aa 100644 --- a/email_test.go +++ b/email_test.go @@ -316,12 +316,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("
this is a test mail with inline images
\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: ", 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("
this is a test mail with inline images
\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: ", 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) } @@ -343,9 +419,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("some content, foo bar", +// err := client.Send("
some content, foo bar
\n
\n
", // 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) //}