mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-11 08:06:13 +00:00
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.
287 lines
7.9 KiB
Go
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
|
|
}
|