diff --git a/README.md b/README.md index 08d751fb..efcb61a4 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Have any feedback or want to share your good/bad experience with Gatus? Feel fre - [Client configuration](#client-configuration) - [Alerting](#alerting) - [Configuring Discord alerts](#configuring-discord-alerts) + - [Configuring Email alerts](#configuring-email-alerts) - [Configuring Mattermost alerts](#configuring-mattermost-alerts) - [Configuring Messagebird alerts](#configuring-messagebird-alerts) - [Configuring PagerDuty alerts](#configuring-pagerduty-alerts) @@ -348,6 +349,44 @@ endpoints: ``` +#### Configuring Email alerts +| Parameter | Description | Default | +|:---------------------------------- |:------------------------------------------------- |:-------------- | +| `alerting.email` | Configuration for alerts of type `email` | `{}` | +| `alerting.email.from` | Email used to send the alert | Required `""` | +| `alerting.email.password` | Password of the email used to send the alert | Required `""` | +| `alerting.email.host` | Host of the mail server (e.g. `smtp.gmail.com`) | Required `""` | +| `alerting.email.port` | Port the mail server is listening to (e.g. `587`) | Required `0` | +| `alerting.email.to` | Email(s) to send the alerts to | Required `""` | +| `alerting.email.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | + +```yaml +alerting: + email: + from: "from@example.com" + password: "hunter2" + host: "mail.example.com" + port: 587 + to: "recipient1@example.com,recipient2@example.com" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + - "[BODY].status == UP" + - "[RESPONSE_TIME] < 300" + alerts: + - type: email + enabled: true + description: "healthcheck failed" + send-on-resolved: true +``` + +**NOTE:** Some mail servers are painfully slow. + + #### Configuring Mattermost alerts | Parameter | Description | Default | |:----------------------------------- |:------------------------------------------------------------------------------------------- |:-------------- | diff --git a/alerting/alert/type.go b/alerting/alert/type.go index 434accbb..8ca233df 100644 --- a/alerting/alert/type.go +++ b/alerting/alert/type.go @@ -11,6 +11,9 @@ const ( // TypeDiscord is the Type for the discord alerting provider TypeDiscord Type = "discord" + // TypeEmail is the Type for the email alerting provider + TypeEmail Type = "email" + // TypeMattermost is the Type for the mattermost alerting provider TypeMattermost Type = "mattermost" diff --git a/alerting/config.go b/alerting/config.go index 76b471aa..6554a380 100644 --- a/alerting/config.go +++ b/alerting/config.go @@ -5,6 +5,7 @@ import ( "github.com/TwiN/gatus/v3/alerting/provider" "github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v3/alerting/provider/discord" + "github.com/TwiN/gatus/v3/alerting/provider/email" "github.com/TwiN/gatus/v3/alerting/provider/mattermost" "github.com/TwiN/gatus/v3/alerting/provider/messagebird" "github.com/TwiN/gatus/v3/alerting/provider/pagerduty" @@ -17,31 +18,34 @@ import ( // Config is the configuration for alerting providers type Config struct { // Custom is the configuration for the custom alerting provider - Custom *custom.AlertProvider `yaml:"custom"` + Custom *custom.AlertProvider `yaml:"custom,omitempty"` // Discord is the configuration for the discord alerting provider - Discord *discord.AlertProvider `yaml:"discord"` + Discord *discord.AlertProvider `yaml:"discord,omitempty"` + + // Email is the configuration for the email alerting provider + Email *email.AlertProvider `yaml:"email,omitempty"` // Mattermost is the configuration for the mattermost alerting provider - Mattermost *mattermost.AlertProvider `yaml:"mattermost"` + Mattermost *mattermost.AlertProvider `yaml:"mattermost,omitempty"` // Messagebird is the configuration for the messagebird alerting provider - Messagebird *messagebird.AlertProvider `yaml:"messagebird"` + Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"` // PagerDuty is the configuration for the pagerduty alerting provider - PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty"` + PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"` // Slack is the configuration for the slack alerting provider - Slack *slack.AlertProvider `yaml:"slack"` + Slack *slack.AlertProvider `yaml:"slack,omitempty"` // Teams is the configuration for the teams alerting provider - Teams *teams.AlertProvider `yaml:"teams"` + Teams *teams.AlertProvider `yaml:"teams,omitempty"` // Telegram is the configuration for the telegram alerting provider - Telegram *telegram.AlertProvider `yaml:"telegram"` + Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"` // Twilio is the configuration for the twilio alerting provider - Twilio *twilio.AlertProvider `yaml:"twilio"` + Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"` } // GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type @@ -59,6 +63,12 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid return nil } return config.Discord + case alert.TypeEmail: + if config.Email == nil { + // Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil + return nil + } + return config.Email case alert.TypeMattermost: if config.Mattermost == nil { // Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil diff --git a/alerting/provider/custom/custom.go b/alerting/provider/custom/custom.go index 5382f647..31fc2923 100644 --- a/alerting/provider/custom/custom.go +++ b/alerting/provider/custom/custom.go @@ -38,11 +38,6 @@ func (provider *AlertProvider) IsValid() bool { return len(provider.URL) > 0 && provider.ClientConfig != nil } -// ToCustomAlertProvider converts the provider into a custom.AlertProvider -func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *AlertProvider { - return provider -} - // GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string { status := "TRIGGERED" @@ -105,27 +100,26 @@ func (provider *AlertProvider) buildHTTPRequest(endpointName, alertDescription s return request } -// Send a request to the alert provider and return the body -func (provider *AlertProvider) Send(endpointName, alertDescription string, resolved bool) ([]byte, error) { +func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { if os.Getenv("MOCK_ALERT_PROVIDER") == "true" { if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" { - return nil, errors.New("error") + return errors.New("error") } - return []byte("{}"), nil + return nil } - request := provider.buildHTTPRequest(endpointName, alertDescription, resolved) + request := provider.buildHTTPRequest(endpoint.Name, alert.GetDescription(), resolved) response, err := client.GetHTTPClient(provider.ClientConfig).Do(request) if err != nil { - return nil, err + return err } if response.StatusCode > 399 { body, err := ioutil.ReadAll(response.Body) if err != nil { - return nil, fmt.Errorf("call to provider alert returned status code %d", response.StatusCode) + return fmt.Errorf("call to provider alert returned status code %d", response.StatusCode) } - return nil, fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) + return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) } - return ioutil.ReadAll(response.Body) + return err } // GetDefaultAlert returns the provider's default alert configuration diff --git a/alerting/provider/custom/custom_test.go b/alerting/provider/custom/custom_test.go index 806486f2..06dfaf99 100644 --- a/alerting/provider/custom/custom_test.go +++ b/alerting/provider/custom/custom_test.go @@ -3,9 +3,6 @@ package custom import ( "io/ioutil" "testing" - - "github.com/TwiN/gatus/v3/alerting/alert" - "github.com/TwiN/gatus/v3/core" ) func TestAlertProvider_IsValid(t *testing.T) { @@ -59,17 +56,6 @@ func TestAlertProvider_buildHTTPRequestWhenTriggered(t *testing.T) { } } -func TestAlertProvider_ToCustomAlertProvider(t *testing.T) { - provider := AlertProvider{URL: "https://example.com"} - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, true) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") - } - if customAlertProvider.URL != "https://example.com" { - t.Error("expected URL to be https://example.com, got", customAlertProvider.URL) - } -} - func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) { const ( ExpectedURL = "https://example.com/endpoint-name?event=test&description=alert-description" diff --git a/alerting/provider/discord/discord.go b/alerting/provider/discord/discord.go index 608600c6..0872317c 100644 --- a/alerting/provider/discord/discord.go +++ b/alerting/provider/discord/discord.go @@ -1,11 +1,15 @@ package discord import ( + "bytes" + "errors" "fmt" + "io/ioutil" "net/http" + "os" "github.com/TwiN/gatus/v3/alerting/alert" - "github.com/TwiN/gatus/v3/alerting/provider/custom" + "github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v3/core" ) @@ -22,8 +26,36 @@ func (provider *AlertProvider) IsValid() bool { return len(provider.WebhookURL) > 0 } -// ToCustomAlertProvider converts the provider into a custom.AlertProvider -func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider { +// Send an alert using the provider +func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { + if os.Getenv("MOCK_ALERT_PROVIDER") == "true" { + if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" { + return errors.New("error") + } + return nil + } + buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved))) + request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + if response.StatusCode > 399 { + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return fmt.Errorf("call to provider alert returned status code %d", response.StatusCode) + } + return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) + } + return err +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string { var message, results string var colorCode int if resolved { @@ -46,10 +78,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { description = ":\\n> " + alertDescription } - return &custom.AlertProvider{ - URL: provider.WebhookURL, - Method: http.MethodPost, - Body: fmt.Sprintf(`{ + return fmt.Sprintf(`{ "content": "", "embeds": [ { @@ -65,9 +94,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al ] } ] -}`, message, description, colorCode, results), - Headers: map[string]string{"Content-Type": "application/json"}, - } +}`, message, description, colorCode, results) } // GetDefaultAlert returns the provider's default alert configuration diff --git a/alerting/provider/discord/discord_test.go b/alerting/provider/discord/discord_test.go index 60c655e2..7692740c 100644 --- a/alerting/provider/discord/discord_test.go +++ b/alerting/provider/discord/discord_test.go @@ -2,8 +2,6 @@ package discord import ( "encoding/json" - "net/http" - "strings" "testing" "github.com/TwiN/gatus/v3/alerting/alert" @@ -21,50 +19,51 @@ func TestAlertProvider_IsValid(t *testing.T) { } } -func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { - provider := AlertProvider{WebhookURL: "http://example.com"} - alertDescription := "test" - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") +func TestAlertProvider_buildRequestBody(t *testing.T) { + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + ExpectedBody string + }{ + { + Name: "triggered", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: "{\n \"content\": \"\",\n \"embeds\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"description\": \"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"color\": 15158332,\n \"fields\": [\n {\n \"name\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"inline\": false\n }\n ]\n }\n ]\n}", + }, + { + Name: "resolved", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: "{\n \"content\": \"\",\n \"embeds\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"description\": \"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"color\": 3066993,\n \"fields\": [\n {\n \"name\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"inline\": false\n }\n ]\n }\n ]\n}", + }, } - if !strings.Contains(customAlertProvider.Body, "resolved") { - t.Error("customAlertProvider.Body should've contained the substring resolved") - } - if customAlertProvider.URL != "http://example.com" { - t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - body := make(map[string]interface{}) - err := json.Unmarshal([]byte(customAlertProvider.Body), &body) - if err != nil { - t.Error("expected body to be valid JSON, got error:", err.Error()) - } - if expected := "An alert for **svc** has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["embeds"].([]interface{})[0].(map[string]interface{})["description"] { - t.Errorf("expected $.embeds[0].description to be %s, got %s", expected, body["embeds"].([]interface{})[0].(map[string]interface{})["description"]) - } -} - -func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { - provider := AlertProvider{WebhookURL: "http://example.com"} - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") - } - if !strings.Contains(customAlertProvider.Body, "triggered") { - t.Error("customAlertProvider.Body should've contained the substring triggered") - } - if customAlertProvider.URL != "http://example.com" { - t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - body := make(map[string]interface{}) - err := json.Unmarshal([]byte(customAlertProvider.Body), &body) - if err != nil { - t.Error("expected body to be valid JSON, got error:", err.Error()) + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + body := scenario.Provider.buildRequestBody( + &core.Endpoint{Name: "endpoint-name"}, + &scenario.Alert, + &core.Result{ + ConditionResults: []*core.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + }, + }, + scenario.Resolved, + ) + if body != scenario.ExpectedBody { + t.Errorf("expected %s, got %s", scenario.ExpectedBody, body) + } + out := make(map[string]interface{}) + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Error("expected body to be valid JSON, got error:", err.Error()) + } + }) } } diff --git a/alerting/provider/email/email.go b/alerting/provider/email/email.go new file mode 100644 index 00000000..cece01d8 --- /dev/null +++ b/alerting/provider/email/email.go @@ -0,0 +1,79 @@ +package email + +import ( + "errors" + "fmt" + "math" + "os" + "strings" + + "github.com/TwiN/gatus/v3/alerting/alert" + "github.com/TwiN/gatus/v3/core" + gomail "gopkg.in/mail.v2" +) + +// AlertProvider is the configuration necessary for sending an alert using SMTP +type AlertProvider struct { + From string `yaml:"from"` + Password string `yaml:"password"` + Host string `yaml:"host"` + Port int `yaml:"port"` + To string `yaml:"to"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` +} + +// IsValid returns whether the provider's configuration is valid +func (provider *AlertProvider) IsValid() bool { + return len(provider.From) > 0 && len(provider.Password) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16 +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { + if os.Getenv("MOCK_ALERT_PROVIDER") == "true" { + if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" { + return errors.New("error") + } + return nil + } + subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved) + m := gomail.NewMessage() + m.SetHeader("From", provider.From) + m.SetHeader("To", strings.Split(provider.To, ",")...) + m.SetHeader("Subject", subject) + m.SetBody("text/plain", body) + d := gomail.NewDialer(provider.Host, provider.Port, provider.From, provider.Password) + return d.DialAndSend(m) +} + +// buildMessageSubjectAndBody builds the message subject and body +func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) { + var subject, message, results string + if resolved { + subject = fmt.Sprintf("[%s] Alert resolved", endpoint.Name) + message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold) + } else { + subject = fmt.Sprintf("[%s] Alert triggered", endpoint.Name) + message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold) + } + for _, conditionResult := range result.ConditionResults { + var prefix string + if conditionResult.Success { + prefix = "✅" + } else { + prefix = "❌" + } + results += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition) + } + var description string + if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { + description = "\n\nAlert description: " + alertDescription + } + return subject, message + description + "\n\nCondition results:\n" + results +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} diff --git a/alerting/provider/email/email_test.go b/alerting/provider/email/email_test.go new file mode 100644 index 00000000..49d98451 --- /dev/null +++ b/alerting/provider/email/email_test.go @@ -0,0 +1,70 @@ +package email + +import ( + "testing" + + "github.com/TwiN/gatus/v3/alerting/alert" + "github.com/TwiN/gatus/v3/core" +) + +func TestAlertProvider_IsValid(t *testing.T) { + invalidProvider := AlertProvider{} + if invalidProvider.IsValid() { + t.Error("provider shouldn't have been valid") + } + validProvider := AlertProvider{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"} + if !validProvider.IsValid() { + t.Error("provider should've been valid") + } +} + +func TestAlertProvider_buildRequestBody(t *testing.T) { + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + ExpectedSubject string + ExpectedBody string + }{ + { + Name: "triggered", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + 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, + 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", + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + subject, body := scenario.Provider.buildMessageSubjectAndBody( + &core.Endpoint{Name: "endpoint-name"}, + &scenario.Alert, + &core.Result{ + ConditionResults: []*core.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) + } + }) + } +} diff --git a/alerting/provider/mattermost/mattermost.go b/alerting/provider/mattermost/mattermost.go index 76199759..e42e1497 100644 --- a/alerting/provider/mattermost/mattermost.go +++ b/alerting/provider/mattermost/mattermost.go @@ -1,11 +1,14 @@ package mattermost import ( + "bytes" + "errors" "fmt" + "io/ioutil" "net/http" + "os" "github.com/TwiN/gatus/v3/alerting/alert" - "github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v3/core" ) @@ -29,10 +32,37 @@ func (provider *AlertProvider) IsValid() bool { return len(provider.WebhookURL) > 0 } -// ToCustomAlertProvider converts the provider into a custom.AlertProvider -func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider { - var message string - var color string +// Send an alert using the provider +func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { + if os.Getenv("MOCK_ALERT_PROVIDER") == "true" { + if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" { + return errors.New("error") + } + return nil + } + buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved))) + request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + response, err := client.GetHTTPClient(provider.ClientConfig).Do(request) + if err != nil { + return err + } + if response.StatusCode > 399 { + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return fmt.Errorf("call to provider alert returned status code %d", response.StatusCode) + } + return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) + } + return err +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string { + var message, color string if resolved { message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold) color = "#36A64F" @@ -54,11 +84,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { description = ":\\n> " + alertDescription } - return &custom.AlertProvider{ - URL: provider.WebhookURL, - Method: http.MethodPost, - ClientConfig: provider.ClientConfig, - Body: fmt.Sprintf(`{ + return fmt.Sprintf(`{ "text": "", "username": "gatus", "icon_url": "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png", @@ -83,9 +109,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al ] } ] -}`, message, message, description, color, endpoint.URL, results), - Headers: map[string]string{"Content-Type": "application/json"}, - } +}`, message, message, description, color, endpoint.URL, results) } // GetDefaultAlert returns the provider's default alert configuration diff --git a/alerting/provider/mattermost/mattermost_test.go b/alerting/provider/mattermost/mattermost_test.go index b0089b89..7f005b1f 100644 --- a/alerting/provider/mattermost/mattermost_test.go +++ b/alerting/provider/mattermost/mattermost_test.go @@ -2,8 +2,6 @@ package mattermost import ( "encoding/json" - "net/http" - "strings" "testing" "github.com/TwiN/gatus/v3/alerting/alert" @@ -21,50 +19,51 @@ func TestAlertProvider_IsValid(t *testing.T) { } } -func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { - provider := AlertProvider{WebhookURL: "http://example.org"} - alertDescription := "test" - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") +func TestAlertProvider_buildRequestBody(t *testing.T) { + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + ExpectedBody string + }{ + { + Name: "triggered", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: "{\n \"text\": \"\",\n \"username\": \"gatus\",\n \"icon_url\": \"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\n \"attachments\": [\n {\n \"title\": \":rescue_worker_helmet: Gatus\",\n \"fallback\": \"Gatus - An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row\",\n \"text\": \"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"short\": false,\n \"color\": \"#DD0000\",\n \"fields\": [\n {\n \"title\": \"URL\",\n \"value\": \"\",\n \"short\": false\n },\n {\n \"title\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}", + }, + { + Name: "resolved", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: "{\n \"text\": \"\",\n \"username\": \"gatus\",\n \"icon_url\": \"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\n \"attachments\": [\n {\n \"title\": \":rescue_worker_helmet: Gatus\",\n \"fallback\": \"Gatus - An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row\",\n \"text\": \"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"short\": false,\n \"color\": \"#36A64F\",\n \"fields\": [\n {\n \"title\": \"URL\",\n \"value\": \"\",\n \"short\": false\n },\n {\n \"title\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}", + }, } - if !strings.Contains(customAlertProvider.Body, "resolved") { - t.Error("customAlertProvider.Body should've contained the substring resolved") - } - if customAlertProvider.URL != "http://example.org" { - t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - body := make(map[string]interface{}) - err := json.Unmarshal([]byte(customAlertProvider.Body), &body) - if err != nil { - t.Error("expected body to be valid JSON, got error:", err.Error()) - } - if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["attachments"].([]interface{})[0].(map[string]interface{})["text"] { - t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"]) - } -} - -func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { - provider := AlertProvider{WebhookURL: "http://example.org"} - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") - } - if !strings.Contains(customAlertProvider.Body, "triggered") { - t.Error("customAlertProvider.Body should've contained the substring triggered") - } - if customAlertProvider.URL != "http://example.org" { - t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - body := make(map[string]interface{}) - err := json.Unmarshal([]byte(customAlertProvider.Body), &body) - if err != nil { - t.Error("expected body to be valid JSON, got error:", err.Error()) + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + body := scenario.Provider.buildRequestBody( + &core.Endpoint{Name: "endpoint-name"}, + &scenario.Alert, + &core.Result{ + ConditionResults: []*core.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + }, + }, + scenario.Resolved, + ) + if body != scenario.ExpectedBody { + t.Errorf("expected %s, got %s", scenario.ExpectedBody, body) + } + out := make(map[string]interface{}) + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Error("expected body to be valid JSON, got error:", err.Error()) + } + }) } } diff --git a/alerting/provider/messagebird/messagebird.go b/alerting/provider/messagebird/messagebird.go index 5cb89c86..868747c9 100644 --- a/alerting/provider/messagebird/messagebird.go +++ b/alerting/provider/messagebird/messagebird.go @@ -1,11 +1,15 @@ package messagebird import ( + "bytes" + "errors" "fmt" + "io/ioutil" "net/http" + "os" "github.com/TwiN/gatus/v3/alerting/alert" - "github.com/TwiN/gatus/v3/alerting/provider/custom" + "github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v3/core" ) @@ -20,7 +24,7 @@ type AlertProvider struct { Recipients string `yaml:"recipients"` // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type - DefaultAlert *alert.Alert `yaml:"default-alert"` + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` } // IsValid returns whether the provider's configuration is valid @@ -28,28 +32,49 @@ func (provider *AlertProvider) IsValid() bool { return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0 } -// ToCustomAlertProvider converts the provider into a custom.AlertProvider -// Reference doc for messagebird https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms -func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider { +// Send an alert using the provider +// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms +func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { + if os.Getenv("MOCK_ALERT_PROVIDER") == "true" { + if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" { + return errors.New("error") + } + return nil + } + buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved))) + request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("AccessKey %s", provider.AccessKey)) + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + if response.StatusCode > 399 { + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return fmt.Errorf("call to provider alert returned status code %d", response.StatusCode) + } + return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) + } + return err +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string { var message string if resolved { message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()) } else { message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription()) } - return &custom.AlertProvider{ - URL: restAPIURL, - Method: http.MethodPost, - Body: fmt.Sprintf(`{ + return fmt.Sprintf(`{ "originator": "%s", "recipients": "%s", "body": "%s" -}`, provider.Originator, provider.Recipients, message), - Headers: map[string]string{ - "Content-Type": "application/json", - "Authorization": fmt.Sprintf("AccessKey %s", provider.AccessKey), - }, - } +}`, provider.Originator, provider.Recipients, message) } // GetDefaultAlert returns the provider's default alert configuration diff --git a/alerting/provider/messagebird/messagebird_test.go b/alerting/provider/messagebird/messagebird_test.go index ec9ce85c..3e40ab97 100644 --- a/alerting/provider/messagebird/messagebird_test.go +++ b/alerting/provider/messagebird/messagebird_test.go @@ -2,8 +2,6 @@ package messagebird import ( "encoding/json" - "net/http" - "strings" "testing" "github.com/TwiN/gatus/v3/alerting/alert" @@ -25,54 +23,51 @@ func TestMessagebirdAlertProvider_IsValid(t *testing.T) { } } -func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { - provider := AlertProvider{ - AccessKey: "1", - Originator: "1", - Recipients: "1", +func TestAlertProvider_buildRequestBody(t *testing.T) { + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + ExpectedBody string + }{ + { + Name: "triggered", + Provider: AlertProvider{AccessKey: "1", Originator: "2", Recipients: "3"}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: "{\n \"originator\": \"2\",\n \"recipients\": \"3\",\n \"body\": \"TRIGGERED: endpoint-name - description-1\"\n}", + }, + { + Name: "resolved", + Provider: AlertProvider{AccessKey: "4", Originator: "5", Recipients: "6"}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: "{\n \"originator\": \"5\",\n \"recipients\": \"6\",\n \"body\": \"RESOLVED: endpoint-name - description-2\"\n}", + }, } - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, true) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") - } - if !strings.Contains(customAlertProvider.Body, "RESOLVED") { - t.Error("customAlertProvider.Body should've contained the substring RESOLVED") - } - if customAlertProvider.URL != "https://rest.messagebird.com/messages" { - t.Errorf("expected URL to be %s, got %s", "https://rest.messagebird.com/messages", customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - body := make(map[string]interface{}) - err := json.Unmarshal([]byte(customAlertProvider.Body), &body) - if err != nil { - t.Error("expected body to be valid JSON, got error:", err.Error()) - } -} - -func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { - provider := AlertProvider{ - AccessKey: "1", - Originator: "1", - Recipients: "1", - } - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, false) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") - } - if !strings.Contains(customAlertProvider.Body, "TRIGGERED") { - t.Error("customAlertProvider.Body should've contained the substring TRIGGERED") - } - if customAlertProvider.URL != "https://rest.messagebird.com/messages" { - t.Errorf("expected URL to be %s, got %s", "https://rest.messagebird.com/messages", customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - body := make(map[string]interface{}) - err := json.Unmarshal([]byte(customAlertProvider.Body), &body) - if err != nil { - t.Error("expected body to be valid JSON, got error:", err.Error()) + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + body := scenario.Provider.buildRequestBody( + &core.Endpoint{Name: "endpoint-name"}, + &scenario.Alert, + &core.Result{ + ConditionResults: []*core.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + }, + }, + scenario.Resolved, + ) + if body != scenario.ExpectedBody { + t.Errorf("expected %s, got %s", scenario.ExpectedBody, body) + } + out := make(map[string]interface{}) + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Error("expected body to be valid JSON, got error:", err.Error()) + } + }) } } diff --git a/alerting/provider/pagerduty/pagerduty.go b/alerting/provider/pagerduty/pagerduty.go index 16f43cb7..36e4bc73 100644 --- a/alerting/provider/pagerduty/pagerduty.go +++ b/alerting/provider/pagerduty/pagerduty.go @@ -1,11 +1,17 @@ package pagerduty import ( + "bytes" + "encoding/json" + "errors" "fmt" + "io/ioutil" + "log" "net/http" + "os" "github.com/TwiN/gatus/v3/alerting/alert" - "github.com/TwiN/gatus/v3/alerting/provider/custom" + "github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v3/core" ) @@ -18,10 +24,10 @@ type AlertProvider struct { IntegrationKey string `yaml:"integration-key"` // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type - DefaultAlert *alert.Alert `yaml:"default-alert"` + 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"` + Overrides []Override `yaml:"overrides,omitempty"` } // Override is a case under which the default integration is overridden @@ -45,10 +51,55 @@ func (provider *AlertProvider) IsValid() bool { return len(provider.IntegrationKey) == 32 || len(provider.Overrides) != 0 } -// ToCustomAlertProvider converts the provider into a custom.AlertProvider +// Send an alert using the provider // -// relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/ -func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider { +// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/ +func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { + if os.Getenv("MOCK_ALERT_PROVIDER") == "true" { + if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" { + return errors.New("error") + } + return nil + } + buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved))) + request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + if response.StatusCode > 399 { + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return fmt.Errorf("call to provider alert returned status code %d", response.StatusCode) + } + return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) + } + if alert.IsSendingOnResolved() { + if resolved { + // The alert has been resolved and there's no error, so we can clear the alert's ResolveKey + alert.ResolveKey = "" + } else { + // We need to retrieve the resolve key from the response + body, err := ioutil.ReadAll(response.Body) + var payload pagerDutyResponsePayload + if err = json.Unmarshal(body, &payload); err != nil { + // Silently fail. We don't want to create tons of alerts just because we failed to parse + // the body. + log.Printf("[pagerduty][Send] Ran into error unmarshaling pagerduty response: %s", err.Error()) + } else { + alert.ResolveKey = payload.DedupKey + } + } + } + return nil +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string { var message, eventAction, resolveKey string if resolved { message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()) @@ -59,10 +110,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al eventAction = "trigger" resolveKey = "" } - return &custom.AlertProvider{ - URL: restAPIURL, - Method: http.MethodPost, - Body: fmt.Sprintf(`{ + return fmt.Sprintf(`{ "routing_key": "%s", "dedup_key": "%s", "event_action": "%s", @@ -71,11 +119,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al "source": "%s", "severity": "critical" } -}`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name), - Headers: map[string]string{ - "Content-Type": "application/json", - }, - } +}`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name) } // getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group @@ -94,3 +138,9 @@ func (provider *AlertProvider) getIntegrationKeyForGroup(group string) string { func (provider AlertProvider) GetDefaultAlert() *alert.Alert { return provider.DefaultAlert } + +type pagerDutyResponsePayload struct { + Status string `json:"status"` + Message string `json:"message"` + DedupKey string `json:"dedup_key"` +} diff --git a/alerting/provider/pagerduty/pagerduty_test.go b/alerting/provider/pagerduty/pagerduty_test.go index 8e20b1f6..ec584aed 100644 --- a/alerting/provider/pagerduty/pagerduty_test.go +++ b/alerting/provider/pagerduty/pagerduty_test.go @@ -2,8 +2,6 @@ package pagerduty import ( "encoding/json" - "net/http" - "strings" "testing" "github.com/TwiN/gatus/v3/alerting/alert" @@ -57,107 +55,41 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) { } } -func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { - provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"} - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, true) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") - } - if !strings.Contains(customAlertProvider.Body, "RESOLVED") { - t.Error("customAlertProvider.Body should've contained the substring RESOLVED") - } - if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" { - t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - body := make(map[string]interface{}) - err := json.Unmarshal([]byte(customAlertProvider.Body), &body) - if err != nil { - t.Error("expected body to be valid JSON, got error:", err.Error()) - } -} - -func TestAlertProvider_ToCustomAlertProviderWithResolvedAlertAndOverride(t *testing.T) { - provider := AlertProvider{ - IntegrationKey: "", - Overrides: []Override{ - { - IntegrationKey: "00000000000000000000000000000000", - Group: "group", - }, +func TestAlertProvider_buildRequestBody(t *testing.T) { + description := "test" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + ExpectedBody string + }{ + { + Name: "triggered", + Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"}, + Alert: alert.Alert{Description: &description}, + Resolved: false, + ExpectedBody: "{\n \"routing_key\": \"00000000000000000000000000000000\",\n \"dedup_key\": \"\",\n \"event_action\": \"trigger\",\n \"payload\": {\n \"summary\": \"TRIGGERED: - test\",\n \"source\": \"\",\n \"severity\": \"critical\"\n }\n}", + }, + { + Name: "resolved", + Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"}, + Alert: alert.Alert{Description: &description, ResolveKey: "key"}, + Resolved: true, + ExpectedBody: "{\n \"routing_key\": \"00000000000000000000000000000000\",\n \"dedup_key\": \"key\",\n \"event_action\": \"resolve\",\n \"payload\": {\n \"summary\": \"RESOLVED: - test\",\n \"source\": \"\",\n \"severity\": \"critical\"\n }\n}", }, } - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, true) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") - } - if !strings.Contains(customAlertProvider.Body, "RESOLVED") { - t.Error("customAlertProvider.Body should've contained the substring RESOLVED") - } - if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" { - t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - body := make(map[string]interface{}) - err := json.Unmarshal([]byte(customAlertProvider.Body), &body) - if err != nil { - t.Error("expected body to be valid JSON, got error:", err.Error()) - } -} - -func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { - provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"} - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, false) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") - } - if !strings.Contains(customAlertProvider.Body, "TRIGGERED") { - t.Error("customAlertProvider.Body should've contained the substring TRIGGERED") - } - if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" { - t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - body := make(map[string]interface{}) - err := json.Unmarshal([]byte(customAlertProvider.Body), &body) - if err != nil { - t.Error("expected body to be valid JSON, got error:", err.Error()) - } -} - -func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlertAndOverride(t *testing.T) { - provider := AlertProvider{ - IntegrationKey: "", - Overrides: []Override{ - { - IntegrationKey: "00000000000000000000000000000000", - Group: "group", - }, - }, - } - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, false) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") - } - if !strings.Contains(customAlertProvider.Body, "TRIGGERED") { - t.Error("customAlertProvider.Body should've contained the substring TRIGGERED") - } - if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" { - t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - body := make(map[string]interface{}) - err := json.Unmarshal([]byte(customAlertProvider.Body), &body) - if err != nil { - t.Error("expected body to be valid JSON, got error:", err.Error()) + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + body := scenario.Provider.buildRequestBody(&core.Endpoint{}, &scenario.Alert, &core.Result{}, scenario.Resolved) + if body != scenario.ExpectedBody { + t.Errorf("expected %s, got %s", scenario.ExpectedBody, body) + } + out := make(map[string]interface{}) + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Error("expected body to be valid JSON, got error:", err.Error()) + } + }) } } diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go index d72d013e..f997b35c 100644 --- a/alerting/provider/provider.go +++ b/alerting/provider/provider.go @@ -4,6 +4,7 @@ import ( "github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v3/alerting/provider/discord" + "github.com/TwiN/gatus/v3/alerting/provider/email" "github.com/TwiN/gatus/v3/alerting/provider/mattermost" "github.com/TwiN/gatus/v3/alerting/provider/messagebird" "github.com/TwiN/gatus/v3/alerting/provider/pagerduty" @@ -19,11 +20,11 @@ type AlertProvider interface { // IsValid returns whether the provider's configuration is valid IsValid() bool - // ToCustomAlertProvider converts the provider into a custom.AlertProvider - ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider - // GetDefaultAlert returns the provider's default alert configuration GetDefaultAlert() *alert.Alert + + // Send an alert using the provider + Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error } // ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline @@ -52,6 +53,7 @@ var ( // Validate interface implementation on compile _ AlertProvider = (*custom.AlertProvider)(nil) _ AlertProvider = (*discord.AlertProvider)(nil) + _ AlertProvider = (*email.AlertProvider)(nil) _ AlertProvider = (*mattermost.AlertProvider)(nil) _ AlertProvider = (*messagebird.AlertProvider)(nil) _ AlertProvider = (*pagerduty.AlertProvider)(nil) diff --git a/alerting/provider/slack/slack.go b/alerting/provider/slack/slack.go index 53a2b66b..266890a6 100644 --- a/alerting/provider/slack/slack.go +++ b/alerting/provider/slack/slack.go @@ -1,11 +1,15 @@ package slack import ( + "bytes" + "errors" "fmt" + "io/ioutil" "net/http" + "os" "github.com/TwiN/gatus/v3/alerting/alert" - "github.com/TwiN/gatus/v3/alerting/provider/custom" + "github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v3/core" ) @@ -22,8 +26,36 @@ func (provider *AlertProvider) IsValid() bool { return len(provider.WebhookURL) > 0 } -// ToCustomAlertProvider converts the provider into a custom.AlertProvider -func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider { +// Send an alert using the provider +func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { + if os.Getenv("MOCK_ALERT_PROVIDER") == "true" { + if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" { + return errors.New("error") + } + return nil + } + buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved))) + request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + if response.StatusCode > 399 { + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return fmt.Errorf("call to provider alert returned status code %d", response.StatusCode) + } + return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) + } + return err +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string { var message, color, results string if resolved { message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold) @@ -45,10 +77,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { description = ":\\n> " + alertDescription } - return &custom.AlertProvider{ - URL: provider.WebhookURL, - Method: http.MethodPost, - Body: fmt.Sprintf(`{ + return fmt.Sprintf(`{ "text": "", "attachments": [ { @@ -65,9 +94,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al ] } ] -}`, message, description, color, results), - Headers: map[string]string{"Content-Type": "application/json"}, - } +}`, message, description, color, results) } // GetDefaultAlert returns the provider's default alert configuration diff --git a/alerting/provider/slack/slack_test.go b/alerting/provider/slack/slack_test.go index 11215ce6..8108b422 100644 --- a/alerting/provider/slack/slack_test.go +++ b/alerting/provider/slack/slack_test.go @@ -2,8 +2,6 @@ package slack import ( "encoding/json" - "net/http" - "strings" "testing" "github.com/TwiN/gatus/v3/alerting/alert" @@ -21,50 +19,51 @@ func TestAlertProvider_IsValid(t *testing.T) { } } -func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { - provider := AlertProvider{WebhookURL: "http://example.com"} - alertDescription := "test" - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") +func TestAlertProvider_buildRequestBody(t *testing.T) { + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + ExpectedBody string + }{ + { + Name: "triggered", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"short\": false,\n \"color\": \"#DD0000\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}", + }, + { + Name: "resolved", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"short\": false,\n \"color\": \"#36A64F\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}", + }, } - if !strings.Contains(customAlertProvider.Body, "resolved") { - t.Error("customAlertProvider.Body should've contained the substring resolved") - } - if customAlertProvider.URL != "http://example.com" { - t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - body := make(map[string]interface{}) - err := json.Unmarshal([]byte(customAlertProvider.Body), &body) - if err != nil { - t.Error("expected body to be valid JSON, got error:", err.Error()) - } - if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["attachments"].([]interface{})[0].(map[string]interface{})["text"] { - t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"]) - } -} - -func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { - provider := AlertProvider{WebhookURL: "http://example.com"} - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") - } - if !strings.Contains(customAlertProvider.Body, "triggered") { - t.Error("customAlertProvider.Body should've contained the substring triggered") - } - if customAlertProvider.URL != "http://example.com" { - t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - body := make(map[string]interface{}) - err := json.Unmarshal([]byte(customAlertProvider.Body), &body) - if err != nil { - t.Error("expected body to be valid JSON, got error:", err.Error()) + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + body := scenario.Provider.buildRequestBody( + &core.Endpoint{Name: "endpoint-name"}, + &scenario.Alert, + &core.Result{ + ConditionResults: []*core.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + }, + }, + scenario.Resolved, + ) + if body != scenario.ExpectedBody { + t.Errorf("expected %s, got %s", scenario.ExpectedBody, body) + } + out := make(map[string]interface{}) + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Error("expected body to be valid JSON, got error:", err.Error()) + } + }) } } diff --git a/alerting/provider/teams/teams.go b/alerting/provider/teams/teams.go index 8dc845a7..1e83dcd4 100644 --- a/alerting/provider/teams/teams.go +++ b/alerting/provider/teams/teams.go @@ -1,11 +1,15 @@ package teams import ( + "bytes" + "errors" "fmt" + "io/ioutil" "net/http" + "os" "github.com/TwiN/gatus/v3/alerting/alert" - "github.com/TwiN/gatus/v3/alerting/provider/custom" + "github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v3/core" ) @@ -22,10 +26,37 @@ func (provider *AlertProvider) IsValid() bool { return len(provider.WebhookURL) > 0 } -// ToCustomAlertProvider converts the provider into a custom.AlertProvider -func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider { - var message string - var color string +// Send an alert using the provider +func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { + if os.Getenv("MOCK_ALERT_PROVIDER") == "true" { + if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" { + return errors.New("error") + } + return nil + } + buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved))) + request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + if response.StatusCode > 399 { + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return fmt.Errorf("call to provider alert returned status code %d", response.StatusCode) + } + return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) + } + return err +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string { + var message, color string if resolved { message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold) color = "#36A64F" @@ -47,10 +78,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { description = ":\\n> " + alertDescription } - return &custom.AlertProvider{ - URL: provider.WebhookURL, - Method: http.MethodPost, - Body: fmt.Sprintf(`{ + return fmt.Sprintf(`{ "@type": "MessageCard", "@context": "http://schema.org/extensions", "themeColor": "%s", @@ -66,9 +94,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al "text": "%s" } ] -}`, color, message, description, endpoint.URL, results), - Headers: map[string]string{"Content-Type": "application/json"}, - } +}`, color, message, description, endpoint.URL, results) } // GetDefaultAlert returns the provider's default alert configuration diff --git a/alerting/provider/teams/teams_test.go b/alerting/provider/teams/teams_test.go index ed8ece5d..7597ff56 100644 --- a/alerting/provider/teams/teams_test.go +++ b/alerting/provider/teams/teams_test.go @@ -2,8 +2,6 @@ package teams import ( "encoding/json" - "net/http" - "strings" "testing" "github.com/TwiN/gatus/v3/alerting/alert" @@ -21,50 +19,51 @@ func TestAlertProvider_IsValid(t *testing.T) { } } -func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { - provider := AlertProvider{WebhookURL: "http://example.org"} - alertDescription := "test" - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") +func TestAlertProvider_buildRequestBody(t *testing.T) { + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + ExpectedBody string + }{ + { + Name: "triggered", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: "{\n \"@type\": \"MessageCard\",\n \"@context\": \"http://schema.org/extensions\",\n \"themeColor\": \"#DD0000\",\n \"title\": \"🚨 Gatus\",\n \"text\": \"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"sections\": [\n {\n \"activityTitle\": \"URL\",\n \"text\": \"\"\n },\n {\n \"activityTitle\": \"Condition results\",\n \"text\": \"❌ - `[CONNECTED] == true`
❌ - `[STATUS] == 200`
\"\n }\n ]\n}", + }, + { + Name: "resolved", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: "{\n \"@type\": \"MessageCard\",\n \"@context\": \"http://schema.org/extensions\",\n \"themeColor\": \"#36A64F\",\n \"title\": \"🚨 Gatus\",\n \"text\": \"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"sections\": [\n {\n \"activityTitle\": \"URL\",\n \"text\": \"\"\n },\n {\n \"activityTitle\": \"Condition results\",\n \"text\": \"✅ - `[CONNECTED] == true`
✅ - `[STATUS] == 200`
\"\n }\n ]\n}", + }, } - if !strings.Contains(customAlertProvider.Body, "resolved") { - t.Error("customAlertProvider.Body should've contained the substring resolved") - } - if customAlertProvider.URL != "http://example.org" { - t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - body := make(map[string]interface{}) - err := json.Unmarshal([]byte(customAlertProvider.Body), &body) - if err != nil { - t.Error("expected body to be valid JSON, got error:", err.Error()) - } - if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["text"] { - t.Errorf("expected $.text to be %s, got %s", expected, body["text"]) - } -} - -func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { - provider := AlertProvider{WebhookURL: "http://example.org"} - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") - } - if !strings.Contains(customAlertProvider.Body, "triggered") { - t.Error("customAlertProvider.Body should've contained the substring triggered") - } - if customAlertProvider.URL != "http://example.org" { - t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - body := make(map[string]interface{}) - err := json.Unmarshal([]byte(customAlertProvider.Body), &body) - if err != nil { - t.Error("expected body to be valid JSON, got error:", err.Error()) + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + body := scenario.Provider.buildRequestBody( + &core.Endpoint{Name: "endpoint-name"}, + &scenario.Alert, + &core.Result{ + ConditionResults: []*core.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + }, + }, + scenario.Resolved, + ) + if body != scenario.ExpectedBody { + t.Errorf("expected %s, got %s", scenario.ExpectedBody, body) + } + out := make(map[string]interface{}) + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Error("expected body to be valid JSON, got error:", err.Error()) + } + }) } } diff --git a/alerting/provider/telegram/telegram.go b/alerting/provider/telegram/telegram.go index 60364371..dfc3bfa9 100644 --- a/alerting/provider/telegram/telegram.go +++ b/alerting/provider/telegram/telegram.go @@ -1,11 +1,15 @@ package telegram import ( + "bytes" + "errors" "fmt" + "io/ioutil" "net/http" + "os" "github.com/TwiN/gatus/v3/alerting/alert" - "github.com/TwiN/gatus/v3/alerting/provider/custom" + "github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v3/core" ) @@ -23,8 +27,36 @@ func (provider *AlertProvider) IsValid() bool { return len(provider.Token) > 0 && len(provider.ID) > 0 } -// ToCustomAlertProvider converts the provider into a custom.AlertProvider -func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider { +// Send an alert using the provider +func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { + if os.Getenv("MOCK_ALERT_PROVIDER") == "true" { + if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" { + return errors.New("error") + } + return nil + } + buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved))) + request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + if response.StatusCode > 399 { + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return fmt.Errorf("call to provider alert returned status code %d", response.StatusCode) + } + return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) + } + return err +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string { var message, results string if resolved { message = fmt.Sprintf("An alert for *%s* has been resolved:\\n—\\n _healthcheck passing successfully %d time(s) in a row_\\n— ", endpoint.Name, alert.FailureThreshold) @@ -46,12 +78,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al } else { text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Condition results*\\n%s", message, results) } - return &custom.AlertProvider{ - URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), - Method: http.MethodPost, - Body: fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text), - Headers: map[string]string{"Content-Type": "application/json"}, - } + return fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text) } // GetDefaultAlert returns the provider's default alert configuration diff --git a/alerting/provider/telegram/telegram_test.go b/alerting/provider/telegram/telegram_test.go index efb27e11..e2fe8c15 100644 --- a/alerting/provider/telegram/telegram_test.go +++ b/alerting/provider/telegram/telegram_test.go @@ -2,9 +2,6 @@ package telegram import ( "encoding/json" - "fmt" - "net/http" - "strings" "testing" "github.com/TwiN/gatus/v3/alerting/alert" @@ -22,70 +19,51 @@ func TestAlertProvider_IsValid(t *testing.T) { } } -func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { - provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"} - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") +func TestAlertProvider_buildRequestBody(t *testing.T) { + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + ExpectedBody string + }{ + { + Name: "triggered", + Provider: AlertProvider{ID: "123"}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: "{\"chat_id\": \"123\", \"text\": \"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\", \"parse_mode\": \"MARKDOWN\"}", + }, + { + Name: "resolved", + Provider: AlertProvider{ID: "123"}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: "{\"chat_id\": \"123\", \"text\": \"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 3 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\", \"parse_mode\": \"MARKDOWN\"}", + }, } - if !strings.Contains(customAlertProvider.Body, "resolved") { - t.Error("customAlertProvider.Body should've contained the substring resolved") - } - if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) { - t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - body := make(map[string]interface{}) - err := json.Unmarshal([]byte(customAlertProvider.Body), &body) - //_, err := json.Marshal(customAlertProvider.Body) - if err != nil { - t.Error("expected body to be valid JSON, got error:", err.Error()) - } -} - -func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { - provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"} - description := "Healthcheck Successful" - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{Description: &description}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") - } - if !strings.Contains(customAlertProvider.Body, "triggered") { - t.Error("customAlertProvider.Body should've contained the substring triggered") - } - if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) { - t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - body := make(map[string]interface{}) - err := json.Unmarshal([]byte(customAlertProvider.Body), &body) - if err != nil { - t.Error("expected body to be valid JSON, got error:", err.Error()) - } -} - -func TestAlertProvider_ToCustomAlertProviderWithDescription(t *testing.T) { - provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"} - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") - } - if !strings.Contains(customAlertProvider.Body, "triggered") { - t.Error("customAlertProvider.Body should've contained the substring triggered") - } - if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) { - t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - body := make(map[string]interface{}) - err := json.Unmarshal([]byte(customAlertProvider.Body), &body) - if err != nil { - t.Error("expected body to be valid JSON, got error:", err.Error()) + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + body := scenario.Provider.buildRequestBody( + &core.Endpoint{Name: "endpoint-name"}, + &scenario.Alert, + &core.Result{ + ConditionResults: []*core.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + }, + }, + scenario.Resolved, + ) + if body != scenario.ExpectedBody { + t.Errorf("expected %s, got %s", scenario.ExpectedBody, body) + } + out := make(map[string]interface{}) + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Error("expected body to be valid JSON, got error:", err.Error()) + } + }) } } diff --git a/alerting/provider/twilio/twilio.go b/alerting/provider/twilio/twilio.go index 8dfcc01a..a80c72ee 100644 --- a/alerting/provider/twilio/twilio.go +++ b/alerting/provider/twilio/twilio.go @@ -1,13 +1,17 @@ package twilio import ( + "bytes" "encoding/base64" + "errors" "fmt" + "io/ioutil" "net/http" "net/url" + "os" "github.com/TwiN/gatus/v3/alerting/alert" - "github.com/TwiN/gatus/v3/alerting/provider/custom" + "github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v3/core" ) @@ -27,27 +31,48 @@ func (provider *AlertProvider) IsValid() bool { return len(provider.Token) > 0 && len(provider.SID) > 0 && len(provider.From) > 0 && len(provider.To) > 0 } -// ToCustomAlertProvider converts the provider into a custom.AlertProvider -func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider { +// Send an alert using the provider +func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { + if os.Getenv("MOCK_ALERT_PROVIDER") == "true" { + if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" { + return errors.New("error") + } + return nil + } + buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved))) + request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(provider.SID+":"+provider.Token)))) + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + if response.StatusCode > 399 { + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return fmt.Errorf("call to provider alert returned status code %d", response.StatusCode) + } + return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) + } + return err +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string { var message string if resolved { message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()) } else { message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription()) } - return &custom.AlertProvider{ - URL: fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), - Method: http.MethodPost, - Body: url.Values{ - "To": {provider.To}, - "From": {provider.From}, - "Body": {message}, - }.Encode(), - Headers: map[string]string{ - "Content-Type": "application/x-www-form-urlencoded", - "Authorization": fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", provider.SID, provider.Token)))), - }, - } + return url.Values{ + "To": {provider.To}, + "From": {provider.From}, + "Body": {message}, + }.Encode() } // GetDefaultAlert returns the provider's default alert configuration diff --git a/alerting/provider/twilio/twilio_test.go b/alerting/provider/twilio/twilio_test.go index 8d1a4d32..02511de1 100644 --- a/alerting/provider/twilio/twilio_test.go +++ b/alerting/provider/twilio/twilio_test.go @@ -1,8 +1,6 @@ package twilio import ( - "net/http" - "strings" "testing" "github.com/TwiN/gatus/v3/alerting/alert" @@ -25,54 +23,47 @@ func TestTwilioAlertProvider_IsValid(t *testing.T) { } } -func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { - provider := AlertProvider{ - SID: "1", - Token: "2", - From: "3", - To: "4", +func TestAlertProvider_buildRequestBody(t *testing.T) { + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + ExpectedBody string + }{ + { + Name: "triggered", + Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4", + }, + { + Name: "resolved", + Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4", + }, } - description := "alert-description" - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "endpoint-name"}, &alert.Alert{Description: &description}, &core.Result{}, true) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") - } - if !strings.Contains(customAlertProvider.Body, "RESOLVED") { - t.Error("customAlertProvider.Body should've contained the substring RESOLVED") - } - if customAlertProvider.URL != "https://api.twilio.com/2010-04-01/Accounts/1/Messages.json" { - t.Errorf("expected URL to be %s, got %s", "https://api.twilio.com/2010-04-01/Accounts/1/Messages.json", customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - if customAlertProvider.Body != "Body=RESOLVED%3A+endpoint-name+-+alert-description&From=3&To=4" { - t.Errorf("expected body to be %s, got %s", "Body=RESOLVED%3A+endpoint-name+-+alert-description&From=3&To=4", customAlertProvider.Body) - } -} - -func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { - provider := AlertProvider{ - SID: "4", - Token: "3", - From: "2", - To: "1", - } - description := "alert-description" - customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "endpoint-name"}, &alert.Alert{Description: &description}, &core.Result{}, false) - if customAlertProvider == nil { - t.Fatal("customAlertProvider shouldn't have been nil") - } - if !strings.Contains(customAlertProvider.Body, "TRIGGERED") { - t.Error("customAlertProvider.Body should've contained the substring TRIGGERED") - } - if customAlertProvider.URL != "https://api.twilio.com/2010-04-01/Accounts/4/Messages.json" { - t.Errorf("expected URL to be %s, got %s", "https://api.twilio.com/2010-04-01/Accounts/4/Messages.json", customAlertProvider.URL) - } - if customAlertProvider.Method != http.MethodPost { - t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) - } - if customAlertProvider.Body != "Body=TRIGGERED%3A+endpoint-name+-+alert-description&From=2&To=1" { - t.Errorf("expected body to be %s, got %s", "Body=TRIGGERED%3A+endpoint-name+-+alert-description&From=2&To=1", customAlertProvider.Body) + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + body := scenario.Provider.buildRequestBody( + &core.Endpoint{Name: "endpoint-name"}, + &scenario.Alert, + &core.Result{ + ConditionResults: []*core.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + }, + }, + scenario.Resolved, + ) + if body != scenario.ExpectedBody { + t.Errorf("expected %s, got %s", scenario.ExpectedBody, body) + } + }) } } diff --git a/config/config.go b/config/config.go index 33b90a55..73291e53 100644 --- a/config/config.go +++ b/config/config.go @@ -274,6 +274,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E alertTypes := []alert.Type{ alert.TypeCustom, alert.TypeDiscord, + alert.TypeEmail, alert.TypeMattermost, alert.TypeMessagebird, alert.TypePagerDuty, diff --git a/config/maintenance/maintenance.go b/config/maintenance/maintenance.go index 73c27c18..fe721477 100644 --- a/config/maintenance/maintenance.go +++ b/config/maintenance/maintenance.go @@ -39,7 +39,6 @@ type Config struct { Every []string `yaml:"every"` durationToStartFromMidnight time.Duration - timeLocation *time.Location } func GetDefaultConfig() *Config { diff --git a/go.mod b/go.mod index 4b013c09..4f99c6dd 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,8 @@ require ( golang.org/x/tools v0.1.7 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/protobuf v1.27.1 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/mail.v2 v2.3.1 gopkg.in/yaml.v2 v2.4.0 lukechampine.com/uint128 v1.1.1 // indirect modernc.org/cc/v3 v3.35.8 // indirect diff --git a/go.sum b/go.sum index 36e86ed5..521dc912 100644 --- a/go.sum +++ b/go.sum @@ -523,12 +523,16 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= +gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/vendor/gopkg.in/alexcesaro/quotedprintable.v3/LICENSE b/vendor/gopkg.in/alexcesaro/quotedprintable.v3/LICENSE new file mode 100644 index 00000000..5f5c12af --- /dev/null +++ b/vendor/gopkg.in/alexcesaro/quotedprintable.v3/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Alexandre Cesaro + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/gopkg.in/alexcesaro/quotedprintable.v3/README.md b/vendor/gopkg.in/alexcesaro/quotedprintable.v3/README.md new file mode 100644 index 00000000..98ddf829 --- /dev/null +++ b/vendor/gopkg.in/alexcesaro/quotedprintable.v3/README.md @@ -0,0 +1,16 @@ +# quotedprintable + +## Introduction + +Package quotedprintable implements quoted-printable and message header encoding +as specified by RFC 2045 and RFC 2047. + +It is a copy of the Go 1.5 package `mime/quotedprintable`. It also includes +the new functions of package `mime` concerning RFC 2047. + +This code has minor changes with the standard library code in order to work +with Go 1.0 and newer. + +## Documentation + +https://godoc.org/gopkg.in/alexcesaro/quotedprintable.v3 diff --git a/vendor/gopkg.in/alexcesaro/quotedprintable.v3/encodedword.go b/vendor/gopkg.in/alexcesaro/quotedprintable.v3/encodedword.go new file mode 100644 index 00000000..cfd02617 --- /dev/null +++ b/vendor/gopkg.in/alexcesaro/quotedprintable.v3/encodedword.go @@ -0,0 +1,279 @@ +package quotedprintable + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "io" + "strings" + "unicode" + "unicode/utf8" +) + +// A WordEncoder is a RFC 2047 encoded-word encoder. +type WordEncoder byte + +const ( + // BEncoding represents Base64 encoding scheme as defined by RFC 2045. + BEncoding = WordEncoder('b') + // QEncoding represents the Q-encoding scheme as defined by RFC 2047. + QEncoding = WordEncoder('q') +) + +var ( + errInvalidWord = errors.New("mime: invalid RFC 2047 encoded-word") +) + +// Encode returns the encoded-word form of s. If s is ASCII without special +// characters, it is returned unchanged. The provided charset is the IANA +// charset name of s. It is case insensitive. +func (e WordEncoder) Encode(charset, s string) string { + if !needsEncoding(s) { + return s + } + return e.encodeWord(charset, s) +} + +func needsEncoding(s string) bool { + for _, b := range s { + if (b < ' ' || b > '~') && b != '\t' { + return true + } + } + return false +} + +// encodeWord encodes a string into an encoded-word. +func (e WordEncoder) encodeWord(charset, s string) string { + buf := getBuffer() + defer putBuffer(buf) + + buf.WriteString("=?") + buf.WriteString(charset) + buf.WriteByte('?') + buf.WriteByte(byte(e)) + buf.WriteByte('?') + + if e == BEncoding { + w := base64.NewEncoder(base64.StdEncoding, buf) + io.WriteString(w, s) + w.Close() + } else { + enc := make([]byte, 3) + for i := 0; i < len(s); i++ { + b := s[i] + switch { + case b == ' ': + buf.WriteByte('_') + case b <= '~' && b >= '!' && b != '=' && b != '?' && b != '_': + buf.WriteByte(b) + default: + enc[0] = '=' + enc[1] = upperhex[b>>4] + enc[2] = upperhex[b&0x0f] + buf.Write(enc) + } + } + } + buf.WriteString("?=") + return buf.String() +} + +const upperhex = "0123456789ABCDEF" + +// A WordDecoder decodes MIME headers containing RFC 2047 encoded-words. +type WordDecoder struct { + // CharsetReader, if non-nil, defines a function to generate + // charset-conversion readers, converting from the provided + // charset into UTF-8. + // Charsets are always lower-case. utf-8, iso-8859-1 and us-ascii charsets + // are handled by default. + // One of the the CharsetReader's result values must be non-nil. + CharsetReader func(charset string, input io.Reader) (io.Reader, error) +} + +// Decode decodes an encoded-word. If word is not a valid RFC 2047 encoded-word, +// word is returned unchanged. +func (d *WordDecoder) Decode(word string) (string, error) { + fields := strings.Split(word, "?") // TODO: remove allocation? + if len(fields) != 5 || fields[0] != "=" || fields[4] != "=" || len(fields[2]) != 1 { + return "", errInvalidWord + } + + content, err := decode(fields[2][0], fields[3]) + if err != nil { + return "", err + } + + buf := getBuffer() + defer putBuffer(buf) + + if err := d.convert(buf, fields[1], content); err != nil { + return "", err + } + + return buf.String(), nil +} + +// DecodeHeader decodes all encoded-words of the given string. It returns an +// error if and only if CharsetReader of d returns an error. +func (d *WordDecoder) DecodeHeader(header string) (string, error) { + // If there is no encoded-word, returns before creating a buffer. + i := strings.Index(header, "=?") + if i == -1 { + return header, nil + } + + buf := getBuffer() + defer putBuffer(buf) + + buf.WriteString(header[:i]) + header = header[i:] + + betweenWords := false + for { + start := strings.Index(header, "=?") + if start == -1 { + break + } + cur := start + len("=?") + + i := strings.Index(header[cur:], "?") + if i == -1 { + break + } + charset := header[cur : cur+i] + cur += i + len("?") + + if len(header) < cur+len("Q??=") { + break + } + encoding := header[cur] + cur++ + + if header[cur] != '?' { + break + } + cur++ + + j := strings.Index(header[cur:], "?=") + if j == -1 { + break + } + text := header[cur : cur+j] + end := cur + j + len("?=") + + content, err := decode(encoding, text) + if err != nil { + betweenWords = false + buf.WriteString(header[:start+2]) + header = header[start+2:] + continue + } + + // Write characters before the encoded-word. White-space and newline + // characters separating two encoded-words must be deleted. + if start > 0 && (!betweenWords || hasNonWhitespace(header[:start])) { + buf.WriteString(header[:start]) + } + + if err := d.convert(buf, charset, content); err != nil { + return "", err + } + + header = header[end:] + betweenWords = true + } + + if len(header) > 0 { + buf.WriteString(header) + } + + return buf.String(), nil +} + +func decode(encoding byte, text string) ([]byte, error) { + switch encoding { + case 'B', 'b': + return base64.StdEncoding.DecodeString(text) + case 'Q', 'q': + return qDecode(text) + } + return nil, errInvalidWord +} + +func (d *WordDecoder) convert(buf *bytes.Buffer, charset string, content []byte) error { + switch { + case strings.EqualFold("utf-8", charset): + buf.Write(content) + case strings.EqualFold("iso-8859-1", charset): + for _, c := range content { + buf.WriteRune(rune(c)) + } + case strings.EqualFold("us-ascii", charset): + for _, c := range content { + if c >= utf8.RuneSelf { + buf.WriteRune(unicode.ReplacementChar) + } else { + buf.WriteByte(c) + } + } + default: + if d.CharsetReader == nil { + return fmt.Errorf("mime: unhandled charset %q", charset) + } + r, err := d.CharsetReader(strings.ToLower(charset), bytes.NewReader(content)) + if err != nil { + return err + } + if _, err = buf.ReadFrom(r); err != nil { + return err + } + } + return nil +} + +// hasNonWhitespace reports whether s (assumed to be ASCII) contains at least +// one byte of non-whitespace. +func hasNonWhitespace(s string) bool { + for _, b := range s { + switch b { + // Encoded-words can only be separated by linear white spaces which does + // not include vertical tabs (\v). + case ' ', '\t', '\n', '\r': + default: + return true + } + } + return false +} + +// qDecode decodes a Q encoded string. +func qDecode(s string) ([]byte, error) { + dec := make([]byte, len(s)) + n := 0 + for i := 0; i < len(s); i++ { + switch c := s[i]; { + case c == '_': + dec[n] = ' ' + case c == '=': + if i+2 >= len(s) { + return nil, errInvalidWord + } + b, err := readHexByte(s[i+1], s[i+2]) + if err != nil { + return nil, err + } + dec[n] = b + i += 2 + case (c <= '~' && c >= ' ') || c == '\n' || c == '\r' || c == '\t': + dec[n] = c + default: + return nil, errInvalidWord + } + n++ + } + + return dec[:n], nil +} diff --git a/vendor/gopkg.in/alexcesaro/quotedprintable.v3/pool.go b/vendor/gopkg.in/alexcesaro/quotedprintable.v3/pool.go new file mode 100644 index 00000000..24283c52 --- /dev/null +++ b/vendor/gopkg.in/alexcesaro/quotedprintable.v3/pool.go @@ -0,0 +1,26 @@ +// +build go1.3 + +package quotedprintable + +import ( + "bytes" + "sync" +) + +var bufPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, +} + +func getBuffer() *bytes.Buffer { + return bufPool.Get().(*bytes.Buffer) +} + +func putBuffer(buf *bytes.Buffer) { + if buf.Len() > 1024 { + return + } + buf.Reset() + bufPool.Put(buf) +} diff --git a/vendor/gopkg.in/alexcesaro/quotedprintable.v3/pool_go12.go b/vendor/gopkg.in/alexcesaro/quotedprintable.v3/pool_go12.go new file mode 100644 index 00000000..d335b4ab --- /dev/null +++ b/vendor/gopkg.in/alexcesaro/quotedprintable.v3/pool_go12.go @@ -0,0 +1,24 @@ +// +build !go1.3 + +package quotedprintable + +import "bytes" + +var ch = make(chan *bytes.Buffer, 32) + +func getBuffer() *bytes.Buffer { + select { + case buf := <-ch: + return buf + default: + } + return new(bytes.Buffer) +} + +func putBuffer(buf *bytes.Buffer) { + buf.Reset() + select { + case ch <- buf: + default: + } +} diff --git a/vendor/gopkg.in/alexcesaro/quotedprintable.v3/reader.go b/vendor/gopkg.in/alexcesaro/quotedprintable.v3/reader.go new file mode 100644 index 00000000..955edca2 --- /dev/null +++ b/vendor/gopkg.in/alexcesaro/quotedprintable.v3/reader.go @@ -0,0 +1,121 @@ +// Package quotedprintable implements quoted-printable encoding as specified by +// RFC 2045. +package quotedprintable + +import ( + "bufio" + "bytes" + "fmt" + "io" +) + +// Reader is a quoted-printable decoder. +type Reader struct { + br *bufio.Reader + rerr error // last read error + line []byte // to be consumed before more of br +} + +// NewReader returns a quoted-printable reader, decoding from r. +func NewReader(r io.Reader) *Reader { + return &Reader{ + br: bufio.NewReader(r), + } +} + +func fromHex(b byte) (byte, error) { + switch { + case b >= '0' && b <= '9': + return b - '0', nil + case b >= 'A' && b <= 'F': + return b - 'A' + 10, nil + // Accept badly encoded bytes. + case b >= 'a' && b <= 'f': + return b - 'a' + 10, nil + } + return 0, fmt.Errorf("quotedprintable: invalid hex byte 0x%02x", b) +} + +func readHexByte(a, b byte) (byte, error) { + var hb, lb byte + var err error + if hb, err = fromHex(a); err != nil { + return 0, err + } + if lb, err = fromHex(b); err != nil { + return 0, err + } + return hb<<4 | lb, nil +} + +func isQPDiscardWhitespace(r rune) bool { + switch r { + case '\n', '\r', ' ', '\t': + return true + } + return false +} + +var ( + crlf = []byte("\r\n") + lf = []byte("\n") + softSuffix = []byte("=") +) + +// Read reads and decodes quoted-printable data from the underlying reader. +func (r *Reader) Read(p []byte) (n int, err error) { + // Deviations from RFC 2045: + // 1. in addition to "=\r\n", "=\n" is also treated as soft line break. + // 2. it will pass through a '\r' or '\n' not preceded by '=', consistent + // with other broken QP encoders & decoders. + for len(p) > 0 { + if len(r.line) == 0 { + if r.rerr != nil { + return n, r.rerr + } + r.line, r.rerr = r.br.ReadSlice('\n') + + // Does the line end in CRLF instead of just LF? + hasLF := bytes.HasSuffix(r.line, lf) + hasCR := bytes.HasSuffix(r.line, crlf) + wholeLine := r.line + r.line = bytes.TrimRightFunc(wholeLine, isQPDiscardWhitespace) + if bytes.HasSuffix(r.line, softSuffix) { + rightStripped := wholeLine[len(r.line):] + r.line = r.line[:len(r.line)-1] + if !bytes.HasPrefix(rightStripped, lf) && !bytes.HasPrefix(rightStripped, crlf) { + r.rerr = fmt.Errorf("quotedprintable: invalid bytes after =: %q", rightStripped) + } + } else if hasLF { + if hasCR { + r.line = append(r.line, '\r', '\n') + } else { + r.line = append(r.line, '\n') + } + } + continue + } + b := r.line[0] + + switch { + case b == '=': + if len(r.line[1:]) < 2 { + return n, io.ErrUnexpectedEOF + } + b, err = readHexByte(r.line[1], r.line[2]) + if err != nil { + return n, err + } + r.line = r.line[2:] // 2 of the 3; other 1 is done below + case b == '\t' || b == '\r' || b == '\n': + break + case b < ' ' || b > '~': + return n, fmt.Errorf("quotedprintable: invalid unescaped byte 0x%02x in body", b) + } + p[0] = b + p = p[1:] + r.line = r.line[1:] + n++ + } + return n, nil +} diff --git a/vendor/gopkg.in/alexcesaro/quotedprintable.v3/writer.go b/vendor/gopkg.in/alexcesaro/quotedprintable.v3/writer.go new file mode 100644 index 00000000..43359d51 --- /dev/null +++ b/vendor/gopkg.in/alexcesaro/quotedprintable.v3/writer.go @@ -0,0 +1,166 @@ +package quotedprintable + +import "io" + +const lineMaxLen = 76 + +// A Writer is a quoted-printable writer that implements io.WriteCloser. +type Writer struct { + // Binary mode treats the writer's input as pure binary and processes end of + // line bytes as binary data. + Binary bool + + w io.Writer + i int + line [78]byte + cr bool +} + +// NewWriter returns a new Writer that writes to w. +func NewWriter(w io.Writer) *Writer { + return &Writer{w: w} +} + +// Write encodes p using quoted-printable encoding and writes it to the +// underlying io.Writer. It limits line length to 76 characters. The encoded +// bytes are not necessarily flushed until the Writer is closed. +func (w *Writer) Write(p []byte) (n int, err error) { + for i, b := range p { + switch { + // Simple writes are done in batch. + case b >= '!' && b <= '~' && b != '=': + continue + case isWhitespace(b) || !w.Binary && (b == '\n' || b == '\r'): + continue + } + + if i > n { + if err := w.write(p[n:i]); err != nil { + return n, err + } + n = i + } + + if err := w.encode(b); err != nil { + return n, err + } + n++ + } + + if n == len(p) { + return n, nil + } + + if err := w.write(p[n:]); err != nil { + return n, err + } + + return len(p), nil +} + +// Close closes the Writer, flushing any unwritten data to the underlying +// io.Writer, but does not close the underlying io.Writer. +func (w *Writer) Close() error { + if err := w.checkLastByte(); err != nil { + return err + } + + return w.flush() +} + +// write limits text encoded in quoted-printable to 76 characters per line. +func (w *Writer) write(p []byte) error { + for _, b := range p { + if b == '\n' || b == '\r' { + // If the previous byte was \r, the CRLF has already been inserted. + if w.cr && b == '\n' { + w.cr = false + continue + } + + if b == '\r' { + w.cr = true + } + + if err := w.checkLastByte(); err != nil { + return err + } + if err := w.insertCRLF(); err != nil { + return err + } + continue + } + + if w.i == lineMaxLen-1 { + if err := w.insertSoftLineBreak(); err != nil { + return err + } + } + + w.line[w.i] = b + w.i++ + w.cr = false + } + + return nil +} + +func (w *Writer) encode(b byte) error { + if lineMaxLen-1-w.i < 3 { + if err := w.insertSoftLineBreak(); err != nil { + return err + } + } + + w.line[w.i] = '=' + w.line[w.i+1] = upperhex[b>>4] + w.line[w.i+2] = upperhex[b&0x0f] + w.i += 3 + + return nil +} + +// checkLastByte encodes the last buffered byte if it is a space or a tab. +func (w *Writer) checkLastByte() error { + if w.i == 0 { + return nil + } + + b := w.line[w.i-1] + if isWhitespace(b) { + w.i-- + if err := w.encode(b); err != nil { + return err + } + } + + return nil +} + +func (w *Writer) insertSoftLineBreak() error { + w.line[w.i] = '=' + w.i++ + + return w.insertCRLF() +} + +func (w *Writer) insertCRLF() error { + w.line[w.i] = '\r' + w.line[w.i+1] = '\n' + w.i += 2 + + return w.flush() +} + +func (w *Writer) flush() error { + if _, err := w.w.Write(w.line[:w.i]); err != nil { + return err + } + + w.i = 0 + return nil +} + +func isWhitespace(b byte) bool { + return b == ' ' || b == '\t' +} diff --git a/vendor/gopkg.in/mail.v2/.gitignore b/vendor/gopkg.in/mail.v2/.gitignore new file mode 100644 index 00000000..cc4721cf --- /dev/null +++ b/vendor/gopkg.in/mail.v2/.gitignore @@ -0,0 +1,17 @@ + + +# Binaries for programs and plugins +*.exe +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + + +# IDE's +.idea/ diff --git a/vendor/gopkg.in/mail.v2/.travis.yml b/vendor/gopkg.in/mail.v2/.travis.yml new file mode 100644 index 00000000..397d1660 --- /dev/null +++ b/vendor/gopkg.in/mail.v2/.travis.yml @@ -0,0 +1,25 @@ +language: go + +go: + - 1.2 + - 1.3 + - 1.4 + - 1.5 + - 1.6 + - 1.7 + - 1.8 + - 1.9 + - master + +# safelist +branches: + only: + - master + - v2 + +notifications: + email: false + +before_install: + - mkdir -p $GOPATH/src/gopkg.in && + ln -s ../github.com/go-mail/mail $GOPATH/src/gopkg.in/mail.v2 diff --git a/vendor/gopkg.in/mail.v2/CHANGELOG.md b/vendor/gopkg.in/mail.v2/CHANGELOG.md new file mode 100644 index 00000000..cdd898af --- /dev/null +++ b/vendor/gopkg.in/mail.v2/CHANGELOG.md @@ -0,0 +1,88 @@ +# Change Log +All notable changes to this project will be documented in this file. +This project adheres to [Semantic Versioning](http://semver.org/). + +## *Unreleased* + +## [2.3.1] - 2018-11-12 + +### Fixed + +- #39: Reverts addition of Go modules `go.mod` manifest. + +## [2.3.0] - 2018-11-10 + +### Added + +- #12: Adds `SendError` to provide additional info about the cause and index of + a failed attempt to transmit a batch of messages. +- go-gomail#78: Adds new `Message` methods for attaching and embedding + `io.Reader`s: `AttachReader` and `EmbedReader`. + +### Fixed + +- #26: Fixes RFC 1341 compliance by properly capitalizing the + `MIME-Version` header. +- #30: Fixes IO errors being silently dropped in `Message.WriteTo`. + +## [2.2.0] - 2018-03-01 + +### Added + +- #20: Adds `Message.SetBoundary` to allow specifying a custom MIME boundary. +- #22: Adds `Message.SetBodyWriter` to make it easy to use text/template and + html/template for message bodies. Contributed by Quantcast. +- #25: Adds `Dialer.StartTLSPolicy` so that `MandatoryStartTLS` can be required, + or `NoStartTLS` can disable it. Contributed by Quantcast. + +## [2.1.0] - 2017-12-14 + +### Added + +- go-gomail#40: Adds `Dialer.LocalName` field to allow specifying the hostname + sent with SMTP's HELO command. +- go-gomail#47: `Message.SetBody`, `Message.AddAlternative`, and + `Message.AddAlternativeWriter` allow specifying the encoding of message parts. +- `Dialer.Dial`'s returned `SendCloser` automatically redials after a timeout. +- go-gomail#55, go-gomail#56: Adds `Rename` to allow specifying filename + of an attachment. +- go-gomail#100: Exports `NetDialTimeout` to allow setting a custom dialer. +- go-gomail#70: Adds `Dialer.Timeout` field to allow specifying a timeout for + dials, reads, and writes. + +### Changed + +- go-gomail#52: `Dialer.Dial` automatically uses CRAM-MD5 when available. +- `Dialer.Dial` specifies a default timeout of 10 seconds. +- Gomail is forked from to + . + +### Deprecated + +- go-gomail#52: `NewPlainDialer` is deprecated in favor of `NewDialer`. + +### Fixed + +- go-gomail#41, go-gomail#42: Fixes a panic when a `Message` contains a + nil header. +- go-gomail#44: Fixes `AddAlternativeWriter` replacing the message body instead + of adding a body part. +- go-gomail#53: Folds long header lines for RFC 2047 compliance. +- go-gomail#54: Fixes `Message.FormatAddress` when name is blank. + +## [2.0.0] - 2015-09-02 + +- Mailer has been removed. It has been replaced by Dialer and Sender. +- `File` type and the `CreateFile` and `OpenFile` functions have been removed. +- `Message.Attach` and `Message.Embed` have a new signature. +- `Message.GetBodyWriter` has been removed. Use `Message.AddAlternativeWriter` +instead. +- `Message.Export` has been removed. `Message.WriteTo` can be used instead. +- `Message.DelHeader` has been removed. +- The `Bcc` header field is no longer sent. It is far more simpler and +efficient: the same message is sent to all recipients instead of sending a +different email to each Bcc address. +- LoginAuth has been removed. `NewPlainDialer` now implements the LOGIN +authentication mechanism when needed. +- Go 1.2 is now required instead of Go 1.3. No external dependency are used when +using Go 1.5. diff --git a/vendor/gopkg.in/mail.v2/CONTRIBUTING.md b/vendor/gopkg.in/mail.v2/CONTRIBUTING.md new file mode 100644 index 00000000..d5601c25 --- /dev/null +++ b/vendor/gopkg.in/mail.v2/CONTRIBUTING.md @@ -0,0 +1,20 @@ +Thank you for contributing to Gomail! Here are a few guidelines: + +## Bugs + +If you think you found a bug, create an issue and supply the minimum amount +of code triggering the bug so it can be reproduced. + + +## Fixing a bug + +If you want to fix a bug, you can send a pull request. It should contains a +new test or update an existing one to cover that bug. + + +## New feature proposal + +If you think Gomail lacks a feature, you can open an issue or send a pull +request. I want to keep Gomail code and API as simple as possible so please +describe your needs so we can discuss whether this feature should be added to +Gomail or not. diff --git a/vendor/gopkg.in/mail.v2/LICENSE b/vendor/gopkg.in/mail.v2/LICENSE new file mode 100644 index 00000000..5f5c12af --- /dev/null +++ b/vendor/gopkg.in/mail.v2/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Alexandre Cesaro + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/gopkg.in/mail.v2/README.md b/vendor/gopkg.in/mail.v2/README.md new file mode 100644 index 00000000..8cc31b6f --- /dev/null +++ b/vendor/gopkg.in/mail.v2/README.md @@ -0,0 +1,129 @@ +# Gomail +[![Build Status](https://travis-ci.org/go-mail/mail.svg?branch=master)](https://travis-ci.org/go-mail/mail) [![Code Coverage](http://gocover.io/_badge/github.com/go-mail/mail)](http://gocover.io/github.com/go-mail/mail) [![Documentation](https://godoc.org/github.com/go-mail/mail?status.svg)](https://godoc.org/github.com/go-mail/mail) + +This is an actively maintained fork of [Gomail][1] and includes fixes and +improvements for a number of outstanding issues. The current progress is +as follows: + + - [x] Timeouts and retries can be specified outside of the 10 second default. + - [x] Proxying is supported through specifying a custom [NetDialTimeout][2]. + - [ ] Filenames are properly encoded for non-ASCII characters. + - [ ] Email addresses are properly encoded for non-ASCII characters. + - [ ] Embedded files and attachments are tested for their existence. + - [ ] An `io.Reader` can be supplied when embedding and attaching files. + +See [Transitioning Existing Codebases][3] for more information on switching. + +[1]: https://github.com/go-gomail/gomail +[2]: https://godoc.org/gopkg.in/mail.v2#NetDialTimeout +[3]: #transitioning-existing-codebases + +## Introduction + +Gomail is a simple and efficient package to send emails. It is well tested and +documented. + +Gomail can only send emails using an SMTP server. But the API is flexible and it +is easy to implement other methods for sending emails using a local Postfix, an +API, etc. + +It requires Go 1.2 or newer. With Go 1.5, no external dependencies are used. + + +## Features + +Gomail supports: +- Attachments +- Embedded images +- HTML and text templates +- Automatic encoding of special characters +- SSL and TLS +- Sending multiple emails with the same SMTP connection + + +## Documentation + +https://godoc.org/github.com/go-mail/mail + + +## Download + +If you're already using a dependency manager, like [dep][dep], use the following +import path: + +``` +github.com/go-mail/mail +``` + +If you *aren't* using vendoring, `go get` the [Gopkg.in](http://gopkg.in) +import path: + +``` +gopkg.in/mail.v2 +``` + +[dep]: https://github.com/golang/dep#readme + +## Examples + +See the [examples in the documentation](https://godoc.org/github.com/go-mail/mail#example-package). + + +## FAQ + +### x509: certificate signed by unknown authority + +If you get this error it means the certificate used by the SMTP server is not +considered valid by the client running Gomail. As a quick workaround you can +bypass the verification of the server's certificate chain and host name by using +`SetTLSConfig`: + +```go +package main + +import ( + "crypto/tls" + + "gopkg.in/mail.v2" +) + +func main() { + d := mail.NewDialer("smtp.example.com", 587, "user", "123456") + d.TLSConfig = &tls.Config{InsecureSkipVerify: true} + + // Send emails using d. +} +``` + +Note, however, that this is insecure and should not be used in production. + +### Transitioning Existing Codebases + +If you're already using the original Gomail, switching is as easy as updating +the import line to: + +``` +import gomail "gopkg.in/mail.v2" +``` + +## Contribute + +Contributions are more than welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for +more info. + + +## Change log + +See [CHANGELOG.md](CHANGELOG.md). + + +## License + +[MIT](LICENSE) + + +## Support & Contact + +You can ask questions on the [Gomail +thread](https://groups.google.com/d/topic/golang-nuts/jMxZHzvvEVg/discussion) +in the Go mailing-list. diff --git a/vendor/gopkg.in/mail.v2/auth.go b/vendor/gopkg.in/mail.v2/auth.go new file mode 100644 index 00000000..b8c0dde7 --- /dev/null +++ b/vendor/gopkg.in/mail.v2/auth.go @@ -0,0 +1,49 @@ +package mail + +import ( + "bytes" + "errors" + "fmt" + "net/smtp" +) + +// loginAuth is an smtp.Auth that implements the LOGIN authentication mechanism. +type loginAuth struct { + username string + password string + host string +} + +func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + if !server.TLS { + advertised := false + for _, mechanism := range server.Auth { + if mechanism == "LOGIN" { + advertised = true + break + } + } + if !advertised { + return "", nil, errors.New("gomail: unencrypted connection") + } + } + if server.Name != a.host { + return "", nil, errors.New("gomail: wrong host name") + } + return "LOGIN", nil, nil +} + +func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if !more { + return nil, nil + } + + switch { + case bytes.Equal(fromServer, []byte("Username:")): + return []byte(a.username), nil + case bytes.Equal(fromServer, []byte("Password:")): + return []byte(a.password), nil + default: + return nil, fmt.Errorf("gomail: unexpected server challenge: %s", fromServer) + } +} diff --git a/vendor/gopkg.in/mail.v2/doc.go b/vendor/gopkg.in/mail.v2/doc.go new file mode 100644 index 00000000..d65bf359 --- /dev/null +++ b/vendor/gopkg.in/mail.v2/doc.go @@ -0,0 +1,6 @@ +// Package gomail provides a simple interface to compose emails and to mail them +// efficiently. +// +// More info on Github: https://github.com/go-mail/mail +// +package mail diff --git a/vendor/gopkg.in/mail.v2/errors.go b/vendor/gopkg.in/mail.v2/errors.go new file mode 100644 index 00000000..770da8c3 --- /dev/null +++ b/vendor/gopkg.in/mail.v2/errors.go @@ -0,0 +1,16 @@ +package mail + +import "fmt" + +// A SendError represents the failure to transmit a Message, detailing the cause +// of the failure and index of the Message within a batch. +type SendError struct { + // Index specifies the index of the Message within a batch. + Index uint + Cause error +} + +func (err *SendError) Error() string { + return fmt.Sprintf("gomail: could not send email %d: %v", + err.Index+1, err.Cause) +} diff --git a/vendor/gopkg.in/mail.v2/message.go b/vendor/gopkg.in/mail.v2/message.go new file mode 100644 index 00000000..9f7f7bde --- /dev/null +++ b/vendor/gopkg.in/mail.v2/message.go @@ -0,0 +1,359 @@ +package mail + +import ( + "bytes" + "io" + "os" + "path/filepath" + "time" +) + +// Message represents an email. +type Message struct { + header header + parts []*part + attachments []*file + embedded []*file + charset string + encoding Encoding + hEncoder mimeEncoder + buf bytes.Buffer + boundary string +} + +type header map[string][]string + +type part struct { + contentType string + copier func(io.Writer) error + encoding Encoding +} + +// NewMessage creates a new message. It uses UTF-8 and quoted-printable encoding +// by default. +func NewMessage(settings ...MessageSetting) *Message { + m := &Message{ + header: make(header), + charset: "UTF-8", + encoding: QuotedPrintable, + } + + m.applySettings(settings) + + if m.encoding == Base64 { + m.hEncoder = bEncoding + } else { + m.hEncoder = qEncoding + } + + return m +} + +// Reset resets the message so it can be reused. The message keeps its previous +// settings so it is in the same state that after a call to NewMessage. +func (m *Message) Reset() { + for k := range m.header { + delete(m.header, k) + } + m.parts = nil + m.attachments = nil + m.embedded = nil +} + +func (m *Message) applySettings(settings []MessageSetting) { + for _, s := range settings { + s(m) + } +} + +// A MessageSetting can be used as an argument in NewMessage to configure an +// email. +type MessageSetting func(m *Message) + +// SetCharset is a message setting to set the charset of the email. +func SetCharset(charset string) MessageSetting { + return func(m *Message) { + m.charset = charset + } +} + +// SetEncoding is a message setting to set the encoding of the email. +func SetEncoding(enc Encoding) MessageSetting { + return func(m *Message) { + m.encoding = enc + } +} + +// Encoding represents a MIME encoding scheme like quoted-printable or base64. +type Encoding string + +const ( + // QuotedPrintable represents the quoted-printable encoding as defined in + // RFC 2045. + QuotedPrintable Encoding = "quoted-printable" + // Base64 represents the base64 encoding as defined in RFC 2045. + Base64 Encoding = "base64" + // Unencoded can be used to avoid encoding the body of an email. The headers + // will still be encoded using quoted-printable encoding. + Unencoded Encoding = "8bit" +) + +// SetBoundary sets a custom multipart boundary. +func (m *Message) SetBoundary(boundary string) { + m.boundary = boundary +} + +// SetHeader sets a value to the given header field. +func (m *Message) SetHeader(field string, value ...string) { + m.encodeHeader(value) + m.header[field] = value +} + +func (m *Message) encodeHeader(values []string) { + for i := range values { + values[i] = m.encodeString(values[i]) + } +} + +func (m *Message) encodeString(value string) string { + return m.hEncoder.Encode(m.charset, value) +} + +// SetHeaders sets the message headers. +func (m *Message) SetHeaders(h map[string][]string) { + for k, v := range h { + m.SetHeader(k, v...) + } +} + +// SetAddressHeader sets an address to the given header field. +func (m *Message) SetAddressHeader(field, address, name string) { + m.header[field] = []string{m.FormatAddress(address, name)} +} + +// FormatAddress formats an address and a name as a valid RFC 5322 address. +func (m *Message) FormatAddress(address, name string) string { + if name == "" { + return address + } + + enc := m.encodeString(name) + if enc == name { + m.buf.WriteByte('"') + for i := 0; i < len(name); i++ { + b := name[i] + if b == '\\' || b == '"' { + m.buf.WriteByte('\\') + } + m.buf.WriteByte(b) + } + m.buf.WriteByte('"') + } else if hasSpecials(name) { + m.buf.WriteString(bEncoding.Encode(m.charset, name)) + } else { + m.buf.WriteString(enc) + } + m.buf.WriteString(" <") + m.buf.WriteString(address) + m.buf.WriteByte('>') + + addr := m.buf.String() + m.buf.Reset() + return addr +} + +func hasSpecials(text string) bool { + for i := 0; i < len(text); i++ { + switch c := text[i]; c { + case '(', ')', '<', '>', '[', ']', ':', ';', '@', '\\', ',', '.', '"': + return true + } + } + + return false +} + +// SetDateHeader sets a date to the given header field. +func (m *Message) SetDateHeader(field string, date time.Time) { + m.header[field] = []string{m.FormatDate(date)} +} + +// FormatDate formats a date as a valid RFC 5322 date. +func (m *Message) FormatDate(date time.Time) string { + return date.Format(time.RFC1123Z) +} + +// GetHeader gets a header field. +func (m *Message) GetHeader(field string) []string { + return m.header[field] +} + +// SetBody sets the body of the message. It replaces any content previously set +// by SetBody, SetBodyWriter, AddAlternative or AddAlternativeWriter. +func (m *Message) SetBody(contentType, body string, settings ...PartSetting) { + m.SetBodyWriter(contentType, newCopier(body), settings...) +} + +// SetBodyWriter sets the body of the message. It can be useful with the +// text/template or html/template packages. +func (m *Message) SetBodyWriter(contentType string, f func(io.Writer) error, settings ...PartSetting) { + m.parts = []*part{m.newPart(contentType, f, settings)} +} + +// AddAlternative adds an alternative part to the message. +// +// It is commonly used to send HTML emails that default to the plain text +// version for backward compatibility. AddAlternative appends the new part to +// the end of the message. So the plain text part should be added before the +// HTML part. See http://en.wikipedia.org/wiki/MIME#Alternative +func (m *Message) AddAlternative(contentType, body string, settings ...PartSetting) { + m.AddAlternativeWriter(contentType, newCopier(body), settings...) +} + +func newCopier(s string) func(io.Writer) error { + return func(w io.Writer) error { + _, err := io.WriteString(w, s) + return err + } +} + +// AddAlternativeWriter adds an alternative part to the message. It can be +// useful with the text/template or html/template packages. +func (m *Message) AddAlternativeWriter(contentType string, f func(io.Writer) error, settings ...PartSetting) { + m.parts = append(m.parts, m.newPart(contentType, f, settings)) +} + +func (m *Message) newPart(contentType string, f func(io.Writer) error, settings []PartSetting) *part { + p := &part{ + contentType: contentType, + copier: f, + encoding: m.encoding, + } + + for _, s := range settings { + s(p) + } + + return p +} + +// A PartSetting can be used as an argument in Message.SetBody, +// Message.SetBodyWriter, Message.AddAlternative or Message.AddAlternativeWriter +// to configure the part added to a message. +type PartSetting func(*part) + +// SetPartEncoding sets the encoding of the part added to the message. By +// default, parts use the same encoding than the message. +func SetPartEncoding(e Encoding) PartSetting { + return PartSetting(func(p *part) { + p.encoding = e + }) +} + +type file struct { + Name string + Header map[string][]string + CopyFunc func(w io.Writer) error +} + +func (f *file) setHeader(field, value string) { + f.Header[field] = []string{value} +} + +// A FileSetting can be used as an argument in Message.Attach or Message.Embed. +type FileSetting func(*file) + +// SetHeader is a file setting to set the MIME header of the message part that +// contains the file content. +// +// Mandatory headers are automatically added if they are not set when sending +// the email. +func SetHeader(h map[string][]string) FileSetting { + return func(f *file) { + for k, v := range h { + f.Header[k] = v + } + } +} + +// Rename is a file setting to set the name of the attachment if the name is +// different than the filename on disk. +func Rename(name string) FileSetting { + return func(f *file) { + f.Name = name + } +} + +// SetCopyFunc is a file setting to replace the function that runs when the +// message is sent. It should copy the content of the file to the io.Writer. +// +// The default copy function opens the file with the given filename, and copy +// its content to the io.Writer. +func SetCopyFunc(f func(io.Writer) error) FileSetting { + return func(fi *file) { + fi.CopyFunc = f + } +} + +// AttachReader attaches a file using an io.Reader +func (m *Message) AttachReader(name string, r io.Reader, settings ...FileSetting) { + m.attachments = m.appendFile(m.attachments, fileFromReader(name, r), settings) +} + +// Attach attaches the files to the email. +func (m *Message) Attach(filename string, settings ...FileSetting) { + m.attachments = m.appendFile(m.attachments, fileFromFilename(filename), settings) +} + +// EmbedReader embeds the images to the email. +func (m *Message) EmbedReader(name string, r io.Reader, settings ...FileSetting) { + m.embedded = m.appendFile(m.embedded, fileFromReader(name, r), settings) +} + +// Embed embeds the images to the email. +func (m *Message) Embed(filename string, settings ...FileSetting) { + m.embedded = m.appendFile(m.embedded, fileFromFilename(filename), settings) +} + +func fileFromFilename(name string) *file { + return &file{ + Name: filepath.Base(name), + Header: make(map[string][]string), + CopyFunc: func(w io.Writer) error { + h, err := os.Open(name) + if err != nil { + return err + } + if _, err := io.Copy(w, h); err != nil { + h.Close() + return err + } + return h.Close() + }, + } +} + +func fileFromReader(name string, r io.Reader) *file { + return &file{ + Name: filepath.Base(name), + Header: make(map[string][]string), + CopyFunc: func(w io.Writer) error { + if _, err := io.Copy(w, r); err != nil { + return err + } + return nil + }, + } +} + +func (m *Message) appendFile(list []*file, f *file, settings []FileSetting) []*file { + for _, s := range settings { + s(f) + } + + if list == nil { + return []*file{f} + } + + return append(list, f) +} diff --git a/vendor/gopkg.in/mail.v2/mime.go b/vendor/gopkg.in/mail.v2/mime.go new file mode 100644 index 00000000..d95ea2eb --- /dev/null +++ b/vendor/gopkg.in/mail.v2/mime.go @@ -0,0 +1,21 @@ +// +build go1.5 + +package mail + +import ( + "mime" + "mime/quotedprintable" + "strings" +) + +var newQPWriter = quotedprintable.NewWriter + +type mimeEncoder struct { + mime.WordEncoder +} + +var ( + bEncoding = mimeEncoder{mime.BEncoding} + qEncoding = mimeEncoder{mime.QEncoding} + lastIndexByte = strings.LastIndexByte +) diff --git a/vendor/gopkg.in/mail.v2/mime_go14.go b/vendor/gopkg.in/mail.v2/mime_go14.go new file mode 100644 index 00000000..bdb605dc --- /dev/null +++ b/vendor/gopkg.in/mail.v2/mime_go14.go @@ -0,0 +1,25 @@ +// +build !go1.5 + +package mail + +import "gopkg.in/alexcesaro/quotedprintable.v3" + +var newQPWriter = quotedprintable.NewWriter + +type mimeEncoder struct { + quotedprintable.WordEncoder +} + +var ( + bEncoding = mimeEncoder{quotedprintable.BEncoding} + qEncoding = mimeEncoder{quotedprintable.QEncoding} + lastIndexByte = func(s string, c byte) int { + for i := len(s) - 1; i >= 0; i-- { + + if s[i] == c { + return i + } + } + return -1 + } +) diff --git a/vendor/gopkg.in/mail.v2/send.go b/vendor/gopkg.in/mail.v2/send.go new file mode 100644 index 00000000..62e67f0b --- /dev/null +++ b/vendor/gopkg.in/mail.v2/send.go @@ -0,0 +1,116 @@ +package mail + +import ( + "errors" + "fmt" + "io" + stdmail "net/mail" +) + +// Sender is the interface that wraps the Send method. +// +// Send sends an email to the given addresses. +type Sender interface { + Send(from string, to []string, msg io.WriterTo) error +} + +// SendCloser is the interface that groups the Send and Close methods. +type SendCloser interface { + Sender + Close() error +} + +// A SendFunc is a function that sends emails to the given addresses. +// +// The SendFunc type is an adapter to allow the use of ordinary functions as +// email senders. If f is a function with the appropriate signature, SendFunc(f) +// is a Sender object that calls f. +type SendFunc func(from string, to []string, msg io.WriterTo) error + +// Send calls f(from, to, msg). +func (f SendFunc) Send(from string, to []string, msg io.WriterTo) error { + return f(from, to, msg) +} + +// Send sends emails using the given Sender. +func Send(s Sender, msg ...*Message) error { + for i, m := range msg { + if err := send(s, m); err != nil { + return &SendError{Cause: err, Index: uint(i)} + } + } + + return nil +} + +func send(s Sender, m *Message) error { + from, err := m.getFrom() + if err != nil { + return err + } + + to, err := m.getRecipients() + if err != nil { + return err + } + + if err := s.Send(from, to, m); err != nil { + return err + } + + return nil +} + +func (m *Message) getFrom() (string, error) { + from := m.header["Sender"] + if len(from) == 0 { + from = m.header["From"] + if len(from) == 0 { + return "", errors.New(`gomail: invalid message, "From" field is absent`) + } + } + + return parseAddress(from[0]) +} + +func (m *Message) getRecipients() ([]string, error) { + n := 0 + for _, field := range []string{"To", "Cc", "Bcc"} { + if addresses, ok := m.header[field]; ok { + n += len(addresses) + } + } + list := make([]string, 0, n) + + for _, field := range []string{"To", "Cc", "Bcc"} { + if addresses, ok := m.header[field]; ok { + for _, a := range addresses { + addr, err := parseAddress(a) + if err != nil { + return nil, err + } + list = addAddress(list, addr) + } + } + } + + return list, nil +} + +func addAddress(list []string, addr string) []string { + for _, a := range list { + if addr == a { + return list + } + } + + return append(list, addr) +} + +func parseAddress(field string) (string, error) { + addr, err := stdmail.ParseAddress(field) + if err != nil { + return "", fmt.Errorf("gomail: invalid address %q: %v", field, err) + } + return addr.Address, nil +} diff --git a/vendor/gopkg.in/mail.v2/smtp.go b/vendor/gopkg.in/mail.v2/smtp.go new file mode 100644 index 00000000..547e04d1 --- /dev/null +++ b/vendor/gopkg.in/mail.v2/smtp.go @@ -0,0 +1,292 @@ +package mail + +import ( + "crypto/tls" + "fmt" + "io" + "net" + "net/smtp" + "strings" + "time" +) + +// A Dialer is a dialer to an SMTP server. +type Dialer struct { + // Host represents the host of the SMTP server. + Host string + // Port represents the port of the SMTP server. + Port int + // Username is the username to use to authenticate to the SMTP server. + Username string + // Password is the password to use to authenticate to the SMTP server. + Password string + // Auth represents the authentication mechanism used to authenticate to the + // SMTP server. + Auth smtp.Auth + // SSL defines whether an SSL connection is used. It should be false in + // most cases since the authentication mechanism should use the STARTTLS + // extension instead. + SSL bool + // TLSConfig represents the TLS configuration used for the TLS (when the + // STARTTLS extension is used) or SSL connection. + TLSConfig *tls.Config + // StartTLSPolicy represents the TLS security level required to + // communicate with the SMTP server. + // + // This defaults to OpportunisticStartTLS for backwards compatibility, + // but we recommend MandatoryStartTLS for all modern SMTP servers. + // + // This option has no effect if SSL is set to true. + StartTLSPolicy StartTLSPolicy + // LocalName is the hostname sent to the SMTP server with the HELO command. + // By default, "localhost" is sent. + LocalName string + // Timeout to use for read/write operations. Defaults to 10 seconds, can + // be set to 0 to disable timeouts. + Timeout time.Duration + // Whether we should retry mailing if the connection returned an error, + // defaults to true. + RetryFailure bool +} + +// NewDialer returns a new SMTP Dialer. The given parameters are used to connect +// to the SMTP server. +func NewDialer(host string, port int, username, password string) *Dialer { + return &Dialer{ + Host: host, + Port: port, + Username: username, + Password: password, + SSL: port == 465, + Timeout: 10 * time.Second, + RetryFailure: true, + } +} + +// NewPlainDialer returns a new SMTP Dialer. The given parameters are used to +// connect to the SMTP server. +// +// Deprecated: Use NewDialer instead. +func NewPlainDialer(host string, port int, username, password string) *Dialer { + return NewDialer(host, port, username, password) +} + +// NetDialTimeout specifies the DialTimeout function to establish a connection +// to the SMTP server. This can be used to override dialing in the case that a +// proxy or other special behavior is needed. +var NetDialTimeout = net.DialTimeout + +// Dial dials and authenticates to an SMTP server. The returned SendCloser +// should be closed when done using it. +func (d *Dialer) Dial() (SendCloser, error) { + conn, err := NetDialTimeout("tcp", addr(d.Host, d.Port), d.Timeout) + if err != nil { + return nil, err + } + + if d.SSL { + conn = tlsClient(conn, d.tlsConfig()) + } + + c, err := smtpNewClient(conn, d.Host) + if err != nil { + return nil, err + } + + if d.Timeout > 0 { + conn.SetDeadline(time.Now().Add(d.Timeout)) + } + + if d.LocalName != "" { + if err := c.Hello(d.LocalName); err != nil { + return nil, err + } + } + + if !d.SSL && d.StartTLSPolicy != NoStartTLS { + ok, _ := c.Extension("STARTTLS") + if !ok && d.StartTLSPolicy == MandatoryStartTLS { + err := StartTLSUnsupportedError{ + Policy: d.StartTLSPolicy} + return nil, err + } + + if ok { + if err := c.StartTLS(d.tlsConfig()); err != nil { + c.Close() + return nil, err + } + } + } + + if d.Auth == nil && d.Username != "" { + if ok, auths := c.Extension("AUTH"); ok { + if strings.Contains(auths, "CRAM-MD5") { + d.Auth = smtp.CRAMMD5Auth(d.Username, d.Password) + } else if strings.Contains(auths, "LOGIN") && + !strings.Contains(auths, "PLAIN") { + d.Auth = &loginAuth{ + username: d.Username, + password: d.Password, + host: d.Host, + } + } else { + d.Auth = smtp.PlainAuth("", d.Username, d.Password, d.Host) + } + } + } + + if d.Auth != nil { + if err = c.Auth(d.Auth); err != nil { + c.Close() + return nil, err + } + } + + return &smtpSender{c, conn, d}, nil +} + +func (d *Dialer) tlsConfig() *tls.Config { + if d.TLSConfig == nil { + return &tls.Config{ServerName: d.Host} + } + return d.TLSConfig +} + +// StartTLSPolicy constants are valid values for Dialer.StartTLSPolicy. +type StartTLSPolicy int + +const ( + // OpportunisticStartTLS means that SMTP transactions are encrypted if + // STARTTLS is supported by the SMTP server. Otherwise, messages are + // sent in the clear. This is the default setting. + OpportunisticStartTLS StartTLSPolicy = iota + // MandatoryStartTLS means that SMTP transactions must be encrypted. + // SMTP transactions are aborted unless STARTTLS is supported by the + // SMTP server. + MandatoryStartTLS + // NoStartTLS means encryption is disabled and messages are sent in the + // clear. + NoStartTLS = -1 +) + +func (policy *StartTLSPolicy) String() string { + switch *policy { + case OpportunisticStartTLS: + return "OpportunisticStartTLS" + case MandatoryStartTLS: + return "MandatoryStartTLS" + case NoStartTLS: + return "NoStartTLS" + default: + return fmt.Sprintf("StartTLSPolicy:%v", *policy) + } +} + +// StartTLSUnsupportedError is returned by Dial when connecting to an SMTP +// server that does not support STARTTLS. +type StartTLSUnsupportedError struct { + Policy StartTLSPolicy +} + +func (e StartTLSUnsupportedError) Error() string { + return "gomail: " + e.Policy.String() + " required, but " + + "SMTP server does not support STARTTLS" +} + +func addr(host string, port int) string { + return fmt.Sprintf("%s:%d", host, port) +} + +// DialAndSend opens a connection to the SMTP server, sends the given emails and +// closes the connection. +func (d *Dialer) DialAndSend(m ...*Message) error { + s, err := d.Dial() + if err != nil { + return err + } + defer s.Close() + + return Send(s, m...) +} + +type smtpSender struct { + smtpClient + conn net.Conn + d *Dialer +} + +func (c *smtpSender) retryError(err error) bool { + if !c.d.RetryFailure { + return false + } + + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + return true + } + + return err == io.EOF +} + +func (c *smtpSender) Send(from string, to []string, msg io.WriterTo) error { + if c.d.Timeout > 0 { + c.conn.SetDeadline(time.Now().Add(c.d.Timeout)) + } + + if err := c.Mail(from); err != nil { + if c.retryError(err) { + // This is probably due to a timeout, so reconnect and try again. + sc, derr := c.d.Dial() + if derr == nil { + if s, ok := sc.(*smtpSender); ok { + *c = *s + return c.Send(from, to, msg) + } + } + } + + return err + } + + for _, addr := range to { + if err := c.Rcpt(addr); err != nil { + return err + } + } + + w, err := c.Data() + if err != nil { + return err + } + + if _, err = msg.WriteTo(w); err != nil { + w.Close() + return err + } + + return w.Close() +} + +func (c *smtpSender) Close() error { + return c.Quit() +} + +// Stubbed out for tests. +var ( + tlsClient = tls.Client + smtpNewClient = func(conn net.Conn, host string) (smtpClient, error) { + return smtp.NewClient(conn, host) + } +) + +type smtpClient interface { + Hello(string) error + Extension(string) (bool, string) + StartTLS(*tls.Config) error + Auth(smtp.Auth) error + Mail(string) error + Rcpt(string) error + Data() (io.WriteCloser, error) + Quit() error + Close() error +} diff --git a/vendor/gopkg.in/mail.v2/writeto.go b/vendor/gopkg.in/mail.v2/writeto.go new file mode 100644 index 00000000..faf6124d --- /dev/null +++ b/vendor/gopkg.in/mail.v2/writeto.go @@ -0,0 +1,313 @@ +package mail + +import ( + "encoding/base64" + "errors" + "io" + "mime" + "mime/multipart" + "path/filepath" + "strings" + "time" +) + +// WriteTo implements io.WriterTo. It dumps the whole message into w. +func (m *Message) WriteTo(w io.Writer) (int64, error) { + mw := &messageWriter{w: w} + mw.writeMessage(m) + return mw.n, mw.err +} + +func (w *messageWriter) writeMessage(m *Message) { + if _, ok := m.header["MIME-Version"]; !ok { + w.writeString("MIME-Version: 1.0\r\n") + } + if _, ok := m.header["Date"]; !ok { + w.writeHeader("Date", m.FormatDate(now())) + } + w.writeHeaders(m.header) + + if m.hasMixedPart() { + w.openMultipart("mixed", m.boundary) + } + + if m.hasRelatedPart() { + w.openMultipart("related", m.boundary) + } + + if m.hasAlternativePart() { + w.openMultipart("alternative", m.boundary) + } + for _, part := range m.parts { + w.writePart(part, m.charset) + } + if m.hasAlternativePart() { + w.closeMultipart() + } + + w.addFiles(m.embedded, false) + if m.hasRelatedPart() { + w.closeMultipart() + } + + w.addFiles(m.attachments, true) + if m.hasMixedPart() { + w.closeMultipart() + } +} + +func (m *Message) hasMixedPart() bool { + return (len(m.parts) > 0 && len(m.attachments) > 0) || len(m.attachments) > 1 +} + +func (m *Message) hasRelatedPart() bool { + return (len(m.parts) > 0 && len(m.embedded) > 0) || len(m.embedded) > 1 +} + +func (m *Message) hasAlternativePart() bool { + return len(m.parts) > 1 +} + +type messageWriter struct { + w io.Writer + n int64 + writers [3]*multipart.Writer + partWriter io.Writer + depth uint8 + err error +} + +func (w *messageWriter) openMultipart(mimeType, boundary string) { + mw := multipart.NewWriter(w) + if boundary != "" { + mw.SetBoundary(boundary) + } + contentType := "multipart/" + mimeType + ";\r\n boundary=" + mw.Boundary() + w.writers[w.depth] = mw + + if w.depth == 0 { + w.writeHeader("Content-Type", contentType) + w.writeString("\r\n") + } else { + w.createPart(map[string][]string{ + "Content-Type": {contentType}, + }) + } + w.depth++ +} + +func (w *messageWriter) createPart(h map[string][]string) { + w.partWriter, w.err = w.writers[w.depth-1].CreatePart(h) +} + +func (w *messageWriter) closeMultipart() { + if w.depth > 0 { + w.writers[w.depth-1].Close() + w.depth-- + } +} + +func (w *messageWriter) writePart(p *part, charset string) { + w.writeHeaders(map[string][]string{ + "Content-Type": {p.contentType + "; charset=" + charset}, + "Content-Transfer-Encoding": {string(p.encoding)}, + }) + w.writeBody(p.copier, p.encoding) +} + +func (w *messageWriter) addFiles(files []*file, isAttachment bool) { + for _, f := range files { + if _, ok := f.Header["Content-Type"]; !ok { + mediaType := mime.TypeByExtension(filepath.Ext(f.Name)) + if mediaType == "" { + mediaType = "application/octet-stream" + } + f.setHeader("Content-Type", mediaType+`; name="`+f.Name+`"`) + } + + if _, ok := f.Header["Content-Transfer-Encoding"]; !ok { + f.setHeader("Content-Transfer-Encoding", string(Base64)) + } + + if _, ok := f.Header["Content-Disposition"]; !ok { + var disp string + if isAttachment { + disp = "attachment" + } else { + disp = "inline" + } + f.setHeader("Content-Disposition", disp+`; filename="`+f.Name+`"`) + } + + if !isAttachment { + if _, ok := f.Header["Content-ID"]; !ok { + f.setHeader("Content-ID", "<"+f.Name+">") + } + } + w.writeHeaders(f.Header) + w.writeBody(f.CopyFunc, Base64) + } +} + +func (w *messageWriter) Write(p []byte) (int, error) { + if w.err != nil { + return 0, errors.New("gomail: cannot write as writer is in error") + } + + var n int + n, w.err = w.w.Write(p) + w.n += int64(n) + return n, w.err +} + +func (w *messageWriter) writeString(s string) { + if w.err != nil { // do nothing when in error + return + } + var n int + n, w.err = io.WriteString(w.w, s) + w.n += int64(n) +} + +func (w *messageWriter) writeHeader(k string, v ...string) { + w.writeString(k) + if len(v) == 0 { + w.writeString(":\r\n") + return + } + w.writeString(": ") + + // Max header line length is 78 characters in RFC 5322 and 76 characters + // in RFC 2047. So for the sake of simplicity we use the 76 characters + // limit. + charsLeft := 76 - len(k) - len(": ") + + for i, s := range v { + // If the line is already too long, insert a newline right away. + if charsLeft < 1 { + if i == 0 { + w.writeString("\r\n ") + } else { + w.writeString(",\r\n ") + } + charsLeft = 75 + } else if i != 0 { + w.writeString(", ") + charsLeft -= 2 + } + + // While the header content is too long, fold it by inserting a newline. + for len(s) > charsLeft { + s = w.writeLine(s, charsLeft) + charsLeft = 75 + } + w.writeString(s) + if i := lastIndexByte(s, '\n'); i != -1 { + charsLeft = 75 - (len(s) - i - 1) + } else { + charsLeft -= len(s) + } + } + w.writeString("\r\n") +} + +func (w *messageWriter) writeLine(s string, charsLeft int) string { + // If there is already a newline before the limit. Write the line. + if i := strings.IndexByte(s, '\n'); i != -1 && i < charsLeft { + w.writeString(s[:i+1]) + return s[i+1:] + } + + for i := charsLeft - 1; i >= 0; i-- { + if s[i] == ' ' { + w.writeString(s[:i]) + w.writeString("\r\n ") + return s[i+1:] + } + } + + // We could not insert a newline cleanly so look for a space or a newline + // even if it is after the limit. + for i := 75; i < len(s); i++ { + if s[i] == ' ' { + w.writeString(s[:i]) + w.writeString("\r\n ") + return s[i+1:] + } + if s[i] == '\n' { + w.writeString(s[:i+1]) + return s[i+1:] + } + } + + // Too bad, no space or newline in the whole string. Just write everything. + w.writeString(s) + return "" +} + +func (w *messageWriter) writeHeaders(h map[string][]string) { + if w.depth == 0 { + for k, v := range h { + if k != "Bcc" { + w.writeHeader(k, v...) + } + } + } else { + w.createPart(h) + } +} + +func (w *messageWriter) writeBody(f func(io.Writer) error, enc Encoding) { + var subWriter io.Writer + if w.depth == 0 { + w.writeString("\r\n") + subWriter = w.w + } else { + subWriter = w.partWriter + } + + if enc == Base64 { + wc := base64.NewEncoder(base64.StdEncoding, newBase64LineWriter(subWriter)) + w.err = f(wc) + wc.Close() + } else if enc == Unencoded { + w.err = f(subWriter) + } else { + wc := newQPWriter(subWriter) + w.err = f(wc) + wc.Close() + } +} + +// As required by RFC 2045, 6.7. (page 21) for quoted-printable, and +// RFC 2045, 6.8. (page 25) for base64. +const maxLineLen = 76 + +// base64LineWriter limits text encoded in base64 to 76 characters per line +type base64LineWriter struct { + w io.Writer + lineLen int +} + +func newBase64LineWriter(w io.Writer) *base64LineWriter { + return &base64LineWriter{w: w} +} + +func (w *base64LineWriter) Write(p []byte) (int, error) { + n := 0 + for len(p)+w.lineLen > maxLineLen { + w.w.Write(p[:maxLineLen-w.lineLen]) + w.w.Write([]byte("\r\n")) + p = p[maxLineLen-w.lineLen:] + n += maxLineLen - w.lineLen + w.lineLen = 0 + } + + w.w.Write(p) + w.lineLen += len(p) + + return n + len(p), nil +} + +// Stubbed out for testing. +var now = time.Now diff --git a/vendor/modules.txt b/vendor/modules.txt index bce5410a..1b9c0001 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -154,6 +154,12 @@ google.golang.org/protobuf/types/descriptorpb google.golang.org/protobuf/types/known/anypb google.golang.org/protobuf/types/known/durationpb google.golang.org/protobuf/types/known/timestamppb +# gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc +## explicit +gopkg.in/alexcesaro/quotedprintable.v3 +# gopkg.in/mail.v2 v2.3.1 +## explicit +gopkg.in/mail.v2 # gopkg.in/yaml.v2 v2.4.0 ## explicit; go 1.15 gopkg.in/yaml.v2 diff --git a/watchdog/alerting.go b/watchdog/alerting.go index 07f8d0a7..2d8757d9 100644 --- a/watchdog/alerting.go +++ b/watchdog/alerting.go @@ -1,11 +1,9 @@ package watchdog import ( - "encoding/json" "log" "github.com/TwiN/gatus/v3/alerting" - "github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v3/core" ) @@ -38,24 +36,7 @@ func handleAlertsToTrigger(endpoint *core.Endpoint, result *core.Result, alertin alertProvider := alertingConfig.GetAlertingProviderByAlertType(endpointAlert.Type) if alertProvider != nil && alertProvider.IsValid() { log.Printf("[watchdog][handleAlertsToTrigger] Sending %s alert because alert for endpoint=%s with description='%s' has been TRIGGERED", endpointAlert.Type, endpoint.Name, endpointAlert.GetDescription()) - customAlertProvider := alertProvider.ToCustomAlertProvider(endpoint, endpointAlert, result, false) - // TODO: retry on error - var err error - // We need to extract the DedupKey from PagerDuty's response - if endpointAlert.Type == alert.TypePagerDuty { - var body []byte - if body, err = customAlertProvider.Send(endpoint.Name, endpointAlert.GetDescription(), false); err == nil { - var response pagerDutyResponse - if err = json.Unmarshal(body, &response); err != nil { - log.Printf("[watchdog][handleAlertsToTrigger] Ran into error unmarshaling pagerduty response: %s", err.Error()) - } else { - endpointAlert.ResolveKey = response.DedupKey - } - } - } else { - // All other alert types don't need to extract anything from the body, so we can just send the request right away - _, err = customAlertProvider.Send(endpoint.Name, endpointAlert.GetDescription(), false) - } + err := alertProvider.Send(endpoint, endpointAlert, result, false) if err != nil { log.Printf("[watchdog][handleAlertsToTrigger] Failed to send an alert for endpoint=%s: %s", endpoint.Name, err.Error()) } else { @@ -82,15 +63,9 @@ func handleAlertsToResolve(endpoint *core.Endpoint, result *core.Result, alertin alertProvider := alertingConfig.GetAlertingProviderByAlertType(endpointAlert.Type) if alertProvider != nil && alertProvider.IsValid() { log.Printf("[watchdog][handleAlertsToResolve] Sending %s alert because alert for endpoint=%s with description='%s' has been RESOLVED", endpointAlert.Type, endpoint.Name, endpointAlert.GetDescription()) - customAlertProvider := alertProvider.ToCustomAlertProvider(endpoint, endpointAlert, result, true) - // TODO: retry on error - _, err := customAlertProvider.Send(endpoint.Name, endpointAlert.GetDescription(), true) + err := alertProvider.Send(endpoint, endpointAlert, result, true) if err != nil { log.Printf("[watchdog][handleAlertsToResolve] Failed to send an alert for endpoint=%s: %s", endpoint.Name, err.Error()) - } else { - if endpointAlert.Type == alert.TypePagerDuty { - endpointAlert.ResolveKey = "" - } } } else { log.Printf("[watchdog][handleAlertsToResolve] Not sending alert of type=%s despite being RESOLVED, because the provider wasn't configured properly", endpointAlert.Type) @@ -98,9 +73,3 @@ func handleAlertsToResolve(endpoint *core.Endpoint, result *core.Result, alertin } endpoint.NumberOfFailuresInARow = 0 } - -type pagerDutyResponse struct { - Status string `json:"status"` - Message string `json:"message"` - DedupKey string `json:"dedup_key"` -} diff --git a/watchdog/watchdog.go b/watchdog/watchdog.go index eb96b0b4..32bc2867 100644 --- a/watchdog/watchdog.go +++ b/watchdog/watchdog.go @@ -74,6 +74,7 @@ func execute(endpoint *core.Endpoint, alertingConfig *alerting.Config, maintenan result.Duration.Round(time.Millisecond), ) if !maintenanceConfig.IsUnderMaintenance() { + // TODO: Consider moving this after the monitoring lock is unlocked? I mean, how much noise can a single alerting provider cause... HandleAlerting(endpoint, result, alertingConfig, debug) } else if debug { log.Println("[watchdog][execute] Not handling alerting because currently in the maintenance window")