mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-22 00:02:23 +00:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user