mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-25 00:00:58 +00:00
159 lines
4.7 KiB
Go
159 lines
4.7 KiB
Go
package msgraph
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"mime/quotedprintable"
|
|
"net/textproto"
|
|
)
|
|
|
|
// Attachment is a fully-resolved file the worker has already fetched from object
|
|
// storage, ready to be MIME-encoded.
|
|
type Attachment struct {
|
|
Filename string
|
|
MimeType string
|
|
Data []byte
|
|
}
|
|
|
|
type hdr struct{ name, value string }
|
|
|
|
// buildMIME assembles a complete RFC 5322 message from the given top-level
|
|
// headers and bodies. This is the only reliable way to control Message-ID,
|
|
// In-Reply-To/References, and custom warmup/unsubscribe headers on Graph, which
|
|
// the JSON sendMail shape cannot set. Body HTML (already carrying the tracking
|
|
// pixel + rewritten links) is preserved verbatim. Shapes emitted:
|
|
// - text/plain only -> single text/plain part (warmup sends)
|
|
// - text/plain + text/html -> multipart/alternative
|
|
// - + attachments -> multipart/mixed(alternative, files...)
|
|
func buildMIME(hdrs []hdr, bodyPlain, bodyHTML string, attachments []Attachment) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
writeHeaders := func(extra ...hdr) {
|
|
for _, h := range hdrs {
|
|
fmt.Fprintf(&buf, "%s: %s\r\n", h.name, h.value)
|
|
}
|
|
for _, h := range extra {
|
|
fmt.Fprintf(&buf, "%s: %s\r\n", h.name, h.value)
|
|
}
|
|
}
|
|
|
|
// Plain-only: no multipart wrapper needed.
|
|
if len(attachments) == 0 && bodyHTML == "" {
|
|
writeHeaders(hdr{"Content-Type", "text/plain; charset=UTF-8"}, hdr{"Content-Transfer-Encoding", "quoted-printable"})
|
|
buf.WriteString("\r\n")
|
|
if err := writeQuotedPrintable(&buf, bodyPlain); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// Text + HTML with no attachments: a single multipart/alternative.
|
|
if len(attachments) == 0 {
|
|
alt := multipart.NewWriter(&buf)
|
|
writeHeaders(hdr{"Content-Type", fmt.Sprintf("multipart/alternative; boundary=%s", alt.Boundary())})
|
|
buf.WriteString("\r\n")
|
|
if err := writeAltBodies(alt, bodyPlain, bodyHTML); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), alt.Close()
|
|
}
|
|
|
|
// Attachments present: multipart/mixed wrapping the alternative + one part
|
|
// per file.
|
|
mixed := multipart.NewWriter(&buf)
|
|
writeHeaders(hdr{"Content-Type", fmt.Sprintf("multipart/mixed; boundary=%s", mixed.Boundary())})
|
|
buf.WriteString("\r\n")
|
|
|
|
var altBuf bytes.Buffer
|
|
alt := multipart.NewWriter(&altBuf)
|
|
if err := writeAltBodies(alt, bodyPlain, bodyHTML); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := alt.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
altPart, err := mixed.CreatePart(textproto.MIMEHeader{
|
|
"Content-Type": {fmt.Sprintf("multipart/alternative; boundary=%s", alt.Boundary())},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := altPart.Write(altBuf.Bytes()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, a := range attachments {
|
|
mimeType := a.MimeType
|
|
if mimeType == "" {
|
|
mimeType = "application/octet-stream"
|
|
}
|
|
fn := mime.QEncoding.Encode("utf-8", a.Filename)
|
|
part, perr := mixed.CreatePart(textproto.MIMEHeader{
|
|
"Content-Type": {fmt.Sprintf("%s; name=%q", mimeType, fn)},
|
|
"Content-Transfer-Encoding": {"base64"},
|
|
"Content-Disposition": {fmt.Sprintf("attachment; filename=%q", fn)},
|
|
})
|
|
if perr != nil {
|
|
return nil, perr
|
|
}
|
|
if werr := writeBase64Wrapped(part, a.Data); werr != nil {
|
|
return nil, werr
|
|
}
|
|
}
|
|
|
|
return buf.Bytes(), mixed.Close()
|
|
}
|
|
|
|
// writeAltBodies writes the text/plain (and optional text/html) parts of a
|
|
// multipart/alternative body, quoted-printable encoded.
|
|
func writeAltBodies(w *multipart.Writer, bodyPlain, bodyHTML string) error {
|
|
if err := writeTextPart(w, "text/plain; charset=UTF-8", bodyPlain); err != nil {
|
|
return err
|
|
}
|
|
if bodyHTML != "" {
|
|
if err := writeTextPart(w, "text/html; charset=UTF-8", bodyHTML); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeTextPart(w *multipart.Writer, contentType, body string) error {
|
|
part, err := w.CreatePart(textproto.MIMEHeader{
|
|
"Content-Type": {contentType},
|
|
"Content-Transfer-Encoding": {"quoted-printable"},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeQuotedPrintable(part, body)
|
|
}
|
|
|
|
func writeQuotedPrintable(w io.Writer, body string) error {
|
|
qp := quotedprintable.NewWriter(w)
|
|
if _, err := qp.Write([]byte(body)); err != nil {
|
|
return err
|
|
}
|
|
return qp.Close()
|
|
}
|
|
|
|
// writeBase64Wrapped writes data as base64 hard-wrapped at 76 columns per RFC
|
|
// 2045 so strict MTAs accept the message.
|
|
func writeBase64Wrapped(w io.Writer, data []byte) error {
|
|
encoded := base64.StdEncoding.EncodeToString(data)
|
|
const lineLen = 76
|
|
for i := 0; i < len(encoded); i += lineLen {
|
|
end := i + lineLen
|
|
if end > len(encoded) {
|
|
end = len(encoded)
|
|
}
|
|
if _, err := w.Write([]byte(encoded[i:end] + "\r\n")); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|