v0.1.1: Added embeds/attachments

This commit is contained in:
Winni Neessen 2022-03-14 10:29:53 +01:00
parent 13f4c54a27
commit 1157180369
Signed by: wneessen
GPG key ID: 385AC9889632126E
8 changed files with 271 additions and 60 deletions

View file

@ -27,8 +27,8 @@ Some of the features of this library:
* [X] RFC5322 compliant mail address validation * [X] RFC5322 compliant mail address validation
* [X] Support for common mail header field generation (Message-ID, Date, Bulk-Precedence, etc.) * [X] Support for common mail header field generation (Message-ID, Date, Bulk-Precedence, etc.)
* [X] Reusing the same SMTP connection to send multiple mails * [X] Reusing the same SMTP connection to send multiple mails
* [X] Support for attachments and inline embeds
* [ ] Support for different encodings * [ ] Support for different encodings
* [ ] Support for attachments
## Examples ## Examples
The packageis shipped with example code. Check it out on the packages The packageis shipped with example code. Check it out on the packages

View file

@ -83,6 +83,10 @@ var (
// ErrNoActiveConnection should be used when a method is used that requies a server connection // ErrNoActiveConnection should be used when a method is used that requies a server connection
// but is not yet connected // but is not yet connected
ErrNoActiveConnection = errors.New("not connected to SMTP server") ErrNoActiveConnection = errors.New("not connected to SMTP server")
// ErrServerNoUnencoded should be used when 8BIT encoding is selected for a message, but
// the server does not offer 8BITMIME mode
ErrServerNoUnencoded = errors.New("message is 8bit unencoded, but server does not support 8BITMIME")
) )
// NewClient returns a new Session client object // NewClient returns a new Session client object
@ -281,6 +285,11 @@ func (c *Client) Send(ml ...*Msg) error {
return fmt.Errorf("failed to send mail: %w", err) return fmt.Errorf("failed to send mail: %w", err)
} }
for _, m := range ml { for _, m := range ml {
if m.encoding == NoEncoding {
if ok, _ := c.sc.Extension("8BITMIME"); !ok {
return ErrServerNoUnencoded
}
}
f, err := m.GetSender(false) f, err := m.GetSender(false)
if err != nil { if err != nil {
return err return err

View file

@ -1,9 +1,9 @@
package main package main
import ( import (
"flag"
"fmt" "fmt"
"github.com/wneessen/go-mail" "github.com/wneessen/go-mail"
"io"
"os" "os"
) )
@ -27,34 +27,46 @@ func main() {
fmt.Printf("failed to set TO address: %s", err) fmt.Printf("failed to set TO address: %s", err)
os.Exit(1) os.Exit(1)
} }
m.Subject("This is the Subject with Umlauts: üöäß") m.Subject("This is a mail with attachments")
m.SetHeader(mail.HeaderContentLang, "de", "en", "fr", "sp", "de", "en", "fr", "sp", "de", "en", "fr",
"sp", "de", "en", "fr", "sp")
m.SetHeader(mail.HeaderListUnsubscribePost, "üüüüüüüü", "aaaaääää", "ßßßßßßßßß", "XXXXXX", "ZZZZZ", "XXXXXXXX",
"äää äää", "YYYYYY", "XXXXXX", "ZZZZZ", "üäö´")
m.SetMessageID() m.SetMessageID()
m.SetDate() m.SetDate()
m.SetBulk() m.SetBulk()
m.SetBodyString(mail.TypeTextPlain, "This should have an attachment.")
m.SetBodyWriter(mail.TypeTextPlain, func(fw io.Writer) error { f, err := os.Open("/home/wneessen/certs.csv")
_, 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))
if err != nil { if err != nil {
fmt.Printf("failed to create new client: %s\n", err) fmt.Printf("failed to open file for reading: %s\n", err)
os.Exit(1)
}
if err := c.DialAndSend(m); err != nil {
fmt.Printf("failed to dial: %s\n", err)
os.Exit(1) os.Exit(1)
} }
defer func() {
if err := f.Close(); err != nil {
fmt.Printf("failed to close file: %s\n", err)
os.Exit(1)
}
}()
m.AttachReader("certs.csv", f, mail.WithFileName("test.txt"))
sendMail := flag.Bool("send", false, "wether to send mail or output to STDOUT")
flag.Parse()
if !*sendMail {
_, err := m.Write(os.Stdout)
if err != nil {
fmt.Printf("failed to write mail: %s\n", err)
os.Exit(1)
}
} else {
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))
if err != nil {
fmt.Printf("failed to create new client: %s\n", err)
os.Exit(1)
}
if err := c.DialAndSend(m); err != nil {
fmt.Printf("failed to dial: %s\n", err)
os.Exit(1)
}
}
} }

33
file.go Normal file
View file

@ -0,0 +1,33 @@
package mail
import (
"io"
"net/textproto"
)
// FileOption returns a function that can be used for grouping File options
type FileOption func(*File)
// File is an attachment or embedded file of the Msg
type File struct {
Name string
Header textproto.MIMEHeader
Writer func(w io.Writer) error
}
// WithFileName sets the filename of the File
func WithFileName(n string) FileOption {
return func(f *File) {
f.Name = n
}
}
// setHeader sets header fields to a File
func (f *File) setHeader(h Header, v string) {
f.Header.Set(string(h), v)
}
func (f *File) getHeader(h Header) (string, bool) {
v := f.Header.Get(string(h))
return v, v != ""
}

View file

@ -14,6 +14,9 @@ const (
// HeaderContentDisposition is the "Content-Disposition" header // HeaderContentDisposition is the "Content-Disposition" header
HeaderContentDisposition Header = "Content-Disposition" HeaderContentDisposition Header = "Content-Disposition"
// HeaderContentID is the "Content-ID" header
HeaderContentID Header = "Content-ID"
// HeaderContentLang is the "Content-Language" header // HeaderContentLang is the "Content-Language" header
HeaderContentLang Header = "Content-Language" HeaderContentLang Header = "Content-Language"

137
msg.go
View file

@ -9,6 +9,7 @@ import (
"mime" "mime"
"net/mail" "net/mail"
"os" "os"
"path/filepath"
"time" "time"
) )
@ -45,17 +46,13 @@ type Msg struct {
// parts represent the different parts of the Msg // parts represent the different parts of the Msg
parts []*Part parts []*Part
}
// Part is a part of the Msg // attachments represent the different attachment File of the Msg
type Part struct { attachments []*File
w func(io.Writer) error
ctype ContentType
enc Encoding
}
// PartOption returns a function that can be used for grouping Part options // embeds represent the different embedded File of the Msg
type PartOption func(*Part) embeds []*File
}
// 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)
@ -372,7 +369,7 @@ func (m *Msg) SetBodyString(ct ContentType, b string, o ...PartOption) {
// SetBodyWriter sets the body of the message. // SetBodyWriter sets the body of the message.
func (m *Msg) SetBodyWriter(ct ContentType, w func(io.Writer) error, o ...PartOption) { func (m *Msg) SetBodyWriter(ct ContentType, w func(io.Writer) error, o ...PartOption) {
p := m.NewPart(ct, o...) p := m.newPart(ct, o...)
p.w = w p.w = w
m.parts = []*Part{p} m.parts = []*Part{p}
} }
@ -389,11 +386,31 @@ func (m *Msg) AddAlternativeString(ct ContentType, b string, o ...PartOption) {
// AddAlternativeWriter sets the body of the message. // AddAlternativeWriter sets the body of the message.
func (m *Msg) AddAlternativeWriter(ct ContentType, w func(io.Writer) error, o ...PartOption) { func (m *Msg) AddAlternativeWriter(ct ContentType, w func(io.Writer) error, o ...PartOption) {
p := m.NewPart(ct, o...) p := m.newPart(ct, o...)
p.w = w p.w = w
m.parts = append(m.parts, p) m.parts = append(m.parts, p)
} }
// AttachFile adds an attachment File to the Msg
func (m *Msg) AttachFile(n string, o ...FileOption) {
m.attachments = m.appendFile(m.attachments, fileFromFS(n), o...)
}
// AttachReader adds an attachment File via io.Reader to the Msg
func (m *Msg) AttachReader(n string, r io.Reader, o ...FileOption) {
m.attachments = m.appendFile(m.attachments, fileFromReader(n, r), o...)
}
// EmbedFile adds an embedded File to the Msg
func (m *Msg) EmbedFile(n string, o ...FileOption) {
m.embeds = m.appendFile(m.embeds, fileFromFS(n), o...)
}
// EmbedReader adds an embedded File from an io.Reader to the Msg
func (m *Msg) EmbedReader(n string, r io.Reader, o ...FileOption) {
m.embeds = m.appendFile(m.embeds, fileFromReader(n, r), o...)
}
// 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}
@ -401,8 +418,46 @@ 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 // appendFile adds a File to the Msg (as attachment or embed)
func (m *Msg) NewPart(ct ContentType, o ...PartOption) *Part { func (m *Msg) appendFile(c []*File, f *File, o ...FileOption) []*File {
// Override defaults with optionally provided FileOption functions
for _, co := range o {
if co == nil {
continue
}
co(f)
}
if c == nil {
return []*File{f}
}
return append(c, f)
}
// encodeString encodes a string based on the configured message encoder and the corresponding
// charset for the Msg
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
}
// hasMixed returns true if the Msg has mixed parts
func (m *Msg) hasMixed() bool {
return (len(m.parts) > 0 && len(m.attachments) > 0) || len(m.attachments) > 1
}
// hasRelated returns true if the Msg has related parts
func (m *Msg) hasRelated() bool {
return (len(m.parts) > 0 && len(m.embeds) > 0) || len(m.embeds) > 1
}
// newPart returns a new Part for the Msg
func (m *Msg) newPart(ct ContentType, o ...PartOption) *Part {
p := &Part{ p := &Part{
ctype: ct, ctype: ct,
enc: m.encoding, enc: m.encoding,
@ -419,23 +474,44 @@ func (m *Msg) NewPart(ct ContentType, o ...PartOption) *Part {
return 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() {
m.encoder = getEncoder(m.encoding) m.encoder = getEncoder(m.encoding)
} }
// fileFromFS returns a File pointer from a given file in the system's file system
func fileFromFS(n string) *File {
return &File{
Name: filepath.Base(n),
Header: make(map[string][]string),
Writer: func(w io.Writer) error {
h, err := os.Open(n)
if err != nil {
return err
}
if _, err := io.Copy(w, h); err != nil {
_ = h.Close()
return fmt.Errorf("failed to copy file to io.Writer: %w", err)
}
return h.Close()
},
}
}
// fileFromReader returns a File pointer from a given io.Reader
func fileFromReader(n string, r io.Reader) *File {
return &File{
Name: filepath.Base(n),
Header: make(map[string][]string),
Writer: func(w io.Writer) error {
if _, err := io.Copy(w, r); err != nil {
return err
}
return nil
},
}
}
// getEncoder creates a new mime.WordEncoder based on the encoding setting of the message // getEncoder creates a new mime.WordEncoder based on the encoding setting of the message
func getEncoder(e Encoding) mime.WordEncoder { func getEncoder(e Encoding) mime.WordEncoder {
switch e { switch e {
@ -447,14 +523,3 @@ func getEncoder(e Encoding) mime.WordEncoder {
return mime.QEncoding return mime.QEncoding
} }
} }
// encodeString encodes a string based on the configured message encoder and the corresponding
// charset for the Msg
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
}

View file

@ -4,9 +4,11 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io" "io"
"mime"
"mime/multipart" "mime/multipart"
"mime/quotedprintable" "mime/quotedprintable"
"net/textproto" "net/textproto"
"path/filepath"
"strings" "strings"
) )
@ -55,16 +57,37 @@ func (mw *msgWriter) writeMsg(m *Msg) {
} }
mw.writeHeader(HeaderMIMEVersion, string(m.mimever)) mw.writeHeader(HeaderMIMEVersion, string(m.mimever))
if m.hasMixed() {
mw.startMP("mixed", m.boundary)
mw.writeString("\r\n\r\n")
}
if m.hasRelated() {
mw.startMP("related", m.boundary)
mw.writeString("\r\n\r\n")
}
if m.hasAlt() { if m.hasAlt() {
mw.startMP(MIMEAlternative, m.boundary) mw.startMP(MIMEAlternative, m.boundary)
mw.writeString("\r\n\r\n") mw.writeString("\r\n\r\n")
} }
for _, p := range m.parts { for _, p := range m.parts {
mw.writePart(p, m.charset) mw.writePart(p, m.charset)
} }
if m.hasAlt() { if m.hasAlt() {
mw.stopMP() mw.stopMP()
} }
// Add embeds
mw.addFiles(m.embeds, false)
if m.hasRelated() {
mw.stopMP()
}
// Add attachments
mw.addFiles(m.attachments, true)
if m.hasMixed() {
mw.stopMP()
}
} }
// startMP writes a multipart beginning // startMP writes a multipart beginning
@ -94,6 +117,47 @@ func (mw *msgWriter) stopMP() {
} }
} }
// addFiles adds the attachments/embeds file content to the mail body
func (mw *msgWriter) addFiles(fl []*File, a bool) {
for _, f := range fl {
if _, ok := f.getHeader(HeaderContentType); !ok {
mt := mime.TypeByExtension(filepath.Ext(f.Name))
if mt == "" {
mt = "application/octet-stream"
}
f.setHeader(HeaderContentType, fmt.Sprintf(`%s; name="%s"`, mt, f.Name))
}
if _, ok := f.getHeader(HeaderContentTransferEnc); !ok {
f.setHeader(HeaderContentTransferEnc, string(EncodingB64))
}
if _, ok := f.getHeader(HeaderContentDisposition); !ok {
d := "inline"
if a {
d = "attachment"
}
f.setHeader(HeaderContentDisposition, fmt.Sprintf(`%s; filename="%s"`, d, f.Name))
}
if !a {
if _, ok := f.getHeader(HeaderContentID); !ok {
f.setHeader(HeaderContentID, fmt.Sprintf("<%s>", f.Name))
}
}
if mw.d == 0 {
for h, v := range f.Header {
mw.writeHeader(Header(h), v...)
}
mw.writeString("\r\n")
}
if mw.d > 0 {
mw.newPart(f.Header)
}
mw.writeBody(f.Writer, EncodingB64)
}
}
// newPart creates a new MIME multipart io.Writer and sets the partwriter to it // newPart creates a new MIME multipart io.Writer and sets the partwriter to it
func (mw *msgWriter) newPart(h map[string][]string) { func (mw *msgWriter) newPart(h map[string][]string) {
mw.pw, mw.err = mw.mpw[mw.d-1].CreatePart(h) mw.pw, mw.err = mw.mpw[mw.d-1].CreatePart(h)

25
part.go Normal file
View file

@ -0,0 +1,25 @@
package mail
import "io"
// PartOption returns a function that can be used for grouping Part options
type PartOption func(*Part)
// Part is a part of the Msg
type Part struct {
ctype ContentType
enc Encoding
w func(io.Writer) error
}
// SetEncoding creates a new mime.WordEncoder based on the encoding setting of the message
func (p *Part) SetEncoding(e Encoding) {
p.enc = e
}
// WithPartEncoding overrides the default Part encoding
func WithPartEncoding(e Encoding) PartOption {
return func(p *Part) {
p.enc = e
}
}