mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-10 08:06:04 +00:00
269 lines
8.2 KiB
Go
269 lines
8.2 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"
|
|
"github.com/warmbly/warmbly/internal/pkg/mailhdr"
|
|
"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,
|
|
fromName string,
|
|
to, cc, bcc []string,
|
|
messageID,
|
|
subject, bodyPlain, bodyHTML string,
|
|
parent *models.EmailMessageData,
|
|
attachments []Attachment,
|
|
customHeaders ...map[string]string,
|
|
) (*gmail.Message, error) {
|
|
// 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(fromName, to, cc, bcc, messageID, subject, bodyPlain, bodyHTML, parent, attachments, customHeaders...)
|
|
}
|
|
|
|
// 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(
|
|
fromName string,
|
|
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.FromAddress(fromName)},
|
|
header{"To", mailhdr.AddressList(to)},
|
|
// We now own header encoding, so a non-ASCII subject (or recipient
|
|
// display name) has to be RFC 2047-encoded here. Encoding is a no-op
|
|
// on plain ASCII.
|
|
header{"Subject", mailhdr.Subject(subject)},
|
|
header{"Message-ID", messageID},
|
|
header{"MIME-Version", "1.0"},
|
|
)
|
|
if len(cc) > 0 {
|
|
hdrs = append(hdrs, header{"Cc", mailhdr.AddressList(cc)})
|
|
}
|
|
if len(bcc) > 0 {
|
|
hdrs = append(hdrs, header{"Bcc", mailhdr.AddressList(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 := buildMIME(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 }
|
|
|
|
// 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
|
|
// 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.
|
|
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 ---
|
|
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
|
|
}
|