mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 16:02:48 +00:00
202 lines
7.3 KiB
Go
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
|
|
}
|