package notify import "bytes" import "context" import "encoding/json" import "fmt" import "io" import "net/http" import "net/url" import "strings" import "codit/internal/models" // msGraphConfig is the non-secret transport config for Microsoft Graph email. // The client_secret comes from the secret blob. Cloud selects the sovereign // endpoint pair. type msGraphConfig struct { TenantID string `json:"tenant_id"` ClientID string `json:"client_id"` Sender string `json:"sender"` Cloud string `json:"cloud"` } // graphEndpoints maps a cloud key to its (authority host, graph host) pair. func graphEndpoints(cloud string) (string, string) { switch strings.TrimSpace(cloud) { case "gcc_high": return "login.microsoftonline.us", "graph.microsoft.us" case "china": return "login.chinacloudapi.cn", "microsoftgraph.chinacloudapi.cn" } return "login.microsoftonline.com", "graph.microsoft.com" } type graphEmailAddress struct { Address string `json:"address"` } type graphRecipient struct { EmailAddress graphEmailAddress `json:"emailAddress"` } type graphBody struct { ContentType string `json:"contentType"` Content string `json:"content"` } type graphMessage struct { Subject string `json:"subject"` Body graphBody `json:"body"` ToRecipients []graphRecipient `json:"toRecipients"` } type graphSendMail struct { Message graphMessage `json:"message"` SaveToSentItems bool `json:"saveToSentItems"` } // buildGraphSendMail assembles the sendMail request body for an event. func buildGraphSendMail(event models.Event, recipients []string) graphSendMail { var to []graphRecipient var r string for _, r = range recipients { to = append(to, graphRecipient{EmailAddress: graphEmailAddress{Address: r}}) } return graphSendMail{ Message: graphMessage{ Subject: headerSafe(event.Title), Body: graphBody{ContentType: "Text", Content: event.Body}, ToRecipients: to, }, SaveToSentItems: false, } } type graphTokenResponse struct { AccessToken string `json:"access_token"` ExpiresIn int64 `json:"expires_in"` TokenType string `json:"token_type"` } // fetchGraphToken performs the OAuth2 client-credentials grant and returns an // access token for the Graph .default scope. func (d *Dispatcher) fetchGraphToken(ctx context.Context, authorityHost string, cfg msGraphConfig, clientSecret string) (string, error) { var tokenURL string var form url.Values var req *http.Request var resp *http.Response var body []byte var tok graphTokenResponse var err error tokenURL = "https://" + authorityHost + "/" + url.PathEscape(strings.TrimSpace(cfg.TenantID)) + "/oauth2/v2.0/token" form = url.Values{} form.Set("client_id", strings.TrimSpace(cfg.ClientID)) form.Set("client_secret", clientSecret) form.Set("scope", "https://"+graphHostForScope(cfg.Cloud)+"/.default") form.Set("grant_type", "client_credentials") req, err = http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) if err != nil { return "", err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err = d.client.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, _ = io.ReadAll(io.LimitReader(resp.Body, 8192)) if resp.StatusCode >= 400 { return "", fmt.Errorf("graph token request failed: status %d", resp.StatusCode) } err = json.Unmarshal(body, &tok) if err != nil { return "", err } if strings.TrimSpace(tok.AccessToken) == "" { return "", fmt.Errorf("graph token response had no access_token") } return tok.AccessToken, nil } // graphHostForScope returns the graph host used to build the .default scope. func graphHostForScope(cloud string) string { var _, graphHost = graphEndpoints(cloud) return graphHost } // deliverMSGraph sends the event as email via the Microsoft Graph sendMail API // using an app-only (client-credentials) token. Dials through the shared // egress-guarded client; Graph endpoints use public CAs so system roots verify. func (d *Dispatcher) deliverMSGraph(ctx context.Context, target models.DeliveryTarget, secrets map[string]string, event models.Event) error { var cfg msGraphConfig var recipients []string var clientSecret string var authorityHost string var graphHost string var token string var sendURL string var payload []byte var req *http.Request var resp *http.Response var err error if strings.TrimSpace(target.Config) != "" { _ = json.Unmarshal([]byte(target.Config), &cfg) } if strings.TrimSpace(cfg.TenantID) == "" || strings.TrimSpace(cfg.ClientID) == "" || strings.TrimSpace(cfg.Sender) == "" { return fmt.Errorf("graph channel %q: transport missing tenant_id/client_id/sender", target.ChannelName) } clientSecret = secrets["client_secret"] if strings.TrimSpace(clientSecret) == "" { return fmt.Errorf("graph channel %q: transport has no client secret", target.ChannelName) } recipients = parseRecipients(target.Address) if len(recipients) == 0 { return fmt.Errorf("graph channel %q has no recipients", target.ChannelName) } authorityHost, graphHost = graphEndpoints(cfg.Cloud) token, err = d.fetchGraphToken(ctx, authorityHost, cfg, clientSecret) if err != nil { return err } sendURL = "https://" + graphHost + "/v1.0/users/" + url.PathEscape(strings.TrimSpace(cfg.Sender)) + "/sendMail" payload, err = json.Marshal(buildGraphSendMail(d.withOriginTitle(event), recipients)) if err != nil { return err } req, err = http.NewRequestWithContext(ctx, http.MethodPost, sendURL, bytes.NewReader(payload)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err = d.client.Do(req) if err != nil { return err } defer resp.Body.Close() _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 8192)) if resp.StatusCode >= 400 { return fmt.Errorf("graph sendMail failed: status %d", resp.StatusCode) } return nil }