Files

189 lines
5.8 KiB
Go

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
}