189 lines
5.8 KiB
Go
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
|
|
}
|