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] Support for common mail header field generation (Message-ID, Date, Bulk-Precedence, etc.)
* [X] Reusing the same SMTP connection to send multiple mails
* [X] Support for attachments and inline embeds
* [ ] Support for different encodings
* [ ] Support for attachments
## Examples
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
// but is not yet connected
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
@ -281,6 +285,11 @@ func (c *Client) Send(ml ...*Msg) error {
return fmt.Errorf("failed to send mail: %w", err)
}
for _, m := range ml {
if m.encoding == NoEncoding {
if ok, _ := c.sc.Extension("8BITMIME"); !ok {
return ErrServerNoUnencoded
}
}
f, err := m.GetSender(false)
if err != nil {
return err

View file

@ -1,9 +1,9 @@
package main
import (
"flag"
"fmt"
"github.com/wneessen/go-mail"
"io"
"os"
)
@ -27,22 +27,34 @@ func main() {
fmt.Printf("failed to set TO address: %s", err)
os.Exit(1)
}
m.Subject("This is the Subject with Umlauts: üöäß")
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.Subject("This is a mail with attachments")
m.SetMessageID()
m.SetDate()
m.SetBulk()
m.SetBodyString(mail.TypeTextPlain, "This should have an attachment.")
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)
f, err := os.Open("/home/wneessen/certs.csv")
if err != nil {
fmt.Printf("failed to open file for reading: %s\n", err)
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),
@ -56,5 +68,5 @@ func main() {
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 Header = "Content-Disposition"
// HeaderContentID is the "Content-ID" header
HeaderContentID Header = "Content-ID"
// HeaderContentLang is the "Content-Language" header
HeaderContentLang Header = "Content-Language"

137
msg.go
View file

@ -9,6 +9,7 @@ import (
"mime"
"net/mail"
"os"
"path/filepath"
"time"
)
@ -45,17 +46,13 @@ type Msg struct {
// 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
ctype ContentType
enc Encoding
}
// attachments represent the different attachment File of the Msg
attachments []*File
// PartOption returns a function that can be used for grouping Part options
type PartOption func(*Part)
// embeds represent the different embedded File of the Msg
embeds []*File
}
// MsgOption returns a function that can be used for grouping Msg options
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.
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
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.
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
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
func (m *Msg) Write(w io.Writer) (int64, error) {
mw := &msgWriter{w: w}
@ -401,8 +418,46 @@ 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 {
// appendFile adds a File to the Msg (as attachment or embed)
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{
ctype: ct,
enc: m.encoding,
@ -419,23 +474,44 @@ func (m *Msg) NewPart(ct ContentType, o ...PartOption) *Part {
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() {
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
func getEncoder(e Encoding) mime.WordEncoder {
switch e {
@ -447,14 +523,3 @@ func getEncoder(e Encoding) mime.WordEncoder {
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"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/textproto"
"path/filepath"
"strings"
)
@ -55,16 +57,37 @@ func (mw *msgWriter) writeMsg(m *Msg) {
}
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() {
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()
}
// 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
@ -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
func (mw *msgWriter) newPart(h map[string][]string) {
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
}
}