Files
warmbly/internal/client/msgraph/send.go
T

202 lines
7.3 KiB
Go

package msgraph
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/mailhdr"
)
// SendMessage sends a message through Graph and returns the RFC 5322
// Message-ID the recipient will actually see.
//
// It creates the message as a draft and then sends the draft, rather than
// posting to /me/sendMail, because Exchange stamps its own internetMessageId
// when the item is created and discards the Message-ID we supply. Mail sent
// with sendMail therefore reaches recipients under an id Warmbly has never
// seen, which leaves warmup tokens unverifiable and campaign replies
// unthreadable. Creating the draft first lets us read that id before the
// message leaves, so the draft MIME deliberately carries no Message-ID of our
// own. messageID is only used by the sendMail fallback below.
//
// The body is still built as RFC 5322 MIME, which is the only Graph shape that
// can carry In-Reply-To/References and arbitrary headers. Note that Exchange
// drops custom headers in transit even so: the verify header rides along for
// the providers that keep it, and is not what verification depends on.
// customHeaders is variadic to mirror goog.Client.SendMessage.
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,
) (string, error) {
raw, err := buildMIME(sendHeaders(c.GetAddress(), to, cc, bcc, "", subject, parent, customHeaders...), bodyPlain, bodyHTML, attachments)
if err != nil {
return "", fmt.Errorf("build mime: %w", err)
}
draftID, assignedID, err := c.createDraft(ctx, raw)
if err != nil {
// Nothing was created, so nothing can be double-sent: fall back to the
// single-shot path with our own Message-ID. The send still lands; only
// the id we learn is lost.
log.Warn().Err(err).Str("email", c.Email).Msg("graph draft creation failed; sending without a readable message id")
fallback, berr := buildMIME(sendHeaders(c.GetAddress(), to, cc, bcc, messageID, subject, parent, customHeaders...), bodyPlain, bodyHTML, attachments)
if berr != nil {
return "", fmt.Errorf("build mime: %w", berr)
}
if serr := c.sendMIME(ctx, fallback); serr != nil {
return "", serr
}
return messageID, nil
}
if assignedID == "" {
assignedID = c.draftMessageID(ctx, draftID)
}
if err := c.sendDraft(ctx, draftID); err != nil {
// The draft is still sitting in Drafts; leaving it there would show up
// in the customer's own mail client as an unsent message.
c.discardDraft(ctx, draftID)
return "", err
}
return assignedID, nil
}
// sendHeaders assembles the top-level RFC 5322 headers for an outbound
// message. A blank messageID omits the header entirely, which is what the
// draft path wants: Exchange then assigns the id and we read it back.
func sendHeaders(from string, to, cc, bcc []string, messageID, subject string, parent *models.EmailMessageData, customHeaders ...map[string]string) []hdr {
hdrs := []hdr{
{"From", from},
{"To", mailhdr.AddressList(to)},
// RFC 2047: a non-ASCII subject or display name has to be encoded or
// it reaches the recipient as mojibake. No-op for plain ASCII.
{"Subject", mailhdr.Subject(subject)},
{"MIME-Version", "1.0"},
}
if messageID != "" {
hdrs = append(hdrs, hdr{"Message-ID", messageID})
}
if len(cc) > 0 {
hdrs = append(hdrs, hdr{"Cc", mailhdr.AddressList(cc)})
}
if len(bcc) > 0 {
hdrs = append(hdrs, hdr{"Bcc", mailhdr.AddressList(bcc)})
}
if parent != nil && parent.MessageID != "" {
// Trim any existing <...> before re-wrapping so we never emit <<id>>,
// which won't match the original Message-ID and breaks threading.
mid := "<" + strings.Trim(parent.MessageID, "<>") + ">"
hdrs = append(hdrs, hdr{"In-Reply-To", mid}, hdr{"References", mid})
}
if len(customHeaders) > 0 {
for k, v := range customHeaders[0] {
hdrs = append(hdrs, hdr{k, v})
}
}
return hdrs
}
// createDraft posts the MIME message as a draft and returns the Graph item id
// plus the internetMessageId Exchange assigned to it.
func (c *Client) createDraft(ctx context.Context, raw []byte) (string, string, error) {
encoded := base64.StdEncoding.EncodeToString(raw)
resp, err := c.do(ctx, http.MethodPost, graphBase+"/me/messages", "text/plain", []byte(encoded))
if err != nil {
return "", "", transportError(err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", "", HandleError(resp)
}
var created struct {
ID string `json:"id"`
InternetMessageID string `json:"internetMessageId"`
}
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
return "", "", fmt.Errorf("decode draft: %w", err)
}
if created.ID == "" {
return "", "", fmt.Errorf("graph created a draft without an id")
}
return created.ID, created.InternetMessageID, nil
}
// draftMessageID re-reads the assigned internetMessageId. Returns "" rather
// than an error: an unknown id costs verification accuracy, not the send.
func (c *Client) draftMessageID(ctx context.Context, draftID string) string {
var msg struct {
InternetMessageID string `json:"internetMessageId"`
}
if err := c.doJSON(ctx, http.MethodGet, c.messageURL(draftID)+"?$select=internetMessageId", nil, &msg); err != nil {
log.Warn().Err(err).Str("email", c.Email).Msg("could not read the message id Graph assigned to the draft")
return ""
}
return msg.InternetMessageID
}
// sendDraft submits a created draft.
func (c *Client) sendDraft(ctx context.Context, draftID string) error {
resp, err := c.do(ctx, http.MethodPost, c.messageURL(draftID)+"/send", "", nil)
if err != nil {
return transportError(err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return HandleError(resp)
}
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
// discardDraft removes a draft whose send failed, so it does not sit in the
// customer's Drafts folder as a message they never wrote.
//
// It confirms the item is still a draft first. A send that failed ambiguously
// (a lost response) may in fact have gone out, and deleting the wrong item
// would take the customer's Sent Items copy with it. Best effort otherwise:
// the send has already failed and will be retried.
func (c *Client) discardDraft(ctx context.Context, draftID string) {
var msg struct {
IsDraft bool `json:"isDraft"`
}
if err := c.doJSON(ctx, http.MethodGet, c.messageURL(draftID)+"?$select=isDraft", nil, &msg); err != nil || !msg.IsDraft {
return
}
resp, err := c.do(ctx, http.MethodDelete, c.messageURL(draftID), "", nil)
if err != nil {
return
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
}
// sendMIME is the single-shot /me/sendMail path, kept as the fallback for when
// a draft cannot be created. Graph auto-files the message in Sent Items and
// returns 202 with no id.
func (c *Client) sendMIME(ctx context.Context, raw []byte) error {
encoded := base64.StdEncoding.EncodeToString(raw)
resp, err := c.do(ctx, http.MethodPost, graphBase+"/me/sendMail", "text/plain", []byte(encoded))
if err != nil {
return transportError(err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return HandleError(resp)
}
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}