package msgraph import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "strings" "github.com/rs/zerolog/log" "github.com/warmbly/warmbly/internal/errx" "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 <>, // 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 "", "", errx.ErrMailServerUnreachable } 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 errx.ErrMailServerUnreachable } 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 errx.ErrMailServerUnreachable } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return HandleError(resp) } _, _ = io.Copy(io.Discard, resp.Body) return nil }