feat: send every Gmail message as raw RFC 5322 instead of the structured gmail.MessagePart payload, which Gmail's users.messages.send rejects outright with "'raw' RFC822 payload message string or uploading message via /upload/* URL required" because the structured Payload tree is the read representation returned by messages.get and is not accepted on send, so every non-attachment Gmail send failed with a 400 that was retried and then dead-lettered under a misleading SERVER_UNREACHABLE label while the attachment path already built raw correctly, building the narrowest correct MIME structure per message rather than routing everything through the multipart/mixed attachment builder (bare text/plain for warmup and text-only campaigns, multipart/alternative once there is an HTML body, multipart/mixed only when files are attached, because a needlessly nested tree is a structural difference cold outreach does not need), RFC 2047-encoding the Subject and building the From header through net/mail.Address now that header encoding is ours rather than the API's, so a non-ASCII display name is no longer emitted as bare 8-bit bytes and a name containing a comma no longer splits the header into two recipients, and adding tests that parse the built message back with net/mail and mime/multipart to assert the structure instead of matching strings (#119)

This commit is contained in:
Matthew Meszaros
2026-08-16 07:46:08 +02:00
committed by GitHub
parent 16b672e6f6
commit 4e14df16e2
3 changed files with 264 additions and 101 deletions
+8 -2
View File
@@ -1,10 +1,16 @@
package goog
import (
"fmt"
"net/mail"
"strings"
)
// GetAddress is the From header. mail.Address does the RFC 5322 quoting and
// RFC 2047 encoding a display name needs: sprintf-ing it together emits a bare
// 8-bit name for "Renée", and silently splits the address into two recipients
// for "Doe, Jane". Both matter now that every send builds its own headers.
func (c *Client) GetAddress() string {
return fmt.Sprintf("%s <%s>", strings.TrimSpace(c.FirstName+" "+c.LastName), c.Email)
name := strings.TrimSpace(c.FirstName + " " + c.LastName)
addr := mail.Address{Name: name, Address: c.Email}
return addr.String()
}
+77 -99
View File
@@ -33,102 +33,17 @@ func (c *Client) SendMessage(
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
// Gmail's users.messages.send only ever accepts the base64url raw RFC 5322
// form. The structured gmail.MessagePart/Payload tree is what the API
// returns when you READ a parsed message; submitting one to Send is
// rejected outright with "'raw' RFC822 payload message string or uploading
// message via /upload/* URL required". Every send goes through raw.
return c.sendRaw(to, cc, bcc, messageID, subject, bodyPlain, bodyHTML, parent, attachments, customHeaders...)
}
// 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(
// sendRaw builds an RFC 5322 message and submits it as base64url-encoded Raw,
// which is the only body Gmail's Send endpoint accepts.
func (c *Client) sendRaw(
to, cc, bcc []string,
messageID,
subject, bodyPlain, bodyHTML string,
@@ -140,7 +55,9 @@ func (c *Client) sendRawWithAttachments(
hdrs = append(hdrs,
header{"From", c.GetAddress()},
header{"To", strings.Join(to, ", ")},
header{"Subject", subject},
// We now own header encoding, so a non-ASCII subject has to be
// RFC 2047-encoded here. Encode is a no-op on a plain ASCII subject.
header{"Subject", mime.QEncoding.Encode("utf-8", subject)},
header{"Message-ID", messageID},
header{"MIME-Version", "1.0"},
)
@@ -160,7 +77,7 @@ func (c *Client) sendRawWithAttachments(
}
}
raw, err := buildMixedMIME(hdrs, bodyPlain, bodyHTML, attachments)
raw, err := buildMIME(hdrs, bodyPlain, bodyHTML, attachments)
if err != nil {
return nil, fmt.Errorf("build mime: %w", err)
}
@@ -181,6 +98,69 @@ func (c *Client) sendRawWithAttachments(
type header struct{ name, value string }
// buildMIME assembles the message body using the narrowest structure the
// content actually needs: a bare text/plain when that is all there is, a
// multipart/alternative once there is an HTML body, and a multipart/mixed
// wrapper only when there are attachments. Wrapping every message in
// multipart/mixed would be valid but is not what a mail client produces, and
// cold outreach has no room for gratuitous structural differences.
func buildMIME(hdrs []header, bodyPlain, bodyHTML string, attachments []Attachment) ([]byte, error) {
switch {
case len(attachments) > 0:
return buildMixedMIME(hdrs, bodyPlain, bodyHTML, attachments)
case bodyHTML == "":
return buildPlainMIME(hdrs, bodyPlain)
default:
return buildAlternativeMIME(hdrs, bodyPlain, bodyHTML)
}
}
// writeHeaders emits the top-level headers. They must precede the body and any
// multipart boundary, so this always runs before a part is created.
func writeHeaders(buf *bytes.Buffer, hdrs []header) {
for _, h := range hdrs {
fmt.Fprintf(buf, "%s: %s\r\n", h.name, h.value)
}
}
// buildPlainMIME is the single-part form used by warmup mail and any campaign
// with no HTML body.
func buildPlainMIME(hdrs []header, bodyPlain string) ([]byte, error) {
var buf bytes.Buffer
writeHeaders(&buf, hdrs)
fmt.Fprint(&buf, "Content-Type: text/plain; charset=UTF-8\r\n")
fmt.Fprint(&buf, "Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qp := quotedprintable.NewWriter(&buf)
if _, err := qp.Write([]byte(bodyPlain)); err != nil {
return nil, err
}
if err := qp.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// buildAlternativeMIME is the text + HTML form used by a normal campaign send.
func buildAlternativeMIME(hdrs []header, bodyPlain, bodyHTML string) ([]byte, error) {
var buf bytes.Buffer
alt := multipart.NewWriter(&buf)
writeHeaders(&buf, hdrs)
fmt.Fprintf(&buf, "Content-Type: multipart/alternative; boundary=%s\r\n\r\n", alt.Boundary())
if err := writeTextPart(alt, "text/plain; charset=UTF-8", bodyPlain); err != nil {
return nil, err
}
if err := writeTextPart(alt, "text/html; charset=UTF-8", bodyHTML); err != nil {
return nil, err
}
if err := alt.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// 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
@@ -192,9 +172,7 @@ func buildMixedMIME(hdrs []header, bodyPlain, bodyHTML string, attachments []Att
// 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)
}
writeHeaders(&buf, hdrs)
fmt.Fprintf(&buf, "Content-Type: multipart/mixed; boundary=%s\r\n\r\n", mixed.Boundary())
// --- multipart/alternative sub-tree for the text bodies ---
+179
View File
@@ -0,0 +1,179 @@
package goog
import (
"io"
"mime"
"mime/multipart"
"net/mail"
"strings"
"testing"
)
func baseHeaders() []header {
return []header{
{"From", "Jane Doe <jane@example.com>"},
{"To", "lead@example.com"},
{"Subject", "Hello"},
{"Message-ID", "<abc@example.com>"},
{"MIME-Version", "1.0"},
}
}
// parseMIME reads the built message back the way a receiving MTA would, so the
// tests assert on a parsed message rather than on string fragments.
func parseMIME(t *testing.T, raw []byte) (*mail.Message, string, map[string]string) {
t.Helper()
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
if err != nil {
t.Fatalf("the built message does not parse as RFC 5322: %v", err)
}
mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
if err != nil {
t.Fatalf("Content-Type does not parse: %v", err)
}
return msg, mediaType, params
}
// A message with no HTML and no attachments should be a bare text/plain, not a
// multipart wrapper around a single part.
func TestBuildMIMEPlainOnly(t *testing.T) {
raw, err := buildMIME(baseHeaders(), "just text", "", nil)
if err != nil {
t.Fatalf("buildMIME: %v", err)
}
msg, mediaType, _ := parseMIME(t, raw)
if mediaType != "text/plain" {
t.Errorf("got Content-Type %q, want text/plain", mediaType)
}
if got := msg.Header.Get("Message-ID"); got != "<abc@example.com>" {
t.Errorf("Message-ID = %q", got)
}
}
func TestBuildMIMEAlternativeWhenHTMLPresent(t *testing.T) {
raw, err := buildMIME(baseHeaders(), "just text", "<p>rich</p>", nil)
if err != nil {
t.Fatalf("buildMIME: %v", err)
}
msg, mediaType, params := parseMIME(t, raw)
if mediaType != "multipart/alternative" {
t.Fatalf("got Content-Type %q, want multipart/alternative", mediaType)
}
types := partTypes(t, msg.Body, params["boundary"])
want := []string{"text/plain", "text/html"}
if len(types) != len(want) {
t.Fatalf("got %d parts (%v), want %v", len(types), types, want)
}
for i := range want {
if types[i] != want[i] {
t.Errorf("part %d is %q, want %q", i, types[i], want[i])
}
}
}
// Attachments are the only case that needs the multipart/mixed wrapper.
func TestBuildMIMEMixedWhenAttachmentsPresent(t *testing.T) {
att := []Attachment{{Filename: "a.txt", MimeType: "text/plain", Data: []byte("hi")}}
raw, err := buildMIME(baseHeaders(), "text", "<p>rich</p>", att)
if err != nil {
t.Fatalf("buildMIME: %v", err)
}
msg, mediaType, params := parseMIME(t, raw)
if mediaType != "multipart/mixed" {
t.Fatalf("got Content-Type %q, want multipart/mixed", mediaType)
}
types := partTypes(t, msg.Body, params["boundary"])
want := []string{"multipart/alternative", "text/plain"}
if len(types) != len(want) {
t.Fatalf("got %d parts (%v), want %v", len(types), types, want)
}
for i := range want {
if types[i] != want[i] {
t.Errorf("part %d is %q, want %q", i, types[i], want[i])
}
}
}
// Every send now builds its own headers, so a non-ASCII subject has to be
// RFC 2047-encoded rather than emitted as raw 8-bit bytes.
func TestSubjectEncoding(t *testing.T) {
encoded := mime.QEncoding.Encode("utf-8", "Café renovation")
if !strings.HasPrefix(encoded, "=?utf-8?") {
t.Fatalf("non-ASCII subject was not encoded: %q", encoded)
}
hdrs := append(baseHeaders()[:2], header{"Subject", encoded}, header{"MIME-Version", "1.0"})
raw, err := buildMIME(hdrs, "body", "", nil)
if err != nil {
t.Fatalf("buildMIME: %v", err)
}
msg, _, _ := parseMIME(t, raw)
got, err := new(mime.WordDecoder).DecodeHeader(msg.Header.Get("Subject"))
if err != nil {
t.Fatalf("decode subject: %v", err)
}
if got != "Café renovation" {
t.Errorf("subject round-tripped as %q", got)
}
// An ASCII subject must not be needlessly encoded.
if plain := mime.QEncoding.Encode("utf-8", "Hello"); plain != "Hello" {
t.Errorf("ASCII subject was encoded to %q", plain)
}
}
// A display name with a comma splits the From header into two addresses unless
// it is quoted, and a non-ASCII one has to be encoded.
func TestGetAddressQuotesAndEncodes(t *testing.T) {
c := &Client{FirstName: "Doe,", LastName: "Jane", Email: "jane@example.com"}
addrs, err := mail.ParseAddressList(c.GetAddress())
if err != nil {
t.Fatalf("From does not parse: %v", err)
}
if len(addrs) != 1 {
t.Fatalf("From parsed as %d addresses, want 1", len(addrs))
}
if addrs[0].Address != "jane@example.com" {
t.Errorf("address = %q", addrs[0].Address)
}
accented := &Client{FirstName: "Renée", LastName: "", Email: "renee@example.com"}
parsed, err := mail.ParseAddress(accented.GetAddress())
if err != nil {
t.Fatalf("accented From does not parse: %v", err)
}
if parsed.Name != "Renée" {
t.Errorf("display name round-tripped as %q", parsed.Name)
}
}
func partTypes(t *testing.T, body io.Reader, boundary string) []string {
t.Helper()
if boundary == "" {
t.Fatal("multipart Content-Type carried no boundary")
}
var types []string
mr := multipart.NewReader(body, boundary)
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("reading parts: %v", err)
}
mt, _, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
if err != nil {
t.Fatalf("part Content-Type does not parse: %v", err)
}
types = append(types, mt)
}
return types
}