From 8c804ec5736d7eb393bdcc048b860d56566d45b8 Mon Sep 17 00:00:00 2001 From: Winni Neessen Date: Sun, 13 Mar 2022 17:15:23 +0100 Subject: [PATCH] Implemented MIME multipart handling for alternative content --- README.md | 3 ++ client.go | 14 ++---- cmd/main.go | 13 ++++- encoding.go | 33 ++++++++++++- go.mod | 7 +++ go.sum | 6 +++ header.go | 6 +++ msg.go | 131 +++++++++++++++++++++++++++++++++++++++++++++++---- msgwriter.go | 128 +++++++++++++++++++++++++++++++++++++++++++------ 9 files changed, 303 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index d6a4058..8a9cb23 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ The main idea of this library was to provide a simple interface to sending mails my [JS-Mailer](https://github.com/wneessen/js-mailer) project. It quickly evolved into a full-fledged mail library. +Parts (especially the msgWriter) of this library have been ported from the [GoMail](https://github.com/go-mail/mail) +which seems to not be maintained anymore. + **This library is "WIP" an should not be considered "production ready", yet.** go-mail follows idiomatic Go style and best practice. It's only dependency is the Go Standard Library. diff --git a/client.go b/client.go index 20e34fd..c7800b1 100644 --- a/client.go +++ b/client.go @@ -298,16 +298,10 @@ func (c *Client) Send(ml ...*Msg) error { return fmt.Errorf("sending RCPT TO command failed: %w", err) } } - w := os.Stderr - - /* - w, err := c.sc.Data() - if err != nil { - return fmt.Errorf("sending DATA command failed: %w", err) - } - - */ - + w, err := c.sc.Data() + if err != nil { + return fmt.Errorf("sending DATA command failed: %w", err) + } _, err = m.Write(w) if err != nil { return fmt.Errorf("sending mail content failed: %w", err) diff --git a/cmd/main.go b/cmd/main.go index 9e80eb8..445f374 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "github.com/wneessen/go-mail" + "io" "os" ) @@ -12,8 +13,6 @@ func main() { fmt.Printf("$TEST_HOST env variable cannot be empty\n") os.Exit(1) } - tu := os.Getenv("TEST_USER") - tp := os.Getenv("TEST_PASS") fa := "Winni Neessen " toa := "Winfried Neessen " @@ -37,6 +36,15 @@ func main() { m.SetDate() m.SetBulk() + m.SetBodyWriter(mail.TypeTextPlain, func(fw io.Writer) error { + _, err := io.WriteString(fw, "This is a writer test") + return err + }) + m.AddAlternativeString(mail.TypeTextHTML, "This is HTML content") + //m.Write(os.Stdout) + + tu := os.Getenv("TEST_USER") + tp := os.Getenv("TEST_PASS") c, err := mail.NewClient(th, mail.WithTLSPolicy(mail.TLSMandatory), mail.WithSMTPAuth(mail.SMTPAuthLogin), mail.WithUsername(tu), mail.WithPassword(tp)) @@ -48,4 +56,5 @@ func main() { fmt.Printf("failed to dial: %s\n", err) os.Exit(1) } + } diff --git a/encoding.go b/encoding.go index 74f8e86..ebbb54c 100644 --- a/encoding.go +++ b/encoding.go @@ -1,10 +1,19 @@ package mail +// Charset represents a character set for the encoding +type Charset string + +// ContentType represents a content type for the Msg +type ContentType string + // Encoding represents a MIME encoding scheme like quoted-printable or Base64. type Encoding string -// Charset represents a character set for the encodingA -type Charset string +// MIMEVersion represents the MIME version for the mail +type MIMEVersion string + +// MIMEType represents the MIME type for the mail +type MIMEType string // List of supported encodings const ( @@ -117,6 +126,26 @@ const ( CharsetGBK Charset = "GBK" ) +// List of MIME versions +const ( + //Mime10 is the MIME Version 1.0 + Mime10 MIMEVersion = "1.0" +) + +// List of common content types +const ( + TypeTextPlain ContentType = "text/plain" + TypeTextHTML ContentType = "text/html" + TypeAppOctetStream ContentType = "application/octet-stream" +) + +// List of MIMETypes +const ( + MIMEAlternative MIMEType = "alternative" + MIMEMixed MIMEType = "mixed" + MIMERelated MIMEType = "related" +) + // String is a standard method to convert an Encoding into a printable format func (e Encoding) String() string { return string(e) diff --git a/go.mod b/go.mod index 144db05..5134346 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ module github.com/wneessen/go-mail go 1.17 + +require github.com/go-mail/mail/v2 v2.3.0 + +require ( + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/mail.v2 v2.3.1 // indirect +) diff --git a/go.sum b/go.sum index e69de29..7014555 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/go-mail/mail/v2 v2.3.0 h1:wha99yf2v3cpUzD1V9ujP404Jbw2uEvs+rBJybkdYcw= +github.com/go-mail/mail/v2 v2.3.0/go.mod h1:oE2UK8qebZAjjV1ZYUpY7FPnbi/kIU53l1dmqPRb4go= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= +gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= +gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= diff --git a/header.go b/header.go index af8d300..548e121 100644 --- a/header.go +++ b/header.go @@ -8,9 +8,15 @@ type AddrHeader string // List of common generic header field names const ( + // HeaderContentDisposition is the "Content-Disposition" header + HeaderContentDisposition Header = "Content-Disposition" + // HeaderContentLang is the "Content-Language" header HeaderContentLang Header = "Content-Language" + // HeaderContentTransferEnc is the "Content-Transfer-Encoding" header + HeaderContentTransferEnc Header = "Content-Transfer-Encoding" + // HeaderContentType is the "Content-Type" header HeaderContentType Header = "Content-Type" diff --git a/msg.go b/msg.go index da38653..cfbcbdf 100644 --- a/msg.go +++ b/msg.go @@ -1,6 +1,7 @@ package mail import ( + "bytes" "errors" "fmt" "io" @@ -21,6 +22,12 @@ var ( // Msg is the mail message struct type Msg struct { + // addrHeader is a slice of strings that the different mail AddrHeader fields + addrHeader map[AddrHeader][]*mail.Address + + // boundary is the MIME content boundary + boundary string + // charset represents the charset of the mail (defaults to UTF-8) charset Charset @@ -33,20 +40,35 @@ type Msg struct { // genHeader is a slice of strings that the different generic mail Header fields genHeader map[Header][]string - // addrHeader is a slice of strings that the different mail AddrHeader fields - addrHeader map[AddrHeader][]*mail.Address + // mimever represents the MIME version + mimever MIMEVersion + + // parts represent the different parts of the Msg + parts []*Part } +// Part is a part of the Msg +type Part struct { + w func(io.Writer) error + x io.Writer + ctype ContentType + enc Encoding +} + +// PartOption returns a function that can be used for grouping Part options +type PartOption func(*Part) + // MsgOption returns a function that can be used for grouping Msg options type MsgOption func(*Msg) // NewMsg returns a new Msg pointer func NewMsg(o ...MsgOption) *Msg { m := &Msg{ - encoding: EncodingQP, - charset: CharsetUTF8, - genHeader: make(map[Header][]string), addrHeader: make(map[AddrHeader][]*mail.Address), + charset: CharsetUTF8, + encoding: EncodingQP, + genHeader: make(map[Header][]string), + mimever: Mime10, } // Override defaults with optionally provided MsgOption functions @@ -77,6 +99,13 @@ func WithEncoding(e Encoding) MsgOption { } } +// WithMIMEVersion overrides the default MIME version +func WithMIMEVersion(mv MIMEVersion) MsgOption { + return func(m *Msg) { + m.mimever = mv + } +} + // SetCharset sets the encoding charset of the Msg func (m *Msg) SetCharset(c Charset) { m.charset = c @@ -87,6 +116,16 @@ func (m *Msg) SetEncoding(e Encoding) { m.encoding = e } +// SetBoundary sets the boundary of the Msg +func (m *Msg) SetBoundary(b string) { + m.boundary = b +} + +// SetMIMEVersion sets the MIME version of the Msg +func (m *Msg) SetMIMEVersion(mv MIMEVersion) { + m.mimever = mv +} + // Encoding returns the currently set encoding of the Msg func (m *Msg) Encoding() string { return m.encoding.String() @@ -294,6 +333,40 @@ func (m *Msg) GetRecipients() ([]string, error) { return rl, nil } +// SetBodyString sets the body of the message. +func (m *Msg) SetBodyString(ct ContentType, b string, o ...PartOption) { + buf := bytes.NewBufferString(b) + w := func(w io.Writer) error { + _, err := io.Copy(w, buf) + return err + } + m.SetBodyWriter(ct, w, o...) +} + +// SetBodyWriter sets the body of the message. +func (m *Msg) SetBodyWriter(ct ContentType, w func(io.Writer) error, o ...PartOption) { + p := m.NewPart(ct, o...) + p.w = w + m.parts = []*Part{p} +} + +// AddAlternativeString sets the alternative body of the message. +func (m *Msg) AddAlternativeString(ct ContentType, b string, o ...PartOption) { + buf := bytes.NewBufferString(b) + w := func(w io.Writer) error { + _, err := io.Copy(w, buf) + return err + } + m.AddAlternativeWriter(ct, w, o...) +} + +// AddAlternativeWriter sets the body of the message. +func (m *Msg) AddAlternativeWriter(ct ContentType, w func(io.Writer) error, o ...PartOption) { + p := m.NewPart(ct, o...) + p.w = w + m.parts = append(m.parts, p) +} + // Write writes the formated Msg into a give io.Writer func (m *Msg) Write(w io.Writer) (int64, error) { mw := &msgWriter{w: w} @@ -301,15 +374,50 @@ func (m *Msg) Write(w io.Writer) (int64, error) { return mw.n, mw.err } +// NewPart returns a new Part for the Msg +func (m *Msg) NewPart(ct ContentType, o ...PartOption) *Part { + p := &Part{ + ctype: ct, + enc: m.encoding, + } + + // Override defaults with optionally provided MsgOption functions + for _, co := range o { + if co == nil { + continue + } + co(p) + } + + return p +} + +// WithPartEncoding overrides the default Part encoding +func WithPartEncoding(e Encoding) PartOption { + return func(p *Part) { + p.enc = e + } +} + +// SetEncoding creates a new mime.WordEncoder based on the encoding setting of the message +func (p *Part) SetEncoding(e Encoding) { + p.enc = e +} + // setEncoder creates a new mime.WordEncoder based on the encoding setting of the message func (m *Msg) setEncoder() { - switch m.encoding { + m.encoder = getEncoder(m.encoding) +} + +// getEncoder creates a new mime.WordEncoder based on the encoding setting of the message +func getEncoder(e Encoding) mime.WordEncoder { + switch e { case EncodingQP: - m.encoder = mime.QEncoding + return mime.QEncoding case EncodingB64: - m.encoder = mime.BEncoding + return mime.BEncoding default: - m.encoder = mime.QEncoding + return mime.QEncoding } } @@ -318,3 +426,8 @@ func (m *Msg) setEncoder() { func (m *Msg) encodeString(s string) string { return m.encoder.Encode(string(m.charset), s) } + +// hasAlt returns true if the Msg has more than one part +func (m *Msg) hasAlt() bool { + return len(m.parts) > 1 +} diff --git a/msgwriter.go b/msgwriter.go index 458b0ba..98c8311 100644 --- a/msgwriter.go +++ b/msgwriter.go @@ -1,7 +1,12 @@ package mail import ( + "encoding/base64" + "fmt" "io" + "mime/multipart" + "mime/quotedprintable" + "net/textproto" "strings" ) @@ -11,17 +16,29 @@ const MaxHeaderLength = 76 // msgWriter handles the I/O to the io.WriteCloser of the SMTP client type msgWriter struct { - w io.Writer - n int64 - //writers [3]*multipart.Writer - //partWriter io.Writer - //depth uint8 + d int8 err error + mpw [3]*multipart.Writer + n int64 + pw io.Writer + w io.Writer +} + +// Write implements the io.Writer interface for msgWriter +func (mw *msgWriter) Write(p []byte) (int, error) { + if mw.err != nil { + return 0, fmt.Errorf("failed to write due to previous error: %w", mw.err) + } + + var n int + n, mw.err = mw.w.Write(p) + mw.n += int64(n) + return n, mw.err } // writeMsg formats the message and sends it to its io.Writer func (mw *msgWriter) writeMsg(m *Msg) { - if _, ok := m.genHeader["Date"]; !ok { + if _, ok := m.genHeader[HeaderDate]; !ok { m.SetDate() } for k, v := range m.genHeader { @@ -36,8 +53,72 @@ func (mw *msgWriter) writeMsg(m *Msg) { mw.writeHeader(Header(t), v...) } } - mw.writeString("\r\n") - mw.writeString("This is a test mail") + mw.writeHeader(HeaderMIMEVersion, string(m.mimever)) + + if m.hasAlt() { + mw.startMP(MIMEAlternative, m.boundary) + mw.writeString("\r\n\r\n") + } + for _, p := range m.parts { + mw.writePart(p, m.charset) + } + if m.hasAlt() { + mw.stopMP() + } +} + +// startMP writes a multipart beginning +func (mw *msgWriter) startMP(mt MIMEType, b string) { + mp := multipart.NewWriter(mw) + if b != "" { + mw.err = mp.SetBoundary(b) + } + + ct := fmt.Sprintf("multipart/%s;\r\n boundary=%s", mt, mp.Boundary()) + mw.mpw[mw.d] = mp + + if mw.d == 0 { + mw.writeString(fmt.Sprintf("%s: %s", HeaderContentType, ct)) + } + if mw.d > 0 { + mw.newPart(map[string][]string{"Content-Type": {ct}}) + } + mw.d++ +} + +// stopMP closes the multipart +func (mw *msgWriter) stopMP() { + if mw.d > 0 { + mw.err = mw.mpw[mw.d-1].Close() + mw.d-- + } +} + +// newPart creates a new MIME multipart io.Writer and sets the partwriter to it +func (mw *msgWriter) newPart(h map[string][]string) { + mw.pw, mw.err = mw.mpw[mw.d-1].CreatePart(h) +} + +// writePart writes the corresponding part to the Msg body +func (mw *msgWriter) writePart(p *Part, cs Charset) { + mh := textproto.MIMEHeader{} + mh.Add(string(HeaderContentType), fmt.Sprintf("%s; charset=%s", + p.ctype, cs)) + mh.Add(string(HeaderContentTransferEnc), string(p.enc)) + if mw.d > 0 { + mw.newPart(mh) + } + mw.writeBody(p.w, p.enc) +} + +// writeString writes a string into the msgWriter's io.Writer interface +func (mw *msgWriter) writeString(s string) { + if mw.err != nil { + return + } + var n int + n, mw.err = io.WriteString(mw.w, s) + mw.n += int64(n) } // writeHeader writes a header into the msgWriter's io.Writer @@ -85,12 +166,29 @@ func (mw *msgWriter) writeHeader(k Header, v ...string) { mw.writeString("\r\n") } -// writeString writes a string into the msgWriter's io.Writer interface -func (mw *msgWriter) writeString(s string) { - if mw.err != nil { - return +// writeBody writes an io.Reader into an io.Writer using provided Encoding +func (mw *msgWriter) writeBody(f func(io.Writer) error, e Encoding) { + var w io.Writer + var ew io.WriteCloser + if mw.d == 0 { + w = mw.w } - var n int - n, mw.err = io.WriteString(mw.w, s) - mw.n += int64(n) + if mw.d > 0 { + w = mw.pw + } + + switch e { + case EncodingQP: + ew = quotedprintable.NewWriter(w) + case EncodingB64: + ew = base64.NewEncoder(base64.StdEncoding, w) + case NoEncoding: + mw.err = f(w) + return + default: + ew = quotedprintable.NewWriter(w) + } + + mw.err = f(ew) + mw.err = ew.Close() }