From 4e14df16e276bdf752eadd5e94b186cc563f42b8 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sun, 16 Aug 2026 07:46:08 +0200 Subject: [PATCH] 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) --- internal/client/goog/helper.go | 10 +- internal/client/goog/send.go | 176 +++++++++++------------- internal/client/goog/send_mime_test.go | 179 +++++++++++++++++++++++++ 3 files changed, 264 insertions(+), 101 deletions(-) create mode 100644 internal/client/goog/send_mime_test.go diff --git a/internal/client/goog/helper.go b/internal/client/goog/helper.go index 07e426fa..39e6c811 100644 --- a/internal/client/goog/helper.go +++ b/internal/client/goog/helper.go @@ -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() } diff --git a/internal/client/goog/send.go b/internal/client/goog/send.go index a926dea0..5c1d8610 100644 --- a/internal/client/goog/send.go +++ b/internal/client/goog/send.go @@ -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 <>, - // 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 --- diff --git a/internal/client/goog/send_mime_test.go b/internal/client/goog/send_mime_test.go new file mode 100644 index 00000000..d6c9edec --- /dev/null +++ b/internal/client/goog/send_mime_test.go @@ -0,0 +1,179 @@ +package goog + +import ( + "io" + "mime" + "mime/multipart" + "net/mail" + "strings" + "testing" +) + +func baseHeaders() []header { + return []header{ + {"From", "Jane Doe "}, + {"To", "lead@example.com"}, + {"Subject", "Hello"}, + {"Message-ID", ""}, + {"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 != "" { + t.Errorf("Message-ID = %q", got) + } +} + +func TestBuildMIMEAlternativeWhenHTMLPresent(t *testing.T) { + raw, err := buildMIME(baseHeaders(), "just text", "

rich

", 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", "

rich

", 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 +}