diff --git a/README.md b/README.md
index 233528c62..76a0255a6 100644
--- a/README.md
+++ b/README.md
@@ -91,6 +91,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring Teams alerts *(Deprecated)*](#configuring-teams-alerts-deprecated)
- [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts)
- [Configuring Telegram alerts](#configuring-telegram-alerts)
+ - [Configuring Threema Gateway alerts](#configuring-threema-gateway-alerts)
- [Configuring Twilio alerts](#configuring-twilio-alerts)
- [Configuring Vonage alerts](#configuring-vonage-alerts)
- [Configuring Webex alerts](#configuring-webex-alerts)
@@ -861,6 +862,7 @@ endpoints:
| `alerting.teams` | Configuration for alerts of type `teams`. *(Deprecated)*
See [Configuring Teams alerts](#configuring-teams-alerts-deprecated). | `{}` |
| `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`.
See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` |
| `alerting.telegram` | Configuration for alerts of type `telegram`.
See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
+| `alerting.threema-gateway` | Configuration for alerts of type `threema-gateway`.
See [Configuring Threema Gateway alerts](#configuring-threema-gateway-alerts).| `{}` |
| `alerting.twilio` | Settings for alerts of type `twilio`.
See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
| `alerting.vonage` | Configuration for alerts of type `vonage`.
See [Configuring Vonage alerts](#configuring-vonage-alerts). | `{}` |
| `alerting.webex` | Configuration for alerts of type `webex`.
See [Configuring Webex alerts](#configuring-webex-alerts). | `{}` |
@@ -2241,6 +2243,38 @@ Here's an example of what the notifications look like:

+#### Configuring Threema Gateway alerts
+| Parameter | Description | Default |
+|:---------------------------------------------|:------------------------------------------------------------------------------------------------------------|:----------------------------|
+| `alerting.threema-gateway` | Configuration for alerts of type `threema-gateway` | `{}` |
+| `alerting.threema-gateway.api-base-url` | Threema Gateway API Base URL | `https://msgapi.threema.ch` |
+| `alerting.threema-gateway.send-mode` | Mode used to send the alert, currently only `basic` is supported | `"basic"` |
+| `alerting.threema-gateway.api-identity` | Personal Threema API Identity | Required `""` |
+| `alerting.threema-gateway.recipients` | Recipients in format `[:]`, where type is `id`, `phone` or `email`. Type defaults to `id` | Required `""` |
+| `alerting.threema-gateway.auth-secret` | Threema Gateway API authentication secret | Required `""` |
+| `alerting.threema-gateway.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A |
+| `alerting.threema-gateway.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
+| `alerting.threema-gateway.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
+| `alerting.threema-gateway.overrides[].*` | See `alerting.threema-gateway.*` parameters | `{}` |
+
+```yaml
+alerting:
+ threema-gateway:
+ api-identity: "*ABCDEFG"
+ auth-secret: "secret"
+ recipients:
+ - "THREEMA_ID"
+
+endpoints:
+ - name: website
+ url: "https://twin.sh/health"
+ conditions:
+ - "[STATUS] == 200"
+ alerts:
+ - type: threema-gateway
+```
+
+
#### Configuring Twilio alerts
| Parameter | Description | Default |
|:--------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
diff --git a/alerting/alert/type.go b/alerting/alert/type.go
index 43bd24879..d76288419 100644
--- a/alerting/alert/type.go
+++ b/alerting/alert/type.go
@@ -110,6 +110,9 @@ const (
// TypeTelegram is the Type for the telegram alerting provider
TypeTelegram Type = "telegram"
+ // TypeThreemaGateway is the Type for the threema-gateway alerting provider
+ TypeThreemaGateway Type = "threema-gateway"
+
// TypeTwilio is the Type for the twilio alerting provider
TypeTwilio Type = "twilio"
diff --git a/alerting/config.go b/alerting/config.go
index 65150396b..f79d3fe6d 100644
--- a/alerting/config.go
+++ b/alerting/config.go
@@ -41,6 +41,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
+ "github.com/TwiN/gatus/v5/alerting/provider/threemagateway"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/vonage"
"github.com/TwiN/gatus/v5/alerting/provider/webex"
@@ -156,6 +157,9 @@ type Config struct {
// Telegram is the configuration for the telegram alerting provider
Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
+ // ThreemaGateway is the configuration for the threema-gatway alerting provider
+ ThreemaGateway *threemagateway.AlertProvider `yaml:"threema-gateway,omitempty"`
+
// Twilio is the configuration for the twilio alerting provider
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go
index 64084e203..b847532be 100644
--- a/alerting/provider/provider.go
+++ b/alerting/provider/provider.go
@@ -37,6 +37,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
+ "github.com/TwiN/gatus/v5/alerting/provider/threemagateway"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/webex"
"github.com/TwiN/gatus/v5/alerting/provider/zapier"
@@ -126,6 +127,7 @@ var (
_ AlertProvider = (*teams.AlertProvider)(nil)
_ AlertProvider = (*teamsworkflows.AlertProvider)(nil)
_ AlertProvider = (*telegram.AlertProvider)(nil)
+ _ AlertProvider = (*threemagateway.AlertProvider)(nil)
_ AlertProvider = (*twilio.AlertProvider)(nil)
_ AlertProvider = (*webex.AlertProvider)(nil)
_ AlertProvider = (*zapier.AlertProvider)(nil)
@@ -167,6 +169,7 @@ var (
_ Config[teams.Config] = (*teams.Config)(nil)
_ Config[teamsworkflows.Config] = (*teamsworkflows.Config)(nil)
_ Config[telegram.Config] = (*telegram.Config)(nil)
+ _ Config[threemagateway.Config] = (*threemagateway.Config)(nil)
_ Config[twilio.Config] = (*twilio.Config)(nil)
_ Config[webex.Config] = (*webex.Config)(nil)
_ Config[zapier.Config] = (*zapier.Config)(nil)
diff --git a/alerting/provider/threemagateway/recipient.go b/alerting/provider/threemagateway/recipient.go
new file mode 100644
index 000000000..f9cde046d
--- /dev/null
+++ b/alerting/provider/threemagateway/recipient.go
@@ -0,0 +1,115 @@
+package threemagateway
+
+import (
+ "encoding"
+ "errors"
+ "fmt"
+ "strings"
+)
+
+const (
+ defaultRecipientType = RecipientTypeId
+)
+
+var (
+ ErrInvalidRecipientFormat = errors.New("recipient must be in the format '[:]'")
+ ErrInvalidRecipientType = fmt.Errorf("invalid recipient type, must be one of: %v", joinKeys(validRecipientTypes, ", "))
+ validRecipientTypes = map[string]RecipientType{
+ "id": RecipientTypeId,
+ "phone": RecipientTypePhone,
+ "email": RecipientTypeEmail,
+ }
+
+ ErrInvalidPhoneNumberFormat = errors.New("invalid phone number: must contain only digits and may start with '+'")
+ ErrInvalidEmailAddressFormat = errors.New("invalid email address: must contain '@'")
+)
+
+type RecipientType int
+
+const (
+ RecipientTypeInvalid RecipientType = iota
+ RecipientTypeId
+ RecipientTypePhone
+ RecipientTypeEmail
+)
+
+func parseRecipientType(s string) RecipientType {
+ if val, ok := validRecipientTypes[s]; ok {
+ return val
+ }
+ return RecipientTypeInvalid
+}
+
+type Recipient struct {
+ Value string `yaml:"-"`
+ Type RecipientType `yaml:"-"`
+}
+
+var _ encoding.TextUnmarshaler = (*Recipient)(nil)
+var _ encoding.TextMarshaler = (*Recipient)(nil)
+
+func (r *Recipient) UnmarshalText(text []byte) error {
+ parts := strings.Split(string(text), ":")
+ switch {
+ case len(parts) > 2:
+ return ErrInvalidRecipientFormat
+ case len(parts) == 2:
+ if r.Type = parseRecipientType(parts[0]); r.Type == RecipientTypeInvalid {
+ return ErrInvalidRecipientType
+ }
+ r.Value = parts[1]
+ default:
+ r.Type = defaultRecipientType
+ r.Value = parts[0]
+ }
+ return nil
+}
+
+func (r Recipient) MarshalText() ([]byte, error) {
+ if r.Type == RecipientTypeInvalid {
+ return []byte("invalid" + ":" + r.Value), nil
+ }
+ for key, val := range validRecipientTypes {
+ if val == r.Type {
+ return []byte(key + ":" + r.Value), nil
+ }
+ }
+ return nil, ErrInvalidRecipientType
+}
+
+func (r *Recipient) Validate() error {
+ if len(r.Value) == 0 {
+ return ErrInvalidRecipientFormat
+ }
+ switch r.Type {
+ case RecipientTypeId:
+ if err := validateThreemaId(r.Value); err != nil {
+ return err
+ }
+ case RecipientTypePhone:
+ r.Value = strings.TrimPrefix(r.Value, "+")
+ if !isValidPhoneNumber(r.Value) {
+ return ErrInvalidPhoneNumberFormat
+ }
+ case RecipientTypeEmail:
+ // Basic validation for email address // TODO#1464: improve email validation
+ if !strings.Contains(r.Value, "@") {
+ return ErrInvalidEmailAddressFormat
+ }
+ default:
+ return ErrInvalidRecipientType
+ }
+ return nil
+}
+
+func isValidPhoneNumber(number string) bool {
+ if len(number) == 0 {
+ return false
+ }
+ for _, ch := range number {
+ if ch < '0' || ch > '9' {
+ return false
+ }
+ }
+ return true
+}
diff --git a/alerting/provider/threemagateway/recipient_test.go b/alerting/provider/threemagateway/recipient_test.go
new file mode 100644
index 000000000..5721ce722
--- /dev/null
+++ b/alerting/provider/threemagateway/recipient_test.go
@@ -0,0 +1,134 @@
+package threemagateway
+
+import (
+ "errors"
+ "strings"
+ "testing"
+)
+
+func TestRecipient_UnmarshalText_And_MarshalText(t *testing.T) {
+ scenarios := []struct {
+ name string
+ input string
+ expected Recipient
+ expectError error
+ }{
+ {
+ name: "default recipient type",
+ input: "individual",
+ expected: Recipient{Type: defaultRecipientType, Value: "individual"},
+ expectError: nil,
+ },
+ {
+ name: "empty input",
+ input: "",
+ expected: Recipient{Type: defaultRecipientType, Value: ""},
+ expectError: nil,
+ },
+ {
+ name: "invalid format",
+ input: "type:value:extra",
+ expectError: ErrInvalidRecipientFormat,
+ },
+ {
+ name: "invalid recipient type",
+ input: "unknown:value",
+ expectError: ErrInvalidRecipientType,
+ },
+ {
+ name: "valid phone recipient",
+ input: "phone:+1234567890",
+ expected: Recipient{Type: RecipientTypePhone, Value: "+1234567890"},
+ expectError: nil,
+ },
+ {
+ name: "valid email recipient",
+ input: "email:mail@mail.com",
+ expected: Recipient{Type: RecipientTypeEmail, Value: "mail@mail.com"},
+ expectError: nil,
+ },
+ }
+
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ var recipient Recipient
+ err := recipient.UnmarshalText([]byte(scenario.input))
+
+ if !errors.Is(err, scenario.expectError) {
+ t.Fatalf("expected error for scenario '%s': %v, got: %v", scenario.name, scenario.expectError, err)
+ }
+
+ if scenario.expectError == nil && recipient != scenario.expected {
+ t.Fatalf("expected recipient for scenario '%s': %+v, got: %+v", scenario.name, scenario.expected, recipient)
+ }
+
+ if scenario.expectError == nil {
+ marshaled, err := recipient.MarshalText()
+ if err != nil {
+ t.Fatalf("unexpected error during marshaling for scenario '%s': %v", scenario.name, err)
+ }
+ expectedMarshaled := scenario.input
+ if strings.Contains(scenario.input, ":") == false {
+ expectedMarshaled = "id:" + scenario.input
+ }
+ if string(marshaled) != expectedMarshaled {
+ t.Fatalf("expected marshaled text for scenario '%s': %s, got: %s", scenario.name, expectedMarshaled, string(marshaled))
+ }
+ }
+ })
+ }
+}
+
+func TestRecipient_Validate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ input Recipient
+ expectError error
+ }{
+ {
+ name: "empty recipient",
+ input: Recipient{Type: defaultRecipientType, Value: ""},
+ expectError: ErrInvalidRecipientFormat,
+ },
+ {
+ name: "valid id recipient",
+ input: Recipient{Type: RecipientTypeId, Value: "ABCDEFGH"},
+ expectError: nil,
+ },
+ {
+ name: "valid phone recipient",
+ input: Recipient{Type: RecipientTypePhone, Value: "+1234567890"},
+ expectError: nil,
+ },
+ {
+ name: "invalid phone recipient",
+ input: Recipient{Type: RecipientTypePhone, Value: "123-456-7890"},
+ expectError: ErrInvalidPhoneNumberFormat,
+ },
+ {
+ name: "valid email recipient",
+ input: Recipient{Type: RecipientTypeEmail, Value: "mail@test.com"},
+ expectError: nil,
+ },
+ {
+ name: "invalid email recipient",
+ input: Recipient{Type: RecipientTypeEmail, Value: "mailtest.com"},
+ expectError: ErrInvalidEmailAddressFormat,
+ },
+ {
+ name: "invalid recipient type",
+ input: Recipient{Type: RecipientTypeInvalid, Value: "value"},
+ expectError: ErrInvalidRecipientType,
+ },
+ }
+
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ err := scenario.input.Validate()
+
+ if !errors.Is(err, scenario.expectError) {
+ t.Fatalf("expected error for scenario '%s': %v, got: %v", scenario.name, scenario.expectError, err)
+ }
+ })
+ }
+}
diff --git a/alerting/provider/threemagateway/sendmode.go b/alerting/provider/threemagateway/sendmode.go
new file mode 100644
index 000000000..14ac8c61f
--- /dev/null
+++ b/alerting/provider/threemagateway/sendmode.go
@@ -0,0 +1,56 @@
+package threemagateway
+
+import (
+ "encoding"
+ "errors"
+ "fmt"
+)
+
+const (
+ defaultMode = "basic" // TODO#1464: Should the default be e2ee event though it is not implemented yet to avoid future breaking or bad default behavior?
+)
+
+var (
+ ErrModeTypeInvalid = fmt.Errorf("invalid mode, must be one of: %s", joinKeys(validModeTypes, ", "))
+ ErrNotImplementedMode = errors.New("configured mode is not implemented")
+ validModeTypes = map[string]ModeType{
+ "basic": ModeTypeBasic,
+ "e2ee": ModeTypeE2EE,
+ "e2ee-bulk": ModeTypeE2EEBulk,
+ }
+)
+
+type ModeType int
+
+const (
+ ModeTypeInvalid ModeType = iota
+ ModeTypeBasic
+ ModeTypeE2EE
+ ModeTypeE2EEBulk
+)
+
+type SendMode struct {
+ Value string `yaml:"-"`
+ Type ModeType `yaml:"-"`
+}
+
+var _ encoding.TextUnmarshaler = (*SendMode)(nil)
+var _ encoding.TextMarshaler = (*SendMode)(nil)
+
+func (m *SendMode) UnmarshalText(text []byte) error {
+ t := string(text)
+ if len(t) == 0 {
+ t = defaultMode
+ }
+ m.Value = t
+ if val, ok := validModeTypes[t]; ok {
+ m.Type = val
+ return nil
+ }
+ m.Type = ModeTypeInvalid
+ return ErrModeTypeInvalid
+}
+
+func (m SendMode) MarshalText() ([]byte, error) {
+ return []byte(m.Value), nil
+}
diff --git a/alerting/provider/threemagateway/sendmode_test.go b/alerting/provider/threemagateway/sendmode_test.go
new file mode 100644
index 000000000..f1885911b
--- /dev/null
+++ b/alerting/provider/threemagateway/sendmode_test.go
@@ -0,0 +1,69 @@
+package threemagateway
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestSendMode_UnmarshalText_And_MarshalText(t *testing.T) {
+ scenarios := []struct {
+ name string
+ input string
+ expected SendMode
+ expectError error
+ }{
+ {
+ name: "default mode",
+ input: "",
+ expected: SendMode{Value: defaultMode, Type: validModeTypes[defaultMode]},
+ expectError: nil,
+ },
+ {
+ name: "basic mode",
+ input: "basic",
+ expected: SendMode{Value: "basic", Type: ModeTypeBasic},
+ expectError: nil,
+ },
+ {
+ name: "e2ee mode",
+ input: "e2ee",
+ expected: SendMode{Value: "e2ee", Type: ModeTypeE2EE},
+ expectError: nil,
+ },
+ {
+ name: "invalid mode",
+ input: "invalid-mode",
+ expected: SendMode{Value: "invalid-mode", Type: ModeTypeInvalid},
+ expectError: ErrModeTypeInvalid,
+ },
+ }
+
+ for _, scenario := range scenarios {
+ t.Run(scenario.name, func(t *testing.T) {
+ var mode SendMode
+ err := mode.UnmarshalText([]byte(scenario.input))
+
+ if !errors.Is(err, scenario.expectError) {
+ t.Fatalf("expected error for scenario '%s': %v, got: %v", scenario.name, scenario.expectError, err)
+ }
+
+ if scenario.expectError == nil && mode != scenario.expected {
+ t.Fatalf("expected mode for scenario '%s': %+v, got: %+v", scenario.name, scenario.expected, mode)
+ }
+
+ if scenario.expectError == nil {
+ marshaled, err := mode.MarshalText()
+ if err != nil {
+ t.Fatalf("unexpected error during marshaling for scenario '%s': %v", scenario.name, err)
+ }
+ expectedMarshaled := scenario.input
+ if len(scenario.input) == 0 {
+ expectedMarshaled = defaultMode
+ }
+ if string(marshaled) != expectedMarshaled {
+ t.Fatalf("expected marshaled mode for scenario '%s': '%s', got: '%s'", scenario.name, expectedMarshaled, string(marshaled))
+ }
+ }
+ })
+ }
+}
diff --git a/alerting/provider/threemagateway/threemagateway.go b/alerting/provider/threemagateway/threemagateway.go
new file mode 100644
index 000000000..2805d14af
--- /dev/null
+++ b/alerting/provider/threemagateway/threemagateway.go
@@ -0,0 +1,257 @@
+package threemagateway
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "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 (
+ defaultApiBaseUrl = "https://msgapi.threema.ch"
+)
+
+var (
+ ErrApiIdentityMissing = errors.New("api-identity is required")
+ ErrApiAuthSecretMissing = errors.New("auth-secret is required")
+ ErrRecipientsMissing = errors.New("at least one recipient is required")
+
+ ErrRecipientsTooMany = errors.New("too many recipients for the selected mode")
+)
+
+type Config struct {
+ ApiBaseUrl string `yaml:"api-base-url"`
+ Mode *SendMode `yaml:"send-mode"`
+ ApiIdentity string `yaml:"api-identity"`
+ Recipients []Recipient `yaml:"recipients"` // TODO#1470: Remove comment: This is an array to support bulk sending in e2ee-bulk mode once implemented
+ ApiAuthSecret string `yaml:"auth-secret"`
+}
+
+func (cfg *Config) Validate() error {
+ // Validate API Base URL
+ if len(cfg.ApiBaseUrl) == 0 {
+ cfg.ApiBaseUrl = defaultApiBaseUrl
+ }
+
+ // Validate Mode
+ if cfg.Mode == nil {
+ cfg.Mode = &SendMode{}
+ cfg.Mode.UnmarshalText([]byte{})
+ }
+ switch cfg.Mode.Type {
+ case ModeTypeInvalid:
+ return ErrModeTypeInvalid
+ case ModeTypeE2EE, ModeTypeE2EEBulk:
+ return ErrNotImplementedMode // TODO#1470: implement E2EE and E2EE-Bulk modes
+ }
+
+ // Validate API Identity
+ if len(cfg.ApiIdentity) == 0 {
+ return ErrApiIdentityMissing
+ }
+ if err := validateThreemaId(cfg.ApiIdentity); err != nil {
+ return fmt.Errorf("api-identity: %w", err)
+ }
+
+ // Validate Recipients
+ var modeType = cfg.Mode.Type
+ if modeType == ModeTypeBasic && len(cfg.Recipients) > 1 { // TODO#1470 Handle non bulk e2ee modes properly once implemented
+ return ErrRecipientsTooMany
+ } else if len(cfg.Recipients) == 0 {
+ return ErrRecipientsMissing
+ }
+ for _, recipient := range cfg.Recipients {
+ if err := recipient.Validate(); err != nil {
+ return fmt.Errorf("recipients: %s: %w", recipient.Value, err)
+ }
+ // TODO#1470: Either support recipient types other than id in e2ee modes or handle the error properly once those modes are implemented
+ }
+
+ // Validate API Key
+ if len(cfg.ApiAuthSecret) == 0 {
+ return ErrApiAuthSecretMissing
+ }
+ return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+ if len(override.ApiBaseUrl) > 0 {
+ cfg.ApiBaseUrl = override.ApiBaseUrl
+ }
+ if override.Mode != nil {
+ cfg.Mode = override.Mode
+ }
+ if len(override.ApiIdentity) > 0 {
+ cfg.ApiIdentity = override.ApiIdentity
+ }
+ if len(override.Recipients) > 0 {
+ cfg.Recipients = override.Recipients
+ }
+ if len(override.ApiAuthSecret) > 0 {
+ cfg.ApiAuthSecret = override.ApiAuthSecret
+ }
+}
+
+type AlertProvider struct {
+ DefaultConfig Config `yaml:",inline"`
+ DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+ Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+type Override struct {
+ Group string `yaml:"group"`
+ Config `yaml:",inline"`
+}
+
+func (provider *AlertProvider) Validate() error {
+ // TODO#1464 Validate overrides?
+ return provider.DefaultConfig.Validate()
+}
+
+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
+ }
+ body := provider.buildMessageBody(ep, alert, result, resolved)
+ request, err := provider.prepareRequest(cfg, body)
+ if err != nil {
+ return err
+ }
+ response, err := client.GetHTTPClient(nil).Do(request)
+ if err != nil {
+ return err
+ }
+ return handleResponse(cfg, response)
+}
+
+func (provider *AlertProvider) buildMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
+ group := ep.Group
+ if len(group) > 0 {
+ group += "/"
+ }
+
+ var body string
+ if resolved {
+ body = fmt.Sprintf("✅ *Gatus: %s%s*\nAlert resolved after passing %d checks.", group, ep.Name, alert.SuccessThreshold)
+ } else {
+ body = fmt.Sprintf("🚨 *Gatus: %s%s*\nAlert triggered after failing %d checks.\nConditions:", group, ep.Name, alert.FailureThreshold)
+ for _, conditionResult := range result.ConditionResults {
+ icon := "❌"
+ if conditionResult.Success {
+ icon = "✅"
+ }
+ body += fmt.Sprintf("\n %s %s", icon, conditionResult.Condition)
+ }
+ if len(result.Errors) > 0 {
+ body += "\nErrors:"
+ for _, err := range result.Errors {
+ body += fmt.Sprintf("\n ❌ %s", err)
+ }
+ }
+ }
+ return body
+}
+
+func (provider *AlertProvider) prepareRequest(cfg *Config, body string) (*http.Request, error) {
+ requestUrl := cfg.ApiBaseUrl
+ switch cfg.Mode.Type {
+ case ModeTypeBasic:
+ requestUrl += "/send_simple"
+ case ModeTypeE2EE, ModeTypeE2EEBulk:
+ return nil, ErrNotImplementedMode // TODO#1470: implement E2EE and E2EE-Bulk modes
+ default:
+ return nil, ErrNotImplementedMode
+ }
+
+ data := url.Values{}
+ data.Add("from", cfg.ApiIdentity)
+ var toKey string
+ switch cfg.Recipients[0].Type {
+ case RecipientTypeId:
+ toKey = "to"
+ case RecipientTypePhone:
+ toKey = "phone"
+ case RecipientTypeEmail:
+ toKey = "email"
+ default:
+ return nil, ErrInvalidRecipientType
+ }
+ data.Add(toKey, cfg.Recipients[0].Value)
+ data.Add("text", body)
+ data.Add("secret", cfg.ApiAuthSecret)
+
+ request, err := http.NewRequest(http.MethodPost, requestUrl, strings.NewReader(data.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ return request, nil
+}
+
+func handleResponse(cfg *Config, response *http.Response) error {
+ switch response.StatusCode {
+ case http.StatusOK:
+ switch cfg.Mode.Type {
+ case ModeTypeBasic, ModeTypeE2EE:
+ return nil
+ case ModeTypeE2EEBulk:
+ return nil // TODO#1470: Add correct handling once mode is implemented (check success fields in response body)
+ }
+ case http.StatusBadRequest:
+ switch cfg.Mode.Type {
+ case ModeTypeBasic, ModeTypeE2EE:
+ return fmt.Errorf("%s: Invalid recipient or Threema Account not set up for %s mode", response.Status, cfg.Mode.Value)
+ case ModeTypeE2EEBulk:
+ // TODO#1470: Add correct error info once mode is implemented
+ }
+ case http.StatusUnauthorized:
+ return fmt.Errorf("%s: Invalid auth-secret or api-identity", response.Status)
+ case http.StatusPaymentRequired:
+ return fmt.Errorf("%s: Insufficient credits to send message", response.Status)
+ case http.StatusNotFound:
+ if cfg.Mode.Type == ModeTypeBasic {
+ return fmt.Errorf("%s: Recipient could not be found", response.Status)
+ }
+ }
+ return fmt.Errorf("Response: %s", response.Status)
+}
+
+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 len(provider.Overrides) > 0 {
+ 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)
+ }
+ err := cfg.Validate()
+ return &cfg, err
+}
+
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+ _, err := provider.GetConfig(group, alert)
+ return err
+}
diff --git a/alerting/provider/threemagateway/threemagateway_test.go b/alerting/provider/threemagateway/threemagateway_test.go
new file mode 100644
index 000000000..bd6bfd66a
--- /dev/null
+++ b/alerting/provider/threemagateway/threemagateway_test.go
@@ -0,0 +1,327 @@
+package threemagateway
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/TwiN/gatus/v5/alerting/alert"
+ "github.com/TwiN/gatus/v5/config/endpoint"
+)
+
+func TestConfig_Validate(t *testing.T) {
+ tests := []struct {
+ name string
+ input Config
+ expected error
+ }{
+ {
+ name: "valid config",
+ input: Config{
+ ApiIdentity: "12345678",
+ ApiAuthSecret: "authsecret",
+ Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeId}},
+ },
+ expected: nil,
+ },
+ {
+ name: "missing ApiIdentity",
+ input: Config{
+ ApiAuthSecret: "authsecret",
+ Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeId}},
+ },
+ expected: ErrApiIdentityMissing,
+ },
+ {
+ name: "missing ApiAuthSecret",
+ input: Config{
+ ApiIdentity: "12345678",
+ Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeId}},
+ },
+ expected: ErrApiAuthSecretMissing,
+ },
+ {
+ name: "missing Recipients",
+ input: Config{
+ ApiIdentity: "12345678",
+ ApiAuthSecret: "authsecret",
+ },
+ expected: ErrRecipientsMissing,
+ },
+ {
+ name: "invalid Mode",
+ input: Config{
+ ApiIdentity: "12345678",
+ ApiAuthSecret: "authsecret",
+ Mode: &SendMode{Value: "invalid-mode", Type: ModeTypeInvalid},
+ Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeId}},
+ },
+ expected: ErrModeTypeInvalid,
+ },
+ {
+ name: "invalid ApiIdentity",
+ input: Config{
+ ApiIdentity: "invalid-id",
+ ApiAuthSecret: "authsecret",
+ Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeId}},
+ },
+ expected: ErrInvalidThreemaId,
+ },
+ {
+ name: "invalid ID Recipient",
+ input: Config{
+ ApiIdentity: "12345678",
+ ApiAuthSecret: "authsecret",
+ Recipients: []Recipient{{Value: "invalid-id", Type: RecipientTypeId}},
+ },
+ expected: ErrInvalidThreemaId,
+ },
+ {
+ name: "invalid Phone Recipient",
+ input: Config{
+ ApiIdentity: "12345678",
+ ApiAuthSecret: "authsecret",
+ Recipients: []Recipient{{Value: "a12345", Type: RecipientTypePhone}},
+ },
+ expected: ErrInvalidPhoneNumberFormat,
+ },
+ {
+ name: "invalid Email Recipient",
+ input: Config{
+ ApiIdentity: "12345678",
+ ApiAuthSecret: "authsecret",
+ Recipients: []Recipient{{Value: "invalid-email", Type: RecipientTypeEmail}},
+ },
+ expected: ErrInvalidEmailAddressFormat,
+ },
+ {
+ name: "too many Recipients in basic mode",
+ input: Config{
+ ApiIdentity: "12345678",
+ ApiAuthSecret: "authsecret",
+ Mode: &SendMode{Value: "basic", Type: ModeTypeBasic},
+ Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeId}, {Value: "ABCDEFGH", Type: RecipientTypeId}},
+ },
+ expected: ErrRecipientsTooMany,
+ },
+ {
+ name: "not implemented mode",
+ input: Config{
+ ApiIdentity: "12345678",
+ ApiAuthSecret: "authsecret",
+ Mode: &SendMode{Value: "e2ee", Type: ModeTypeE2EE},
+ Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeId}},
+ },
+ expected: ErrNotImplementedMode,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ err := test.input.Validate()
+ if errors.Is(err, test.expected) == false {
+ t.Errorf("expected %v, got %v", test.expected, err)
+ }
+ })
+ }
+}
+
+func TestConfig_Merge(t *testing.T) {
+ original := Config{
+ ApiBaseUrl: "https://api.threema.ch",
+ Mode: &SendMode{Value: "basic", Type: ModeTypeBasic},
+ ApiIdentity: "12345678",
+ Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeId}},
+ ApiAuthSecret: "authsecret",
+ }
+ override := Config{
+ ApiBaseUrl: "https://custom.api.threema.ch",
+ Mode: &SendMode{Value: "e2ee", Type: ModeTypeE2EE},
+ ApiIdentity: "ABCDEFGH",
+ Recipients: []Recipient{{Value: "HGFEDCBA", Type: RecipientTypeId}},
+ ApiAuthSecret: "newauthsecret",
+ }
+
+ change := original
+ change.Merge(&Config{}) // Merging with empty config should not change anything
+ if change.ApiBaseUrl != original.ApiBaseUrl {
+ t.Errorf("expected ApiBaseUrl to remain %s, got %s", original.ApiBaseUrl, change.ApiBaseUrl)
+ }
+ if change.Mode != original.Mode {
+ t.Errorf("expected Mode to remain %v, got %v", original.Mode, change.Mode)
+ }
+ if change.ApiIdentity != original.ApiIdentity {
+ t.Errorf("expected ApiIdentity to remain %s, got %s", original.ApiIdentity, change.ApiIdentity)
+ }
+ if len(change.Recipients) != len(original.Recipients) || change.Recipients[0] != original.Recipients[0] {
+ t.Errorf("expected Recipients to remain %v, got %v", original.Recipients, change.Recipients)
+ }
+ if change.ApiAuthSecret != original.ApiAuthSecret {
+ t.Errorf("expected ApiAuthSecret to remain %s, got %s", original.ApiAuthSecret, change.ApiAuthSecret)
+ }
+
+ change = original
+ change.Merge(&override)
+ if change.ApiBaseUrl != override.ApiBaseUrl {
+ t.Errorf("expected ApiBaseUrl to be %s, got %s", override.ApiBaseUrl, change.ApiBaseUrl)
+ }
+ if change.Mode.Value != override.Mode.Value || change.Mode.Type != override.Mode.Type {
+ t.Errorf("expected Mode to be %v, got %v", override.Mode, change.Mode)
+ }
+ if change.ApiIdentity != override.ApiIdentity {
+ t.Errorf("expected ApiIdentity to be %s, got %s", override.ApiIdentity, change.ApiIdentity)
+ }
+ if len(change.Recipients) != len(override.Recipients) || change.Recipients[0] != override.Recipients[0] {
+ t.Errorf("expected Recipients to be %v, got %v", override.Recipients, change.Recipients)
+ }
+ if change.ApiAuthSecret != override.ApiAuthSecret {
+ t.Errorf("expected ApiAuthSecret to be %s, got %s", override.ApiAuthSecret, change.ApiAuthSecret)
+ }
+}
+
+func TestAlertProvider_Validate(t *testing.T) {
+ if err := (&AlertProvider{
+ DefaultConfig: Config{
+ ApiIdentity: "12345678",
+ ApiAuthSecret: "authsecret",
+ Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeId}},
+ },
+ }).Validate(); err != nil {
+ t.Errorf("expected valid config to not return an error, got %v", err)
+ }
+
+ if err := (&AlertProvider{
+ DefaultConfig: Config{
+ ApiIdentity: "",
+ ApiAuthSecret: "authsecret",
+ Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeId}},
+ },
+ }).Validate(); !errors.Is(err, ErrApiIdentityMissing) {
+ t.Errorf("expected missing ApiIdentity to return %v, got %v", ErrApiIdentityMissing, err)
+ }
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+ testAlertDescription := "Test alert"
+ provider := &AlertProvider{
+ DefaultConfig: Config{
+ ApiIdentity: "12345678",
+ ApiAuthSecret: "authsecret",
+ Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeId}},
+ },
+ Overrides: []Override{{Group: "default", Config: Config{
+ ApiIdentity: "HGFEDCBA",
+ }}},
+ }
+ ep := &endpoint.Endpoint{Group: "default"}
+ alert := &alert.Alert{Description: &testAlertDescription, ProviderOverride: map[string]any{
+ "ApiAuthSecret": "someothersecret",
+ }}
+ result := &endpoint.Result{Success: false}
+
+ err := provider.Send(ep, alert, result, false)
+ if err == nil || strings.Contains(err.Error(), "401") == false {
+ t.Error("expected error due to invalid credentials, got nil")
+ }
+}
+
+func checkStringOccurenceCount(t *testing.T, body, expectedString string, expectedOccurrences int) {
+ t.Helper()
+ actualOccurrences := strings.Count(body, expectedString)
+ if actualOccurrences != expectedOccurrences {
+ t.Errorf("expected body to contain '%s' %d times, got %d: %s", expectedString, expectedOccurrences, actualOccurrences, body)
+ }
+}
+
+func TestAlertProvider_buildMessageBody(t *testing.T) {
+ testAlertDescription := "Test alert"
+ provider := &AlertProvider{}
+ ep := &endpoint.Endpoint{Name: "Test Endpoint", Group: "Custom Group"}
+ alert := &alert.Alert{Description: &testAlertDescription}
+ result := &endpoint.Result{Success: false, ConditionResults: []*endpoint.ConditionResult{
+ {Condition: "[CONNECTED] == true", Success: true},
+ {Condition: "[STATUS] == 200", Success: false},
+ }, Errors: []string{
+ "Failed to connect to host",
+ }}
+
+ body := provider.buildMessageBody(ep, alert, result, false)
+ checkStringOccurenceCount(t, body, "Custom Group/Test Endpoint", 1)
+ checkStringOccurenceCount(t, body, "triggered", 1)
+ checkStringOccurenceCount(t, body, "🚨", 1)
+ checkStringOccurenceCount(t, body, "✅", 1)
+ checkStringOccurenceCount(t, body, "❌", 2)
+ checkStringOccurenceCount(t, body, "Failed to connect to host", 1)
+
+ result.Success = true
+ result.ConditionResults[1].Success = true
+ result.Errors = nil
+
+ body = provider.buildMessageBody(ep, alert, result, true)
+ checkStringOccurenceCount(t, body, "Custom Group/Test Endpoint", 1)
+ checkStringOccurenceCount(t, body, "resolved", 1)
+ checkStringOccurenceCount(t, body, "✅", 1)
+ checkStringOccurenceCount(t, body, "🚨", 0)
+ checkStringOccurenceCount(t, body, "❌", 0)
+}
+
+func TestAlertProvider_prepareRequest(t *testing.T) {
+ provider := &AlertProvider{}
+ cfg := Config{
+ ApiIdentity: "12345678",
+ ApiAuthSecret: "authsecret",
+ Recipients: []Recipient{{Value: "87654321", Type: RecipientTypePhone}},
+ }
+ cfg.Validate()
+ body := "Test message body"
+
+ request, err := provider.prepareRequest(&cfg, body)
+ if err != nil {
+ t.Errorf("expected no error preparing request, got %v", err)
+ }
+ if request.Method != "POST" {
+ t.Errorf("expected request method to be POST, got %s", request.Method)
+ }
+ expectedUrl := cfg.ApiBaseUrl + "/send_simple"
+ if request.URL.String() != expectedUrl {
+ t.Errorf("expected request URL to be %s, got %s", expectedUrl, request.URL.String())
+ }
+ err = request.ParseForm()
+ if err != nil {
+ t.Errorf("expected no error parsing form, got %v", err)
+ }
+ if request.PostForm.Get("from") != cfg.ApiIdentity {
+ t.Errorf("expected 'from' to be %s, got %s", cfg.ApiIdentity, request.PostForm.Get("from"))
+ }
+ if request.PostForm.Get("phone") != cfg.Recipients[0].Value {
+ t.Errorf("expected 'phone' to be %s, got %s", cfg.Recipients[0].Value, request.PostForm.Get("to"))
+ }
+ if request.PostForm.Get("text") != body {
+ t.Errorf("expected 'text' to be %s, got %s", body, request.PostForm.Get("text"))
+ }
+ if request.PostForm.Get("secret") != cfg.ApiAuthSecret {
+ t.Errorf("expected 'secret' to be %s, got %s", cfg.ApiAuthSecret, request.PostForm.Get("secret"))
+ }
+
+ cfg.Recipients[0] = Recipient{Value: "test@mail.com", Type: RecipientTypeEmail}
+ request, err = provider.prepareRequest(&cfg, body)
+ if err != nil {
+ t.Errorf("expected no error preparing request, got %v", err)
+ }
+ err = request.ParseForm()
+ if err != nil {
+ t.Errorf("expected no error parsing form, got %v", err)
+ }
+ if request.PostForm.Get("email") != cfg.Recipients[0].Value {
+ t.Errorf("expected 'email' to be %s, got %s", cfg.Recipients[0].Value, request.PostForm.Get("to"))
+ }
+}
+
+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")
+ }
+}
diff --git a/alerting/provider/threemagateway/util.go b/alerting/provider/threemagateway/util.go
new file mode 100644
index 000000000..24ef20237
--- /dev/null
+++ b/alerting/provider/threemagateway/util.go
@@ -0,0 +1,23 @@
+package threemagateway
+
+import (
+ "errors"
+ "maps"
+ "slices"
+ "strings"
+)
+
+var (
+ ErrInvalidThreemaId = errors.New("Must be 8 characters long and alphabetic characters must be uppercase")
+)
+
+func joinKeys[V any](m map[string]V, separator string) string {
+ return strings.Join(slices.Collect(maps.Keys(m)), separator)
+}
+
+func validateThreemaId(id string) error {
+ if len(id) != 8 || strings.ToUpper(id) != id {
+ return ErrInvalidThreemaId
+ }
+ return nil
+}
diff --git a/config/config.go b/config/config.go
index 4d9724b54..ec9b1936e 100644
--- a/config/config.go
+++ b/config/config.go
@@ -628,6 +628,7 @@ func ValidateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
alert.TypeTeams,
alert.TypeTeamsWorkflows,
alert.TypeTelegram,
+ alert.TypeThreemaGateway,
alert.TypeTwilio,
alert.TypeVonage,
alert.TypeWebex,
diff --git a/config/config_test.go b/config/config_test.go
index 811f0ed1c..7d90414d5 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -45,6 +45,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
+ "github.com/TwiN/gatus/v5/alerting/provider/threemagateway"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/vonage"
"github.com/TwiN/gatus/v5/alerting/provider/webex"
@@ -1887,6 +1888,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
Telegram: &telegram.AlertProvider{},
Teams: &teams.AlertProvider{},
TeamsWorkflows: &teamsworkflows.AlertProvider{},
+ ThreemaGateway: &threemagateway.AlertProvider{},
Twilio: &twilio.AlertProvider{},
Vonage: &vonage.AlertProvider{},
Webex: &webex.AlertProvider{},
@@ -1931,6 +1933,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
{alertType: alert.TypeTelegram, expected: alertingConfig.Telegram},
{alertType: alert.TypeTeams, expected: alertingConfig.Teams},
{alertType: alert.TypeTeamsWorkflows, expected: alertingConfig.TeamsWorkflows},
+ {alertType: alert.TypeThreemaGateway, expected: alertingConfig.ThreemaGateway},
{alertType: alert.TypeTwilio, expected: alertingConfig.Twilio},
{alertType: alert.TypeVonage, expected: alertingConfig.Vonage},
{alertType: alert.TypeWebex, expected: alertingConfig.Webex},
diff --git a/watchdog/alerting_test.go b/watchdog/alerting_test.go
index 2c9ccd922..03929dae4 100644
--- a/watchdog/alerting_test.go
+++ b/watchdog/alerting_test.go
@@ -24,6 +24,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
+ "github.com/TwiN/gatus/v5/alerting/provider/threemagateway"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/vonage"
"github.com/TwiN/gatus/v5/alerting/provider/zapier"
@@ -454,6 +455,21 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
},
},
},
+ {
+ Name: "threemagateway",
+ AlertType: alert.TypeThreemaGateway,
+ AlertingConfig: &alerting.Config{
+ ThreemaGateway: &threemagateway.AlertProvider{
+ DefaultConfig: threemagateway.Config{
+ ApiBaseUrl: "test-url",
+ Mode: nil,
+ ApiIdentity: "87654321",
+ Recipients: []threemagateway.Recipient{{Value: "12345678", Type: threemagateway.RecipientTypeId}},
+ ApiAuthSecret: "test-secret",
+ },
+ },
+ },
+ },
{
Name: "twilio",
AlertType: alert.TypeTwilio,