From c9aa339b66ddaa00c9e6f40b7ba2f34fd60bb48b Mon Sep 17 00:00:00 2001 From: Gefferson Ferraz Date: Wed, 7 Oct 2026 20:37:30 -0300 Subject: [PATCH] feat(alerting): add Resend alerting provider (#1830) Add a new `resend` alerting provider that sends email alerts through the Resend API (https://resend.com), as an HTTP-based alternative to the SMTP `email` provider. - Supports api-key, from, to (comma-separated), client, default-alert and group overrides, as well as alert-level provider overrides - Validates that at most 50 recipients are configured (Resend API limit) - Sends plain text emails using the same subject/body format as the `email` provider This commit was made by an AI agent (Claude Code, Claude Opus 5.5). Co-authored-by: Claude Opus 5.5 --- README.md | 42 ++ alerting/alert/type.go | 3 + alerting/config.go | 4 + alerting/provider/provider.go | 3 + alerting/provider/resend/resend.go | 233 +++++++++++ alerting/provider/resend/resend_test.go | 500 ++++++++++++++++++++++++ config/config.go | 1 + config/config_test.go | 3 + 8 files changed, 789 insertions(+) create mode 100644 alerting/provider/resend/resend.go create mode 100644 alerting/provider/resend/resend_test.go diff --git a/README.md b/README.md index 72da5ca0..f7be766a 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga - [Configuring PagerDuty alerts](#configuring-pagerduty-alerts) - [Configuring Plivo alerts](#configuring-plivo-alerts) - [Configuring Pushover alerts](#configuring-pushover-alerts) + - [Configuring Resend alerts](#configuring-resend-alerts) - [Configuring Rocket.Chat alerts](#configuring-rocketchat-alerts) - [Configuring SendGrid alerts](#configuring-sendgrid-alerts) - [Configuring Signal alerts](#configuring-signal-alerts) @@ -863,6 +864,7 @@ endpoints: | `alerting.pagerduty` | Configuration for alerts of type `pagerduty`.
See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` | | `alerting.plivo` | Configuration for alerts of type `plivo`.
See [Configuring Plivo alerts](#configuring-plivo-alerts). | `{}` | | `alerting.pushover` | Configuration for alerts of type `pushover`.
See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` | +| `alerting.resend` | Configuration for alerts of type `resend`.
See [Configuring Resend alerts](#configuring-resend-alerts). | `{}` | | `alerting.rocketchat` | Configuration for alerts of type `rocketchat`.
See [Configuring Rocket.Chat alerts](#configuring-rocketchat-alerts). | `{}` | | `alerting.sendgrid` | Configuration for alerts of type `sendgrid`.
See [Configuring SendGrid alerts](#configuring-sendgrid-alerts). | `{}` | | `alerting.signal` | Configuration for alerts of type `signal`.
See [Configuring Signal alerts](#configuring-signal-alerts). | `{}` | @@ -1990,6 +1992,46 @@ endpoints: ``` +#### Configuring Resend alerts +| Parameter | Description | Default | +|:------------------------------------|:----------------------------------------------------------------------------------------------------|:--------------| +| `alerting.resend` | Configuration for alerts of type `resend` | `{}` | +| `alerting.resend.api-key` | Resend API key | Required `""` | +| `alerting.resend.from` | Email address to send from (e.g. `alerts@example.com` or `Gatus `) | Required `""` | +| `alerting.resend.to` | Email address(es) to send alerts to (comma-separated for multiple recipients, up to 50) | Required `""` | +| `alerting.resend.client` | Client configuration.
See [Client configuration](#client-configuration). | `{}` | +| `alerting.resend.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.resend.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.resend.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.resend.overrides[].*` | See `alerting.resend.*` parameters | `{}` | + +The `from` address must belong to a domain that has been verified in your Resend account. + +```yaml +alerting: + resend: + api-key: "re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + from: "Gatus " + to: "admin@example.com,ops@example.com" + # You can also add group-specific to keys, which will + # override the to key above for the specified groups + overrides: + - group: "core" + to: "core-team@example.com" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + alerts: + - type: resend + send-on-resolved: true + description: "healthcheck failed" +``` + + #### Configuring Rocket.Chat alerts > ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. diff --git a/alerting/alert/type.go b/alerting/alert/type.go index 6a575977..75951718 100644 --- a/alerting/alert/type.go +++ b/alerting/alert/type.go @@ -83,6 +83,9 @@ const ( // TypePushover is the Type for the pushover alerting provider TypePushover Type = "pushover" + // TypeResend is the Type for the resend alerting provider + TypeResend Type = "resend" + // TypeRocketChat is the Type for the rocketchat alerting provider TypeRocketChat Type = "rocketchat" diff --git a/alerting/config.go b/alerting/config.go index 923d6cbd..cf86b6c9 100644 --- a/alerting/config.go +++ b/alerting/config.go @@ -32,6 +32,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/pagerduty" "github.com/TwiN/gatus/v5/alerting/provider/plivo" "github.com/TwiN/gatus/v5/alerting/provider/pushover" + "github.com/TwiN/gatus/v5/alerting/provider/resend" "github.com/TwiN/gatus/v5/alerting/provider/rocketchat" "github.com/TwiN/gatus/v5/alerting/provider/sendgrid" "github.com/TwiN/gatus/v5/alerting/provider/signal" @@ -130,6 +131,9 @@ type Config struct { // Pushover is the configuration for the pushover alerting provider Pushover *pushover.AlertProvider `yaml:"pushover,omitempty"` + // Resend is the configuration for the resend alerting provider + Resend *resend.AlertProvider `yaml:"resend,omitempty"` + // RocketChat is the configuration for the rocketchat alerting provider RocketChat *rocketchat.AlertProvider `yaml:"rocketchat,omitempty"` diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go index 35beb92c..ceb63a2f 100644 --- a/alerting/provider/provider.go +++ b/alerting/provider/provider.go @@ -28,6 +28,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/pagerduty" "github.com/TwiN/gatus/v5/alerting/provider/plivo" "github.com/TwiN/gatus/v5/alerting/provider/pushover" + "github.com/TwiN/gatus/v5/alerting/provider/resend" "github.com/TwiN/gatus/v5/alerting/provider/rocketchat" "github.com/TwiN/gatus/v5/alerting/provider/sendgrid" "github.com/TwiN/gatus/v5/alerting/provider/signal" @@ -118,6 +119,7 @@ var ( _ AlertProvider = (*pagerduty.AlertProvider)(nil) _ AlertProvider = (*plivo.AlertProvider)(nil) _ AlertProvider = (*pushover.AlertProvider)(nil) + _ AlertProvider = (*resend.AlertProvider)(nil) _ AlertProvider = (*rocketchat.AlertProvider)(nil) _ AlertProvider = (*sendgrid.AlertProvider)(nil) _ AlertProvider = (*signal.AlertProvider)(nil) @@ -160,6 +162,7 @@ var ( _ Config[pagerduty.Config] = (*pagerduty.Config)(nil) _ Config[plivo.Config] = (*plivo.Config)(nil) _ Config[pushover.Config] = (*pushover.Config)(nil) + _ Config[resend.Config] = (*resend.Config)(nil) _ Config[rocketchat.Config] = (*rocketchat.Config)(nil) _ Config[sendgrid.Config] = (*sendgrid.Config)(nil) _ Config[signal.Config] = (*signal.Config)(nil) diff --git a/alerting/provider/resend/resend.go b/alerting/provider/resend/resend.go new file mode 100644 index 00000000..9afe246c --- /dev/null +++ b/alerting/provider/resend/resend.go @@ -0,0 +1,233 @@ +package resend + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +const ( + ApiURL = "https://api.resend.com/emails" + + // MaximumRecipients is the maximum number of recipients supported by Resend for a single email + MaximumRecipients = 50 +) + +var ( + ErrAPIKeyNotSet = errors.New("api-key not set") + ErrFromNotSet = errors.New("from not set") + ErrToNotSet = errors.New("to not set") + ErrTooManyRecipients = fmt.Errorf("to must not contain more than %d recipients", MaximumRecipients) + ErrDuplicateGroupOverride = errors.New("duplicate group override") +) + +type Config struct { + APIKey string `yaml:"api-key"` + From string `yaml:"from"` + To string `yaml:"to"` // Comma-separated list of recipients + + // ClientConfig is the configuration of the client used to communicate with the provider's target + ClientConfig *client.Config `yaml:"client,omitempty"` +} + +func (cfg *Config) Validate() error { + if len(cfg.APIKey) == 0 { + return ErrAPIKeyNotSet + } + if len(cfg.From) == 0 { + return ErrFromNotSet + } + recipients := cfg.recipients() + if len(recipients) == 0 { + return ErrToNotSet + } + if len(recipients) > MaximumRecipients { + return ErrTooManyRecipients + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if override.ClientConfig != nil { + cfg.ClientConfig = override.ClientConfig + } + if len(override.APIKey) > 0 { + cfg.APIKey = override.APIKey + } + if len(override.From) > 0 { + cfg.From = override.From + } + if len(override.To) > 0 { + cfg.To = override.To + } +} + +// recipients returns the list of non-empty recipients parsed from the comma-separated To field +func (cfg *Config) recipients() []string { + var recipients []string + for _, recipient := range strings.Split(cfg.To, ",") { + if recipient = strings.TrimSpace(recipient); len(recipient) > 0 { + recipients = append(recipients, recipient) + } + } + return recipients +} + +// AlertProvider is the configuration necessary for sending an alert using Resend +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +// Validate the provider's configuration +func (provider *AlertProvider) Validate() error { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" { + return ErrDuplicateGroupOverride + } + registeredGroups[override.Group] = true + } + } + return provider.DefaultConfig.Validate() +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved) + payloadBytes, err := json.Marshal(provider.buildRequestBody(cfg, subject, body)) + if err != nil { + return err + } + request, err := http.NewRequest(http.MethodPost, ApiURL, bytes.NewBuffer(payloadBytes)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", "Bearer "+cfg.APIKey) + response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode >= 400 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) + } + return nil +} + +type Body struct { + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + Text string `json:"text"` +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(cfg *Config, subject, body string) Body { + return Body{ + From: cfg.From, + To: cfg.recipients(), + Subject: subject, + Text: body, + } +} + +// buildMessageSubjectAndBody builds the message subject and body +func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) { + var subject, message string + if resolved { + subject = fmt.Sprintf("%s: Alert resolved", ep.DisplayName()) + message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) + } else { + subject = fmt.Sprintf("%s: Alert triggered", ep.DisplayName()) + message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) + } + var formattedConditionResults string + if len(result.ConditionResults) > 0 { + formattedConditionResults = "\n\nCondition results:\n" + for _, conditionResult := range result.ConditionResults { + var prefix string + if conditionResult.Success { + prefix = "✅" + } else { + prefix = "❌" + } + formattedConditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition) + } + } + var description string + if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { + description = "\n\nAlert description: " + alertDescription + } + var extraLabels string + if len(ep.ExtraLabels) > 0 { + extraLabels = "\n\nExtra labels:\n" + for key, value := range ep.ExtraLabels { + extraLabels += fmt.Sprintf(" %s: %s\n", key, value) + } + } + return subject, message + description + extraLabels + formattedConditionResults +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + err := cfg.Validate() + return &cfg, err +} + +// ValidateOverrides validates the alert's provider override and, if present, the group override +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} diff --git a/alerting/provider/resend/resend_test.go b/alerting/provider/resend/resend_test.go new file mode 100644 index 00000000..a65244b8 --- /dev/null +++ b/alerting/provider/resend/resend_test.go @@ -0,0 +1,500 @@ +package resend + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/test" +) + +func TestAlertProvider_Validate(t *testing.T) { + invalidProvider := AlertProvider{DefaultConfig: Config{APIKey: "", From: "", To: ""}} + if err := invalidProvider.Validate(); err == nil { + t.Error("provider shouldn't have been valid") + } + validProvider := AlertProvider{DefaultConfig: Config{APIKey: "re_test", From: "from@example.com", To: "to@example.com"}} + if err := validProvider.Validate(); err != nil { + t.Error("provider should've been valid") + } +} + +func TestAlertProvider_ValidateWithOverride(t *testing.T) { + providerWithInvalidOverrideGroup := AlertProvider{ + DefaultConfig: Config{APIKey: "re_test", From: "from@example.com", To: "to@example.com"}, + Overrides: []Override{ + { + Config: Config{To: "to@example.com"}, + Group: "", + }, + }, + } + if err := providerWithInvalidOverrideGroup.Validate(); !errors.Is(err, ErrDuplicateGroupOverride) { + t.Errorf("provider with empty Group should return %v, got %v", ErrDuplicateGroupOverride, err) + } + providerWithDuplicateOverrideGroups := AlertProvider{ + DefaultConfig: Config{APIKey: "re_test", From: "from@example.com", To: "to@example.com"}, + Overrides: []Override{ + { + Config: Config{To: "to1@example.com"}, + Group: "group", + }, + { + Config: Config{To: "to2@example.com"}, + Group: "group", + }, + }, + } + if err := providerWithDuplicateOverrideGroups.Validate(); !errors.Is(err, ErrDuplicateGroupOverride) { + t.Errorf("provider with duplicate group overrides should return %v, got %v", ErrDuplicateGroupOverride, err) + } + providerWithValidOverride := AlertProvider{ + DefaultConfig: Config{APIKey: "re_test", From: "from@example.com", To: "to@example.com"}, + Overrides: []Override{ + { + Config: Config{To: "group1@example.com"}, + Group: "group1", + }, + { + Config: Config{To: "group2@example.com"}, + Group: "group2", + }, + }, + } + if err := providerWithValidOverride.Validate(); err != nil { + t.Error("provider should've been valid") + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + MockRoundTripper test.MockRoundTripper + ExpectedError bool + }{ + { + Name: "triggered", + Provider: AlertProvider{DefaultConfig: Config{APIKey: "re_test", From: "from@example.com", To: "to@example.com"}}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"id":"49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"}`))} + }), + ExpectedError: false, + }, + { + Name: "triggered-error", + Provider: AlertProvider{DefaultConfig: Config{APIKey: "re_test", From: "from@example.com", To: "to@example.com"}}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader(`{"statusCode":401,"name":"missing_api_key","message":"Missing API key in the authorization header"}`))} + }), + ExpectedError: true, + }, + { + Name: "resolved", + Provider: AlertProvider{DefaultConfig: Config{APIKey: "re_test", From: "from@example.com", To: "to@example.com"}}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"id":"49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"}`))} + }), + ExpectedError: false, + }, + { + Name: "resolved-error", + Provider: AlertProvider{DefaultConfig: Config{APIKey: "re_test", From: "from@example.com", To: "to@example.com"}}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: true, + }, + { + Name: "invalid-config", + Provider: AlertProvider{DefaultConfig: Config{APIKey: "", From: "from@example.com", To: "to@example.com"}}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + t.Error("no request should have been sent with an invalid configuration") + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + ExpectedError: true, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) + err := scenario.Provider.Send( + &endpoint.Endpoint{Name: "endpoint-name"}, + &scenario.Alert, + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + }, + }, + scenario.Resolved, + ) + if scenario.ExpectedError && err == nil { + t.Error("expected error, got none") + } + if !scenario.ExpectedError && err != nil { + t.Error("expected no error, got", err.Error()) + } + }) + } +} + +func TestAlertProvider_SendRequest(t *testing.T) { + defer client.InjectHTTPClient(nil) + provider := AlertProvider{DefaultConfig: Config{APIKey: "re_test", From: "Gatus ", To: "to1@example.com, to2@example.com"}} + var requestSent bool + client.InjectHTTPClient(&http.Client{Transport: test.MockRoundTripper(func(r *http.Request) *http.Response { + requestSent = true + if r.Method != http.MethodPost { + t.Errorf("expected method to be %s, got %s", http.MethodPost, r.Method) + } + if r.URL.String() != ApiURL { + t.Errorf("expected URL to be %s, got %s", ApiURL, r.URL.String()) + } + if authorization := r.Header.Get("Authorization"); authorization != "Bearer re_test" { + t.Errorf("expected Authorization header to be %s, got %s", "Bearer re_test", authorization) + } + if contentType := r.Header.Get("Content-Type"); contentType != "application/json" { + t.Errorf("expected Content-Type header to be %s, got %s", "application/json", contentType) + } + var body Body + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode request body: %s", err) + } + expectedBody := Body{ + From: "Gatus ", + To: []string{"to1@example.com", "to2@example.com"}, + Subject: "endpoint-name: Alert triggered", + Text: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nCondition results:\n❌ [STATUS] == 200\n", + } + if !reflect.DeepEqual(body, expectedBody) { + t.Errorf("expected body to be %+v, got %+v", expectedBody, body) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"id":"49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"}`))} + })}) + err := provider.Send( + &endpoint.Endpoint{Name: "endpoint-name"}, + &alert.Alert{SuccessThreshold: 5, FailureThreshold: 3}, + &endpoint.Result{ConditionResults: []*endpoint.ConditionResult{{Condition: "[STATUS] == 200", Success: false}}}, + false, + ) + if err != nil { + t.Error("expected no error, got", err.Error()) + } + if !requestSent { + t.Error("expected a request to have been sent") + } +} + +func TestAlertProvider_buildRequestBody(t *testing.T) { + provider := &AlertProvider{} + cfg := &Config{From: "from@example.com", To: "to1@example.com, to2@example.com,,"} + body := provider.buildRequestBody(cfg, "Test Subject", "Test Body\nWith new line") + expectedBody := Body{ + From: "from@example.com", + To: []string{"to1@example.com", "to2@example.com"}, + Subject: "Test Subject", + Text: "Test Body\nWith new line", + } + if !reflect.DeepEqual(body, expectedBody) { + t.Errorf("expected body to be %+v, got %+v", expectedBody, body) + } +} + +func TestAlertProvider_buildMessageSubjectAndBody(t *testing.T) { + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + Endpoint *endpoint.Endpoint + ExpectedSubject string + ExpectedBody string + }{ + { + Name: "triggered", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + Endpoint: &endpoint.Endpoint{Name: "endpoint-name"}, + ExpectedSubject: "endpoint-name: Alert triggered", + ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n", + }, + { + Name: "resolved", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + Endpoint: &endpoint.Endpoint{Name: "endpoint-name"}, + ExpectedSubject: "endpoint-name: Alert resolved", + ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n", + }, + { + Name: "triggered-with-group", + Provider: AlertProvider{}, + Alert: alert.Alert{SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + Endpoint: &endpoint.Endpoint{Name: "endpoint-name", Group: "group"}, + ExpectedSubject: "group/endpoint-name: Alert triggered", + ExpectedBody: "An alert for group/endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n", + }, + { + Name: "triggered-with-single-extra-label", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}}, + ExpectedSubject: "endpoint-name: Alert triggered", + ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n", + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + subject, body := scenario.Provider.buildMessageSubjectAndBody( + scenario.Endpoint, + &scenario.Alert, + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + }, + }, + scenario.Resolved, + ) + if subject != scenario.ExpectedSubject { + t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject) + } + if body != scenario.ExpectedBody { + t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body) + } + }) + } +} + +func TestAlertProvider_GetDefaultAlert(t *testing.T) { + if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { + t.Error("expected default alert to be not nil") + } + if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { + t.Error("expected default alert to be nil") + } +} + +func TestAlertProvider_GetConfig(t *testing.T) { + scenarios := []struct { + Name string + Provider AlertProvider + InputGroup string + InputAlert alert.Alert + ExpectedOutput Config + }{ + { + Name: "provider-no-override-specify-no-group-should-default", + Provider: AlertProvider{ + DefaultConfig: Config{APIKey: "re_test", From: "from@example.com", To: "to@example.com"}, + Overrides: nil, + }, + InputGroup: "", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{APIKey: "re_test", From: "from@example.com", To: "to@example.com"}, + }, + { + Name: "provider-no-override-specify-group-should-default", + Provider: AlertProvider{ + DefaultConfig: Config{APIKey: "re_test", From: "from@example.com", To: "to@example.com"}, + Overrides: nil, + }, + InputGroup: "group", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{APIKey: "re_test", From: "from@example.com", To: "to@example.com"}, + }, + { + Name: "provider-with-override-specify-no-group-should-default", + Provider: AlertProvider{ + DefaultConfig: Config{APIKey: "re_test", From: "from@example.com", To: "to@example.com"}, + Overrides: []Override{ + { + Group: "group", + Config: Config{To: "group-to@example.com"}, + }, + }, + }, + InputGroup: "", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{APIKey: "re_test", From: "from@example.com", To: "to@example.com"}, + }, + { + Name: "provider-with-override-specify-group-should-override", + Provider: AlertProvider{ + DefaultConfig: Config{APIKey: "re_test", From: "from@example.com", To: "to@example.com"}, + Overrides: []Override{ + { + Group: "group", + Config: Config{To: "group-to@example.com"}, + }, + }, + }, + InputGroup: "group", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{APIKey: "re_test", From: "from@example.com", To: "group-to@example.com"}, + }, + { + Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence", + Provider: AlertProvider{ + DefaultConfig: Config{APIKey: "re_test", From: "from@example.com", To: "to@example.com"}, + Overrides: []Override{ + { + Group: "group", + Config: Config{To: "group-to@example.com"}, + }, + }, + }, + InputGroup: "group", + InputAlert: alert.Alert{ProviderOverride: map[string]any{"api-key": "re_override", "from": "alert-from@example.com", "to": "alert-to@example.com"}}, + ExpectedOutput: Config{APIKey: "re_override", From: "alert-from@example.com", To: "alert-to@example.com"}, + }, + { + Name: "provider-partial-override-hierarchy", + Provider: AlertProvider{ + DefaultConfig: Config{APIKey: "re_default", From: "default@example.com", To: "default@example.com"}, + Overrides: []Override{ + { + Group: "group", + Config: Config{From: "group@example.com"}, + }, + }, + }, + InputGroup: "group", + InputAlert: alert.Alert{ProviderOverride: map[string]any{"to": "alert@example.com"}}, + ExpectedOutput: Config{APIKey: "re_default", From: "group@example.com", To: "alert@example.com"}, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if got.APIKey != scenario.ExpectedOutput.APIKey { + t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey) + } + if got.From != scenario.ExpectedOutput.From { + t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From) + } + if got.To != scenario.ExpectedOutput.To { + t.Errorf("expected To to be %s, got %s", scenario.ExpectedOutput.To, got.To) + } + // Test ValidateOverrides as well, since it really just calls GetConfig + if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil { + t.Errorf("unexpected error: %s", err) + } + }) + } +} + +func TestAlertProvider_GetConfigWithInvalidAlertOverride(t *testing.T) { + provider := AlertProvider{DefaultConfig: Config{APIKey: "re_test", From: "from@example.com", To: "to@example.com"}} + if err := provider.ValidateOverrides("", &alert.Alert{ProviderOverride: map[string]any{"api-key": []string{"invalid"}}}); err == nil { + t.Error("expected error due to invalid alert override, got none") + } +} + +func TestConfig_Validate(t *testing.T) { + tooManyRecipients := make([]string, MaximumRecipients+1) + for i := range tooManyRecipients { + tooManyRecipients[i] = "to@example.com" + } + scenarios := []struct { + Name string + Config Config + ExpectedError error + }{ + { + Name: "missing-api-key", + Config: Config{APIKey: "", From: "from@example.com", To: "to@example.com"}, + ExpectedError: ErrAPIKeyNotSet, + }, + { + Name: "missing-from", + Config: Config{APIKey: "re_test", From: "", To: "to@example.com"}, + ExpectedError: ErrFromNotSet, + }, + { + Name: "missing-to", + Config: Config{APIKey: "re_test", From: "from@example.com", To: ""}, + ExpectedError: ErrToNotSet, + }, + { + Name: "to-with-only-separators", + Config: Config{APIKey: "re_test", From: "from@example.com", To: " , ,"}, + ExpectedError: ErrToNotSet, + }, + { + Name: "too-many-recipients", + Config: Config{APIKey: "re_test", From: "from@example.com", To: strings.Join(tooManyRecipients, ",")}, + ExpectedError: ErrTooManyRecipients, + }, + { + Name: "maximum-recipients", + Config: Config{APIKey: "re_test", From: "from@example.com", To: strings.Join(tooManyRecipients[:MaximumRecipients], ",")}, + ExpectedError: nil, + }, + { + Name: "valid-config", + Config: Config{APIKey: "re_test", From: "Gatus ", To: "to1@example.com,to2@example.com"}, + ExpectedError: nil, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + if err := scenario.Config.Validate(); !errors.Is(err, scenario.ExpectedError) { + t.Errorf("expected error %v, got %v", scenario.ExpectedError, err) + } + }) + } +} + +func TestConfig_Merge(t *testing.T) { + config := Config{APIKey: "re_original", From: "from@example.com", To: "to@example.com", ClientConfig: &client.Config{Timeout: 10000}} + config.Merge(&Config{APIKey: "re_override", To: "override@example.com"}) + if config.APIKey != "re_override" { + t.Errorf("expected APIKey to be re_override, got %s", config.APIKey) + } + if config.From != "from@example.com" { + t.Errorf("expected From to remain from@example.com, got %s", config.From) + } + if config.To != "override@example.com" { + t.Errorf("expected To to be override@example.com, got %s", config.To) + } + if config.ClientConfig == nil || config.ClientConfig.Timeout != 10000 { + t.Error("expected ClientConfig to remain unchanged") + } + config.Merge(&Config{ClientConfig: &client.Config{Timeout: 30000}}) + if config.ClientConfig.Timeout != 30000 { + t.Errorf("expected ClientConfig.Timeout to be 30000, got %d", config.ClientConfig.Timeout) + } +} diff --git a/config/config.go b/config/config.go index 6f0b5a18..47f03d8a 100644 --- a/config/config.go +++ b/config/config.go @@ -620,6 +620,7 @@ func ValidateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi alert.TypePagerDuty, alert.TypePlivo, alert.TypePushover, + alert.TypeResend, alert.TypeRocketChat, alert.TypeSendGrid, alert.TypeSignal, diff --git a/config/config_test.go b/config/config_test.go index cbb6e62b..f4d9082d 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -37,6 +37,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/pagerduty" "github.com/TwiN/gatus/v5/alerting/provider/plivo" "github.com/TwiN/gatus/v5/alerting/provider/pushover" + "github.com/TwiN/gatus/v5/alerting/provider/resend" "github.com/TwiN/gatus/v5/alerting/provider/rocketchat" "github.com/TwiN/gatus/v5/alerting/provider/sendgrid" "github.com/TwiN/gatus/v5/alerting/provider/signal" @@ -1895,6 +1896,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) { PagerDuty: &pagerduty.AlertProvider{}, Plivo: &plivo.AlertProvider{}, Pushover: &pushover.AlertProvider{}, + Resend: &resend.AlertProvider{}, RocketChat: &rocketchat.AlertProvider{}, SendGrid: &sendgrid.AlertProvider{}, Signal: &signal.AlertProvider{}, @@ -1940,6 +1942,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) { {alertType: alert.TypePagerDuty, expected: alertingConfig.PagerDuty}, {alertType: alert.TypePlivo, expected: alertingConfig.Plivo}, {alertType: alert.TypePushover, expected: alertingConfig.Pushover}, + {alertType: alert.TypeResend, expected: alertingConfig.Resend}, {alertType: alert.TypeRocketChat, expected: alertingConfig.RocketChat}, {alertType: alert.TypeSendGrid, expected: alertingConfig.SendGrid}, {alertType: alert.TypeSignal, expected: alertingConfig.Signal},