Files
warmbly/internal/client/goog/send.go
T
Matthew Meszaros b701e4efab feat: send campaign email attachments
Carry attachment references inside the stored email body blob, resolve bytes on workers, and encode attachments for Gmail API and SMTP sends without changing the Kafka send-email contract.
2026-06-05 06:03:21 +02:00

287 lines
7.9 KiB
Go

package goog
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/textproto"
"strings"
"github.com/warmbly/warmbly/internal/models"
"google.golang.org/api/gmail/v1"
)
// Attachment is a fully-resolved file ready to be MIME-encoded into an outbound
// message. Data is the raw bytes; MimeType drives the Content-Type.
type Attachment struct {
Filename string
MimeType string
Data []byte
}
func (c *Client) SendMessage(
ctx context.Context,
to, cc, bcc []string,
messageID,
subject, bodyPlain, bodyHTML string,
parent *models.EmailMessageData,
attachments []Attachment,
customHeaders ...map[string]string,
) (*gmail.Message, error) {
// Attachments require a multipart/mixed MIME tree, which the structured
// gmail.MessagePart API does not express well (no per-part raw bytes with
// Content-Disposition). Build a raw RFC 5322 message and submit it as
// base64url Raw. The no-attachment path keeps the existing structured form
// so threading/back-compat behavior is unchanged.
if len(attachments) > 0 {
return c.sendRawWithAttachments(to, cc, bcc, messageID, subject, bodyPlain, bodyHTML, parent, attachments, customHeaders...)
}
// Compose headers
headers := []*gmail.MessagePartHeader{
{Name: "From", Value: c.GetAddress()},
{Name: "To", Value: strings.Join(to, ", ")},
{Name: "Subject", Value: subject},
{Name: "Message-ID", Value: messageID},
}
if len(cc) > 0 {
headers = append(headers, &gmail.MessagePartHeader{
Name: "Cc",
Value: strings.Join(cc, ", "),
})
}
if len(bcc) > 0 {
headers = append(headers, &gmail.MessagePartHeader{
Name: "Bcc",
Value: strings.Join(bcc, ", "),
})
}
if parent != nil && parent.MessageID != "" {
// Trim any existing <...> before re-wrapping so we don't emit <<id>>,
// which won't match the original Message-ID header and breaks threading.
mid := "<" + strings.Trim(parent.MessageID, "<>") + ">"
headers = append(headers,
&gmail.MessagePartHeader{Name: "In-Reply-To", Value: mid},
&gmail.MessagePartHeader{Name: "References", Value: mid},
)
}
// Add custom headers (e.g., X-Warmbly-Token for warmup)
if len(customHeaders) > 0 {
for k, v := range customHeaders[0] {
headers = append(headers, &gmail.MessagePartHeader{Name: k, Value: v})
}
}
// Compose parts
var parts []*gmail.MessagePart
// Plain text part
parts = append(parts, &gmail.MessagePart{
MimeType: "text/plain",
Body: &gmail.MessagePartBody{
Data: base64.URLEncoding.EncodeToString([]byte(bodyPlain)),
},
})
// HTML part (optional)
if bodyHTML != "" {
parts = append(parts, &gmail.MessagePart{
MimeType: "text/html",
Body: &gmail.MessagePartBody{
Data: base64.URLEncoding.EncodeToString([]byte(bodyHTML)),
},
})
}
// Full message
msg := &gmail.Message{
Payload: &gmail.MessagePart{
MimeType: "multipart/alternative",
Headers: headers,
Parts: parts,
},
}
// Threading
if parent != nil && parent.ThreadID != "" {
msg.ThreadId = parent.ThreadID
}
// Send via Gmail API
sent, err := c.srv.Users.Messages.Send("me", msg).Do()
if err != nil {
return nil, fmt.Errorf("send message failed: %w", err)
}
return sent, nil
}
// sendRawWithAttachments builds a multipart/mixed RFC 5322 message
// (multipart/alternative for text+html, then one application/* part per
// attachment) and submits it via the Gmail API as base64url-encoded Raw.
func (c *Client) sendRawWithAttachments(
to, cc, bcc []string,
messageID,
subject, bodyPlain, bodyHTML string,
parent *models.EmailMessageData,
attachments []Attachment,
customHeaders ...map[string]string,
) (*gmail.Message, error) {
var hdrs []header
hdrs = append(hdrs,
header{"From", c.GetAddress()},
header{"To", strings.Join(to, ", ")},
header{"Subject", subject},
header{"Message-ID", messageID},
header{"MIME-Version", "1.0"},
)
if len(cc) > 0 {
hdrs = append(hdrs, header{"Cc", strings.Join(cc, ", ")})
}
if len(bcc) > 0 {
hdrs = append(hdrs, header{"Bcc", strings.Join(bcc, ", ")})
}
if parent != nil && parent.MessageID != "" {
mid := "<" + strings.Trim(parent.MessageID, "<>") + ">"
hdrs = append(hdrs, header{"In-Reply-To", mid}, header{"References", mid})
}
if len(customHeaders) > 0 {
for k, v := range customHeaders[0] {
hdrs = append(hdrs, header{k, v})
}
}
raw, err := buildMixedMIME(hdrs, bodyPlain, bodyHTML, attachments)
if err != nil {
return nil, fmt.Errorf("build mime: %w", err)
}
msg := &gmail.Message{
Raw: base64.URLEncoding.EncodeToString(raw),
}
if parent != nil && parent.ThreadID != "" {
msg.ThreadId = parent.ThreadID
}
sent, err := c.srv.Users.Messages.Send("me", msg).Do()
if err != nil {
return nil, fmt.Errorf("send message failed: %w", err)
}
return sent, nil
}
type header struct{ name, value string }
// buildMixedMIME assembles a multipart/mixed message: a multipart/alternative
// (text/plain + optional text/html) followed by one attachment part each. Text
// parts use quoted-printable; attachment parts use base64 with a
// Content-Disposition: attachment header. Shared by the Gmail raw path.
func buildMixedMIME(hdrs []header, bodyPlain, bodyHTML string, attachments []Attachment) ([]byte, error) {
var buf bytes.Buffer
mixed := multipart.NewWriter(&buf)
// Top-level headers + the multipart/mixed Content-Type. These must precede
// the first boundary, so write them before any part is created.
for _, h := range hdrs {
fmt.Fprintf(&buf, "%s: %s\r\n", h.name, h.value)
}
fmt.Fprintf(&buf, "Content-Type: multipart/mixed; boundary=%s\r\n\r\n", mixed.Boundary())
// --- multipart/alternative sub-tree for the text bodies ---
var altBuf bytes.Buffer
alt := multipart.NewWriter(&altBuf)
if err := writeTextPart(alt, "text/plain; charset=UTF-8", bodyPlain); err != nil {
return nil, err
}
if bodyHTML != "" {
if err := writeTextPart(alt, "text/html; charset=UTF-8", 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
}
// --- one attachment part per file ---
for _, a := range attachments {
mimeType := a.MimeType
if mimeType == "" {
mimeType = "application/octet-stream"
}
// RFC 2047-encode the filename for non-ASCII safety.
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
}
}
if err := mixed.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// writeTextPart writes a quoted-printable text body part with the given
// Content-Type into the multipart writer.
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
}
qp := quotedprintable.NewWriter(part)
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
}