Implemented MIME multipart handling for alternative content

This commit is contained in:
Winni Neessen 2022-03-13 17:15:23 +01:00
parent a85b761f43
commit 8c804ec573
Signed by: wneessen
GPG key ID: 385AC9889632126E
9 changed files with 303 additions and 38 deletions

View file

@ -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 my [JS-Mailer](https://github.com/wneessen/js-mailer) project. It quickly evolved into a
full-fledged mail library. 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.** **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. go-mail follows idiomatic Go style and best practice. It's only dependency is the Go Standard Library.

View file

@ -298,16 +298,10 @@ func (c *Client) Send(ml ...*Msg) error {
return fmt.Errorf("sending RCPT TO command failed: %w", err) return fmt.Errorf("sending RCPT TO command failed: %w", err)
} }
} }
w := os.Stderr
/*
w, err := c.sc.Data() w, err := c.sc.Data()
if err != nil { if err != nil {
return fmt.Errorf("sending DATA command failed: %w", err) return fmt.Errorf("sending DATA command failed: %w", err)
} }
*/
_, err = m.Write(w) _, err = m.Write(w)
if err != nil { if err != nil {
return fmt.Errorf("sending mail content failed: %w", err) return fmt.Errorf("sending mail content failed: %w", err)

View file

@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"github.com/wneessen/go-mail" "github.com/wneessen/go-mail"
"io"
"os" "os"
) )
@ -12,8 +13,6 @@ func main() {
fmt.Printf("$TEST_HOST env variable cannot be empty\n") fmt.Printf("$TEST_HOST env variable cannot be empty\n")
os.Exit(1) os.Exit(1)
} }
tu := os.Getenv("TEST_USER")
tp := os.Getenv("TEST_PASS")
fa := "Winni Neessen <wn@neessen.cloud>" fa := "Winni Neessen <wn@neessen.cloud>"
toa := "Winfried Neessen <wn@neessen.net>" toa := "Winfried Neessen <wn@neessen.net>"
@ -37,6 +36,15 @@ func main() {
m.SetDate() m.SetDate()
m.SetBulk() 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), c, err := mail.NewClient(th, mail.WithTLSPolicy(mail.TLSMandatory),
mail.WithSMTPAuth(mail.SMTPAuthLogin), mail.WithUsername(tu), mail.WithSMTPAuth(mail.SMTPAuthLogin), mail.WithUsername(tu),
mail.WithPassword(tp)) mail.WithPassword(tp))
@ -48,4 +56,5 @@ func main() {
fmt.Printf("failed to dial: %s\n", err) fmt.Printf("failed to dial: %s\n", err)
os.Exit(1) os.Exit(1)
} }
} }

View file

@ -1,10 +1,19 @@
package mail 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. // Encoding represents a MIME encoding scheme like quoted-printable or Base64.
type Encoding string type Encoding string
// Charset represents a character set for the encodingA // MIMEVersion represents the MIME version for the mail
type Charset string type MIMEVersion string
// MIMEType represents the MIME type for the mail
type MIMEType string
// List of supported encodings // List of supported encodings
const ( const (
@ -117,6 +126,26 @@ const (
CharsetGBK Charset = "GBK" 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 // String is a standard method to convert an Encoding into a printable format
func (e Encoding) String() string { func (e Encoding) String() string {
return string(e) return string(e)

7
go.mod
View file

@ -1,3 +1,10 @@
module github.com/wneessen/go-mail module github.com/wneessen/go-mail
go 1.17 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
)

6
go.sum
View file

@ -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=

View file

@ -8,9 +8,15 @@ type AddrHeader string
// List of common generic header field names // List of common generic header field names
const ( const (
// HeaderContentDisposition is the "Content-Disposition" header
HeaderContentDisposition Header = "Content-Disposition"
// HeaderContentLang is the "Content-Language" header // HeaderContentLang is the "Content-Language" header
HeaderContentLang Header = "Content-Language" HeaderContentLang Header = "Content-Language"
// HeaderContentTransferEnc is the "Content-Transfer-Encoding" header
HeaderContentTransferEnc Header = "Content-Transfer-Encoding"
// HeaderContentType is the "Content-Type" header // HeaderContentType is the "Content-Type" header
HeaderContentType Header = "Content-Type" HeaderContentType Header = "Content-Type"

131
msg.go
View file

@ -1,6 +1,7 @@
package mail package mail
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -21,6 +22,12 @@ var (
// Msg is the mail message struct // Msg is the mail message struct
type Msg 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 represents the charset of the mail (defaults to UTF-8)
charset Charset charset Charset
@ -33,20 +40,35 @@ type Msg struct {
// genHeader is a slice of strings that the different generic mail Header fields // genHeader is a slice of strings that the different generic mail Header fields
genHeader map[Header][]string genHeader map[Header][]string
// addrHeader is a slice of strings that the different mail AddrHeader fields // mimever represents the MIME version
addrHeader map[AddrHeader][]*mail.Address 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 // MsgOption returns a function that can be used for grouping Msg options
type MsgOption func(*Msg) type MsgOption func(*Msg)
// NewMsg returns a new Msg pointer // NewMsg returns a new Msg pointer
func NewMsg(o ...MsgOption) *Msg { func NewMsg(o ...MsgOption) *Msg {
m := &Msg{ m := &Msg{
encoding: EncodingQP,
charset: CharsetUTF8,
genHeader: make(map[Header][]string),
addrHeader: make(map[AddrHeader][]*mail.Address), addrHeader: make(map[AddrHeader][]*mail.Address),
charset: CharsetUTF8,
encoding: EncodingQP,
genHeader: make(map[Header][]string),
mimever: Mime10,
} }
// Override defaults with optionally provided MsgOption functions // 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 // SetCharset sets the encoding charset of the Msg
func (m *Msg) SetCharset(c Charset) { func (m *Msg) SetCharset(c Charset) {
m.charset = c m.charset = c
@ -87,6 +116,16 @@ func (m *Msg) SetEncoding(e Encoding) {
m.encoding = e 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 // Encoding returns the currently set encoding of the Msg
func (m *Msg) Encoding() string { func (m *Msg) Encoding() string {
return m.encoding.String() return m.encoding.String()
@ -294,6 +333,40 @@ func (m *Msg) GetRecipients() ([]string, error) {
return rl, nil 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 // Write writes the formated Msg into a give io.Writer
func (m *Msg) Write(w io.Writer) (int64, error) { func (m *Msg) Write(w io.Writer) (int64, error) {
mw := &msgWriter{w: w} mw := &msgWriter{w: w}
@ -301,15 +374,50 @@ func (m *Msg) Write(w io.Writer) (int64, error) {
return mw.n, mw.err 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 // setEncoder creates a new mime.WordEncoder based on the encoding setting of the message
func (m *Msg) setEncoder() { 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: case EncodingQP:
m.encoder = mime.QEncoding return mime.QEncoding
case EncodingB64: case EncodingB64:
m.encoder = mime.BEncoding return mime.BEncoding
default: default:
m.encoder = mime.QEncoding return mime.QEncoding
} }
} }
@ -318,3 +426,8 @@ func (m *Msg) setEncoder() {
func (m *Msg) encodeString(s string) string { func (m *Msg) encodeString(s string) string {
return m.encoder.Encode(string(m.charset), s) 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
}

View file

@ -1,7 +1,12 @@
package mail package mail
import ( import (
"encoding/base64"
"fmt"
"io" "io"
"mime/multipart"
"mime/quotedprintable"
"net/textproto"
"strings" "strings"
) )
@ -11,17 +16,29 @@ const MaxHeaderLength = 76
// msgWriter handles the I/O to the io.WriteCloser of the SMTP client // msgWriter handles the I/O to the io.WriteCloser of the SMTP client
type msgWriter struct { type msgWriter struct {
w io.Writer d int8
n int64
//writers [3]*multipart.Writer
//partWriter io.Writer
//depth uint8
err error 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 // writeMsg formats the message and sends it to its io.Writer
func (mw *msgWriter) writeMsg(m *Msg) { func (mw *msgWriter) writeMsg(m *Msg) {
if _, ok := m.genHeader["Date"]; !ok { if _, ok := m.genHeader[HeaderDate]; !ok {
m.SetDate() m.SetDate()
} }
for k, v := range m.genHeader { for k, v := range m.genHeader {
@ -36,8 +53,72 @@ func (mw *msgWriter) writeMsg(m *Msg) {
mw.writeHeader(Header(t), v...) mw.writeHeader(Header(t), v...)
} }
} }
mw.writeString("\r\n") mw.writeHeader(HeaderMIMEVersion, string(m.mimever))
mw.writeString("This is a test mail")
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 // 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") mw.writeString("\r\n")
} }
// writeString writes a string into the msgWriter's io.Writer interface // writeBody writes an io.Reader into an io.Writer using provided Encoding
func (mw *msgWriter) writeString(s string) { func (mw *msgWriter) writeBody(f func(io.Writer) error, e Encoding) {
if mw.err != nil { var w io.Writer
var ew io.WriteCloser
if mw.d == 0 {
w = mw.w
}
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 return
default:
ew = quotedprintable.NewWriter(w)
} }
var n int
n, mw.err = io.WriteString(mw.w, s) mw.err = f(ew)
mw.n += int64(n) mw.err = ew.Close()
} }