202 lines
5 KiB
Go
202 lines
5 KiB
Go
package mailyak
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"mime/quotedprintable"
|
|
"net/textproto"
|
|
"strings"
|
|
)
|
|
|
|
func (m *MailYak) buildMime(w io.Writer) error {
|
|
mb, err := randomBoundary()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ab, err := randomBoundary()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return m.buildMimeWithBoundaries(w, mb, ab)
|
|
}
|
|
|
|
// randomBoundary returns a random hexadecimal string used for separating MIME
|
|
// parts.
|
|
//
|
|
// The returned string must be sufficiently random to prevent malicious users
|
|
// from performing content injection attacks.
|
|
func randomBoundary() (string, error) {
|
|
buf := make([]byte, 30)
|
|
_, err := io.ReadFull(rand.Reader, buf)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(buf), nil
|
|
}
|
|
|
|
// buildMimeWithBoundaries creates the MIME message using mb and ab as MIME
|
|
// boundaries, and returns the generated MIME data as a buffer.
|
|
func (m *MailYak) buildMimeWithBoundaries(w io.Writer, mb, ab string) error {
|
|
if err := m.writeHeaders(w); err != nil {
|
|
return err
|
|
}
|
|
|
|
var (
|
|
hasBody = m.html.Len() != 0 || m.plain.Len() != 0
|
|
hasAttachments = len(m.attachments) != 0
|
|
)
|
|
|
|
// if we don't have text/html body or attachments we can skip Content-Type header
|
|
// in that case next default will be assumed
|
|
// Content-type: text/plain; charset=us-ascii
|
|
// See https://datatracker.ietf.org/doc/html/rfc2045#page-14
|
|
if !hasBody && !hasAttachments {
|
|
// The RFC said that body can be ommited: https://datatracker.ietf.org/doc/html/rfc822#section-3.1
|
|
// but for example mailhog can't correctly read message without any body.
|
|
// This CRLF just separate header section.
|
|
_, err := fmt.Fprint(w, "\r\n")
|
|
return err
|
|
}
|
|
// Start our multipart/mixed part
|
|
mixed := multipart.NewWriter(w)
|
|
if err := mixed.SetBoundary(mb); err != nil {
|
|
return err
|
|
}
|
|
|
|
// To avoid deferring a mixed.Close(), run the write in a closure and
|
|
// close the mixed after.
|
|
tryWrite := func() error {
|
|
fmt.Fprintf(w, "Content-Type: multipart/mixed;\r\n\tboundary=\"%s\"; charset=UTF-8\r\n\r\n", mixed.Boundary())
|
|
|
|
if hasBody {
|
|
if err := m.writeAlternativePart(mixed, ab); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if hasAttachments {
|
|
if err := m.writeAttachments(mixed, lineSplitterBuilder{}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := tryWrite(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return mixed.Close()
|
|
}
|
|
|
|
// writeHeaders writes the MIME-Version, Date, Reply-To, From, To and Subject headers,
|
|
// plus any custom headers set via AddHeader().
|
|
func (m *MailYak) writeHeaders(w io.Writer) error {
|
|
if _, err := w.Write([]byte(m.fromHeader())); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := w.Write([]byte("MIME-Version: 1.0\r\n")); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Fprintf(w, "Date: %s\r\n", m.date)
|
|
|
|
if m.replyTo != "" {
|
|
fmt.Fprintf(w, "Reply-To: %s\r\n", m.replyTo)
|
|
}
|
|
|
|
fmt.Fprintf(w, "Subject: %s\r\n", m.subject)
|
|
|
|
if len(m.toAddrs) > 0 {
|
|
commaSeparatedToAddrs := strings.Join(m.toAddrs, ",")
|
|
fmt.Fprintf(w, "To: %s\r\n", commaSeparatedToAddrs)
|
|
}
|
|
|
|
if len(m.ccAddrs) > 0 {
|
|
commaSeparatedCCAddrs := strings.Join(m.ccAddrs, ",")
|
|
fmt.Fprintf(w, "CC: %s\r\n", commaSeparatedCCAddrs)
|
|
}
|
|
|
|
if m.writeBccHeader && len(m.bccAddrs) > 0 {
|
|
commaSeparatedBCCAddrs := strings.Join(m.bccAddrs, ",")
|
|
fmt.Fprintf(w, "BCC: %s\r\n", commaSeparatedBCCAddrs)
|
|
}
|
|
|
|
for k, values := range m.headers {
|
|
for _, v := range values {
|
|
fmt.Fprintf(w, "%s: %s\r\n", k, v)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// fromHeader returns a correctly formatted From header, optionally with a name
|
|
// component.
|
|
func (m *MailYak) fromHeader() string {
|
|
if m.fromName == "" {
|
|
return fmt.Sprintf("From: %s\r\n", m.fromAddr)
|
|
}
|
|
|
|
return fmt.Sprintf("From: %s <%s>\r\n", m.fromName, m.fromAddr)
|
|
}
|
|
|
|
func (m *MailYak) writeAlternativePart(mixed *multipart.Writer, boundary string) error {
|
|
ctype := fmt.Sprintf("multipart/alternative;\r\n\tboundary=\"%s\"", boundary)
|
|
|
|
altPart, err := mixed.CreatePart(textproto.MIMEHeader{"Content-Type": {ctype}})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return m.writeBody(altPart, boundary)
|
|
}
|
|
|
|
// writeBody writes the text/plain and text/html mime parts.
|
|
// It's incorrect to call writeBody without html or plain content.
|
|
func (m *MailYak) writeBody(w io.Writer, boundary string) error {
|
|
alt := multipart.NewWriter(w)
|
|
|
|
if err := alt.SetBoundary(boundary); err != nil {
|
|
return err
|
|
}
|
|
|
|
var err error
|
|
writePart := func(ctype string, data []byte) {
|
|
if len(data) == 0 || err != nil {
|
|
return
|
|
}
|
|
|
|
c := fmt.Sprintf("%s; charset=UTF-8", ctype)
|
|
|
|
var part io.Writer
|
|
part, err = alt.CreatePart(textproto.MIMEHeader{"Content-Type": {c}, "Content-Transfer-Encoding": {"quoted-printable"}})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
qpw := quotedprintable.NewWriter(&buf)
|
|
_, _ = qpw.Write(data)
|
|
_ = qpw.Close()
|
|
|
|
_, err = part.Write(buf.Bytes())
|
|
}
|
|
|
|
writePart("text/plain", m.plain.Bytes())
|
|
writePart("text/html", m.html.Bytes())
|
|
|
|
// If closing the alt fails, and there's not already an error set, return the
|
|
// close error.
|
|
if closeErr := alt.Close(); err == nil && closeErr != nil {
|
|
return closeErr
|
|
}
|
|
|
|
return err
|
|
}
|