package main import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "strings" ) type notificationHTTPClient interface { Do(req *http.Request) (*http.Response, error) } type notificationProviderProfile struct { Provider string APIKey string FromEmail string APIBaseURL string } type sendGridNotificationProvider struct { apiKey string fromEmail string apiBaseURL string httpClient notificationHTTPClient } type sendGridEmailAddress struct { Email string `json:"email"` } type sendGridPersonalization struct { To []sendGridEmailAddress `json:"to"` DynamicTemplateData map[string]any `json:"dynamic_template_data,omitempty"` } type sendGridContent struct { Type string `json:"type"` Value string `json:"value"` } type sendGridMailPayload struct { Personalizations []sendGridPersonalization `json:"personalizations"` From sendGridEmailAddress `json:"from"` Subject string `json:"subject,omitempty"` Content []sendGridContent `json:"content,omitempty"` TemplateID string `json:"template_id,omitempty"` } func newNotificationProvider(cfg config) notificationProvider { return newNotificationProviderFromProfile(notificationProviderProfile{ Provider: cfg.NotificationProvider, APIKey: cfg.SendGridAPIKey, FromEmail: cfg.SendGridFromEmail, APIBaseURL: cfg.SendGridAPIBaseURL, }) } func newNotificationProviderFromProfile(profile notificationProviderProfile) notificationProvider { provider := strings.TrimSpace(strings.ToLower(profile.Provider)) if provider == "" || provider == "noop" { return noopNotificationProvider{} } if provider != "sendgrid" { log.Printf("unknown NOTIFICATION_PROVIDER=%q; falling back to noop", profile.Provider) return noopNotificationProvider{} } apiKey := strings.TrimSpace(profile.APIKey) fromEmail := strings.TrimSpace(profile.FromEmail) if apiKey == "" || fromEmail == "" { log.Printf("sendgrid provider selected but missing SENDGRID_API_KEY or SENDGRID_FROM_EMAIL; falling back to noop") return noopNotificationProvider{} } apiBaseURL := strings.TrimSpace(profile.APIBaseURL) if apiBaseURL == "" { apiBaseURL = "https://api.sendgrid.com" } return sendGridNotificationProvider{ apiKey: apiKey, fromEmail: fromEmail, apiBaseURL: strings.TrimRight(apiBaseURL, "/"), httpClient: http.DefaultClient, } } func (s sendGridNotificationProvider) Metadata() notificationProviderMetadata { return notificationProviderMetadata{ Name: "sendgrid", Capabilities: notificationProviderCapabilities{ Email: true, Templates: true, Webhooks: true, Events: true, }, } } func (s sendGridNotificationProvider) Send(ctx context.Context, request notificationRequest) (notificationProviderResult, error) { if strings.TrimSpace(strings.ToLower(request.Channel)) != "email" { return notificationProviderResult{}, errors.New("sendgrid provider supports email channel only") } if strings.TrimSpace(request.To) == "" { return notificationProviderResult{}, errors.New("notification destination is required") } payload := sendGridMailPayload{ Personalizations: []sendGridPersonalization{ { To: []sendGridEmailAddress{{Email: strings.TrimSpace(request.To)}}, DynamicTemplateData: request.Metadata, }, }, From: sendGridEmailAddress{Email: s.fromEmail}, Subject: strings.TrimSpace(request.Subject), TemplateID: strings.TrimSpace(request.TemplateID), } if strings.TrimSpace(request.Body) != "" { payload.Content = []sendGridContent{{Type: "text/plain", Value: request.Body}} } body, err := json.Marshal(payload) if err != nil { return notificationProviderResult{}, fmt.Errorf("marshal sendgrid payload: %w", err) } endpoint := strings.TrimRight(s.apiBaseURL, "/") + "/v3/mail/send" req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return notificationProviderResult{}, fmt.Errorf("create sendgrid request: %w", err) } req.Header.Set("Authorization", "Bearer "+s.apiKey) req.Header.Set("Content-Type", "application/json") client := s.httpClient if client == nil { client = http.DefaultClient } res, err := client.Do(req) if err != nil { return notificationProviderResult{}, fmt.Errorf("sendgrid request failed: %w", err) } defer res.Body.Close() if res.StatusCode < 200 || res.StatusCode > 299 { raw, _ := io.ReadAll(io.LimitReader(res.Body, 8192)) return notificationProviderResult{}, fmt.Errorf("sendgrid send failed: status=%d body=%s", res.StatusCode, strings.TrimSpace(string(raw))) } messageID := strings.TrimSpace(res.Header.Get("X-Message-Id")) if messageID == "" { messageID = "sendgrid-accepted" } return notificationProviderResult{ ProviderMessageID: messageID, Status: "queued", }, nil }