Fix #117: Implement email alerts

This commit is contained in:
TwiN 2021-12-02 21:05:17 -05:00
parent 0331c18401
commit f6336eac4e
53 changed files with 3062 additions and 626 deletions

View File

@ -40,6 +40,7 @@ Have any feedback or want to share your good/bad experience with Gatus? Feel fre
- [Client configuration](#client-configuration) - [Client configuration](#client-configuration)
- [Alerting](#alerting) - [Alerting](#alerting)
- [Configuring Discord alerts](#configuring-discord-alerts) - [Configuring Discord alerts](#configuring-discord-alerts)
- [Configuring Email alerts](#configuring-email-alerts)
- [Configuring Mattermost alerts](#configuring-mattermost-alerts) - [Configuring Mattermost alerts](#configuring-mattermost-alerts)
- [Configuring Messagebird alerts](#configuring-messagebird-alerts) - [Configuring Messagebird alerts](#configuring-messagebird-alerts)
- [Configuring PagerDuty alerts](#configuring-pagerduty-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. <br />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 #### Configuring Mattermost alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:----------------------------------- |:------------------------------------------------------------------------------------------- |:-------------- | |:----------------------------------- |:------------------------------------------------------------------------------------------- |:-------------- |

View File

@ -11,6 +11,9 @@ const (
// TypeDiscord is the Type for the discord alerting provider // TypeDiscord is the Type for the discord alerting provider
TypeDiscord Type = "discord" 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 is the Type for the mattermost alerting provider
TypeMattermost Type = "mattermost" TypeMattermost Type = "mattermost"

View File

@ -5,6 +5,7 @@ import (
"github.com/TwiN/gatus/v3/alerting/provider" "github.com/TwiN/gatus/v3/alerting/provider"
"github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord" "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/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird" "github.com/TwiN/gatus/v3/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty" "github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
@ -17,31 +18,34 @@ import (
// Config is the configuration for alerting providers // Config is the configuration for alerting providers
type Config struct { type Config struct {
// Custom is the configuration for the custom alerting provider // 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 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 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 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 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 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 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 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 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 // 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 nil
} }
return config.Discord 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: case alert.TypeMattermost:
if config.Mattermost == nil { if config.Mattermost == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil // Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil

View File

@ -38,11 +38,6 @@ func (provider *AlertProvider) IsValid() bool {
return len(provider.URL) > 0 && provider.ClientConfig != nil 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 // GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string { func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string {
status := "TRIGGERED" status := "TRIGGERED"
@ -105,27 +100,26 @@ func (provider *AlertProvider) buildHTTPRequest(endpointName, alertDescription s
return request return request
} }
// Send a request to the alert provider and return the body func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
func (provider *AlertProvider) Send(endpointName, alertDescription string, resolved bool) ([]byte, error) {
if os.Getenv("MOCK_ALERT_PROVIDER") == "true" { if os.Getenv("MOCK_ALERT_PROVIDER") == "true" {
if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "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) response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil { if err != nil {
return nil, err return err
} }
if response.StatusCode > 399 { if response.StatusCode > 399 {
body, err := ioutil.ReadAll(response.Body) body, err := ioutil.ReadAll(response.Body)
if err != nil { 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 // GetDefaultAlert returns the provider's default alert configuration

View File

@ -3,9 +3,6 @@ package custom
import ( import (
"io/ioutil" "io/ioutil"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/core"
) )
func TestAlertProvider_IsValid(t *testing.T) { 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) { func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
const ( const (
ExpectedURL = "https://example.com/endpoint-name?event=test&description=alert-description" ExpectedURL = "https://example.com/endpoint-name?event=test&description=alert-description"

View File

@ -1,11 +1,15 @@
package discord package discord
import ( import (
"bytes"
"errors"
"fmt" "fmt"
"io/ioutil"
"net/http" "net/http"
"os"
"github.com/TwiN/gatus/v3/alerting/alert" "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" "github.com/TwiN/gatus/v3/core"
) )
@ -22,8 +26,36 @@ func (provider *AlertProvider) IsValid() bool {
return len(provider.WebhookURL) > 0 return len(provider.WebhookURL) > 0
} }
// ToCustomAlertProvider converts the provider into a custom.AlertProvider // Send an alert using the provider
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider { 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 message, results string
var colorCode int var colorCode int
if resolved { if resolved {
@ -46,10 +78,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription description = ":\\n> " + alertDescription
} }
return &custom.AlertProvider{ return fmt.Sprintf(`{
URL: provider.WebhookURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"content": "", "content": "",
"embeds": [ "embeds": [
{ {
@ -65,9 +94,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
] ]
} }
] ]
}`, message, description, colorCode, results), }`, message, description, colorCode, results)
Headers: map[string]string{"Content-Type": "application/json"},
}
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration

View File

@ -2,8 +2,6 @@ package discord
import ( import (
"encoding/json" "encoding/json"
"net/http"
"strings"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v3/alerting/alert"
@ -21,50 +19,51 @@ func TestAlertProvider_IsValid(t *testing.T) {
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { func TestAlertProvider_buildRequestBody(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.com"} firstDescription := "description-1"
alertDescription := "test" secondDescription := "description-2"
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true) scenarios := []struct {
if customAlertProvider == nil { Name string
t.Fatal("customAlertProvider shouldn't have been nil") 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") { for _, scenario := range scenarios {
t.Error("customAlertProvider.Body should've contained the substring resolved") t.Run(scenario.Name, func(t *testing.T) {
} body := scenario.Provider.buildRequestBody(
if customAlertProvider.URL != "http://example.com" { &core.Endpoint{Name: "endpoint-name"},
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL) &scenario.Alert,
} &core.Result{
if customAlertProvider.Method != http.MethodPost { ConditionResults: []*core.ConditionResult{
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
} {Condition: "[STATUS] == 200", Success: scenario.Resolved},
body := make(map[string]interface{}) },
err := json.Unmarshal([]byte(customAlertProvider.Body), &body) },
if err != nil { scenario.Resolved,
t.Error("expected body to be valid JSON, got error:", err.Error()) )
} if body != scenario.ExpectedBody {
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 %s, got %s", scenario.ExpectedBody, body)
t.Errorf("expected $.embeds[0].description to be %s, got %s", expected, body["embeds"].([]interface{})[0].(map[string]interface{})["description"]) }
} 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())
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())
} }
} }

View File

@ -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
}

View File

@ -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)
}
})
}
}

View File

@ -1,11 +1,14 @@
package mattermost package mattermost
import ( import (
"bytes"
"errors"
"fmt" "fmt"
"io/ioutil"
"net/http" "net/http"
"os"
"github.com/TwiN/gatus/v3/alerting/alert" "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/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v3/core"
) )
@ -29,10 +32,37 @@ func (provider *AlertProvider) IsValid() bool {
return len(provider.WebhookURL) > 0 return len(provider.WebhookURL) > 0
} }
// ToCustomAlertProvider converts the provider into a custom.AlertProvider // Send an alert using the provider
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider { func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
var message string if os.Getenv("MOCK_ALERT_PROVIDER") == "true" {
var color string 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 { 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) 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" color = "#36A64F"
@ -54,11 +84,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription description = ":\\n> " + alertDescription
} }
return &custom.AlertProvider{ return fmt.Sprintf(`{
URL: provider.WebhookURL,
Method: http.MethodPost,
ClientConfig: provider.ClientConfig,
Body: fmt.Sprintf(`{
"text": "", "text": "",
"username": "gatus", "username": "gatus",
"icon_url": "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png", "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), }`, message, message, description, color, endpoint.URL, results)
Headers: map[string]string{"Content-Type": "application/json"},
}
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration

View File

@ -2,8 +2,6 @@ package mattermost
import ( import (
"encoding/json" "encoding/json"
"net/http"
"strings"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v3/alerting/alert"
@ -21,50 +19,51 @@ func TestAlertProvider_IsValid(t *testing.T) {
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { func TestAlertProvider_buildRequestBody(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.org"} firstDescription := "description-1"
alertDescription := "test" secondDescription := "description-2"
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true) scenarios := []struct {
if customAlertProvider == nil { Name string
t.Fatal("customAlertProvider shouldn't have been nil") 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") { for _, scenario := range scenarios {
t.Error("customAlertProvider.Body should've contained the substring resolved") t.Run(scenario.Name, func(t *testing.T) {
} body := scenario.Provider.buildRequestBody(
if customAlertProvider.URL != "http://example.org" { &core.Endpoint{Name: "endpoint-name"},
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL) &scenario.Alert,
} &core.Result{
if customAlertProvider.Method != http.MethodPost { ConditionResults: []*core.ConditionResult{
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
} {Condition: "[STATUS] == 200", Success: scenario.Resolved},
body := make(map[string]interface{}) },
err := json.Unmarshal([]byte(customAlertProvider.Body), &body) },
if err != nil { scenario.Resolved,
t.Error("expected body to be valid JSON, got error:", err.Error()) )
} if body != scenario.ExpectedBody {
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 %s, got %s", scenario.ExpectedBody, body)
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"]) }
} 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())
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())
} }
} }

View File

@ -1,11 +1,15 @@
package messagebird package messagebird
import ( import (
"bytes"
"errors"
"fmt" "fmt"
"io/ioutil"
"net/http" "net/http"
"os"
"github.com/TwiN/gatus/v3/alerting/alert" "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" "github.com/TwiN/gatus/v3/core"
) )
@ -20,7 +24,7 @@ type AlertProvider struct {
Recipients string `yaml:"recipients"` Recipients string `yaml:"recipients"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // 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 // 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 return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0
} }
// ToCustomAlertProvider converts the provider into a custom.AlertProvider // Send an alert using the provider
// Reference doc for messagebird https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms // 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 { 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 var message string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription())
} }
return &custom.AlertProvider{ return fmt.Sprintf(`{
URL: restAPIURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"originator": "%s", "originator": "%s",
"recipients": "%s", "recipients": "%s",
"body": "%s" "body": "%s"
}`, provider.Originator, provider.Recipients, message), }`, provider.Originator, provider.Recipients, message)
Headers: map[string]string{
"Content-Type": "application/json",
"Authorization": fmt.Sprintf("AccessKey %s", provider.AccessKey),
},
}
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration

View File

@ -2,8 +2,6 @@ package messagebird
import ( import (
"encoding/json" "encoding/json"
"net/http"
"strings"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v3/alerting/alert"
@ -25,54 +23,51 @@ func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { func TestAlertProvider_buildRequestBody(t *testing.T) {
provider := AlertProvider{ firstDescription := "description-1"
AccessKey: "1", secondDescription := "description-2"
Originator: "1", scenarios := []struct {
Recipients: "1", 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) for _, scenario := range scenarios {
if customAlertProvider == nil { t.Run(scenario.Name, func(t *testing.T) {
t.Fatal("customAlertProvider shouldn't have been nil") body := scenario.Provider.buildRequestBody(
} &core.Endpoint{Name: "endpoint-name"},
if !strings.Contains(customAlertProvider.Body, "RESOLVED") { &scenario.Alert,
t.Error("customAlertProvider.Body should've contained the substring RESOLVED") &core.Result{
} ConditionResults: []*core.ConditionResult{
if customAlertProvider.URL != "https://rest.messagebird.com/messages" { {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
t.Errorf("expected URL to be %s, got %s", "https://rest.messagebird.com/messages", customAlertProvider.URL) {Condition: "[STATUS] == 200", Success: scenario.Resolved},
} },
if customAlertProvider.Method != http.MethodPost { },
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) scenario.Resolved,
} )
body := make(map[string]interface{}) if body != scenario.ExpectedBody {
err := json.Unmarshal([]byte(customAlertProvider.Body), &body) t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
if err != nil { }
t.Error("expected body to be valid JSON, got error:", err.Error()) 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())
}
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())
} }
} }

View File

@ -1,11 +1,17 @@
package pagerduty package pagerduty
import ( import (
"bytes"
"encoding/json"
"errors"
"fmt" "fmt"
"io/ioutil"
"log"
"net/http" "net/http"
"os"
"github.com/TwiN/gatus/v3/alerting/alert" "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" "github.com/TwiN/gatus/v3/core"
) )
@ -18,10 +24,10 @@ type AlertProvider struct {
IntegrationKey string `yaml:"integration-key"` IntegrationKey string `yaml:"integration-key"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // 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 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 // 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 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/ // 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 { 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 var message, eventAction, resolveKey string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
@ -59,10 +110,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
eventAction = "trigger" eventAction = "trigger"
resolveKey = "" resolveKey = ""
} }
return &custom.AlertProvider{ return fmt.Sprintf(`{
URL: restAPIURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"routing_key": "%s", "routing_key": "%s",
"dedup_key": "%s", "dedup_key": "%s",
"event_action": "%s", "event_action": "%s",
@ -71,11 +119,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
"source": "%s", "source": "%s",
"severity": "critical" "severity": "critical"
} }
}`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name), }`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name)
Headers: map[string]string{
"Content-Type": "application/json",
},
}
} }
// getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group // 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 { func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
type pagerDutyResponsePayload struct {
Status string `json:"status"`
Message string `json:"message"`
DedupKey string `json:"dedup_key"`
}

View File

@ -2,8 +2,6 @@ package pagerduty
import ( import (
"encoding/json" "encoding/json"
"net/http"
"strings"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v3/alerting/alert"
@ -57,107 +55,41 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { func TestAlertProvider_buildRequestBody(t *testing.T) {
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"} description := "test"
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, true) scenarios := []struct {
if customAlertProvider == nil { Name string
t.Fatal("customAlertProvider shouldn't have been nil") Provider AlertProvider
} Alert alert.Alert
if !strings.Contains(customAlertProvider.Body, "RESOLVED") { Resolved bool
t.Error("customAlertProvider.Body should've contained the substring RESOLVED") ExpectedBody string
} }{
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) Name: "triggered",
} Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
if customAlertProvider.Method != http.MethodPost { Alert: alert.Alert{Description: &description},
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) 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}",
body := make(map[string]interface{}) },
err := json.Unmarshal([]byte(customAlertProvider.Body), &body) {
if err != nil { Name: "resolved",
t.Error("expected body to be valid JSON, got error:", err.Error()) 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}",
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlertAndOverride(t *testing.T) {
provider := AlertProvider{
IntegrationKey: "",
Overrides: []Override{
{
IntegrationKey: "00000000000000000000000000000000",
Group: "group",
},
}, },
} }
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, true) for _, scenario := range scenarios {
if customAlertProvider == nil { t.Run(scenario.Name, func(t *testing.T) {
t.Fatal("customAlertProvider shouldn't have been nil") body := scenario.Provider.buildRequestBody(&core.Endpoint{}, &scenario.Alert, &core.Result{}, scenario.Resolved)
} if body != scenario.ExpectedBody {
if !strings.Contains(customAlertProvider.Body, "RESOLVED") { t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
t.Error("customAlertProvider.Body should've contained the substring RESOLVED") }
} out := make(map[string]interface{})
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" { if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL) t.Error("expected body to be valid JSON, got error:", err.Error())
} }
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())
} }
} }

View File

@ -4,6 +4,7 @@ import (
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord" "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/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird" "github.com/TwiN/gatus/v3/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty" "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 returns whether the provider's configuration is valid
IsValid() bool 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 returns the provider's default alert configuration
GetDefaultAlert() *alert.Alert 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 // 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 // Validate interface implementation on compile
_ AlertProvider = (*custom.AlertProvider)(nil) _ AlertProvider = (*custom.AlertProvider)(nil)
_ AlertProvider = (*discord.AlertProvider)(nil) _ AlertProvider = (*discord.AlertProvider)(nil)
_ AlertProvider = (*email.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil) _ AlertProvider = (*mattermost.AlertProvider)(nil)
_ AlertProvider = (*messagebird.AlertProvider)(nil) _ AlertProvider = (*messagebird.AlertProvider)(nil)
_ AlertProvider = (*pagerduty.AlertProvider)(nil) _ AlertProvider = (*pagerduty.AlertProvider)(nil)

View File

@ -1,11 +1,15 @@
package slack package slack
import ( import (
"bytes"
"errors"
"fmt" "fmt"
"io/ioutil"
"net/http" "net/http"
"os"
"github.com/TwiN/gatus/v3/alerting/alert" "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" "github.com/TwiN/gatus/v3/core"
) )
@ -22,8 +26,36 @@ func (provider *AlertProvider) IsValid() bool {
return len(provider.WebhookURL) > 0 return len(provider.WebhookURL) > 0
} }
// ToCustomAlertProvider converts the provider into a custom.AlertProvider // Send an alert using the provider
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider { 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 var message, color, results string
if resolved { 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) 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 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription description = ":\\n> " + alertDescription
} }
return &custom.AlertProvider{ return fmt.Sprintf(`{
URL: provider.WebhookURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"text": "", "text": "",
"attachments": [ "attachments": [
{ {
@ -65,9 +94,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
] ]
} }
] ]
}`, message, description, color, results), }`, message, description, color, results)
Headers: map[string]string{"Content-Type": "application/json"},
}
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration

View File

@ -2,8 +2,6 @@ package slack
import ( import (
"encoding/json" "encoding/json"
"net/http"
"strings"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v3/alerting/alert"
@ -21,50 +19,51 @@ func TestAlertProvider_IsValid(t *testing.T) {
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { func TestAlertProvider_buildRequestBody(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.com"} firstDescription := "description-1"
alertDescription := "test" secondDescription := "description-2"
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true) scenarios := []struct {
if customAlertProvider == nil { Name string
t.Fatal("customAlertProvider shouldn't have been nil") 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") { for _, scenario := range scenarios {
t.Error("customAlertProvider.Body should've contained the substring resolved") t.Run(scenario.Name, func(t *testing.T) {
} body := scenario.Provider.buildRequestBody(
if customAlertProvider.URL != "http://example.com" { &core.Endpoint{Name: "endpoint-name"},
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL) &scenario.Alert,
} &core.Result{
if customAlertProvider.Method != http.MethodPost { ConditionResults: []*core.ConditionResult{
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
} {Condition: "[STATUS] == 200", Success: scenario.Resolved},
body := make(map[string]interface{}) },
err := json.Unmarshal([]byte(customAlertProvider.Body), &body) },
if err != nil { scenario.Resolved,
t.Error("expected body to be valid JSON, got error:", err.Error()) )
} if body != scenario.ExpectedBody {
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 %s, got %s", scenario.ExpectedBody, body)
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"]) }
} 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())
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())
} }
} }

View File

@ -1,11 +1,15 @@
package teams package teams
import ( import (
"bytes"
"errors"
"fmt" "fmt"
"io/ioutil"
"net/http" "net/http"
"os"
"github.com/TwiN/gatus/v3/alerting/alert" "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" "github.com/TwiN/gatus/v3/core"
) )
@ -22,10 +26,37 @@ func (provider *AlertProvider) IsValid() bool {
return len(provider.WebhookURL) > 0 return len(provider.WebhookURL) > 0
} }
// ToCustomAlertProvider converts the provider into a custom.AlertProvider // Send an alert using the provider
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider { func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
var message string if os.Getenv("MOCK_ALERT_PROVIDER") == "true" {
var color string 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 { 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) 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" color = "#36A64F"
@ -47,10 +78,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription description = ":\\n> " + alertDescription
} }
return &custom.AlertProvider{ return fmt.Sprintf(`{
URL: provider.WebhookURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"@type": "MessageCard", "@type": "MessageCard",
"@context": "http://schema.org/extensions", "@context": "http://schema.org/extensions",
"themeColor": "%s", "themeColor": "%s",
@ -66,9 +94,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
"text": "%s" "text": "%s"
} }
] ]
}`, color, message, description, endpoint.URL, results), }`, color, message, description, endpoint.URL, results)
Headers: map[string]string{"Content-Type": "application/json"},
}
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration

View File

@ -2,8 +2,6 @@ package teams
import ( import (
"encoding/json" "encoding/json"
"net/http"
"strings"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v3/alerting/alert"
@ -21,50 +19,51 @@ func TestAlertProvider_IsValid(t *testing.T) {
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { func TestAlertProvider_buildRequestBody(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.org"} firstDescription := "description-1"
alertDescription := "test" secondDescription := "description-2"
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true) scenarios := []struct {
if customAlertProvider == nil { Name string
t.Fatal("customAlertProvider shouldn't have been nil") 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\": \"&#x1F6A8; 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\": \"&#x274C; - `[CONNECTED] == true`<br/>&#x274C; - `[STATUS] == 200`<br/>\"\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\": \"&#x1F6A8; 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\": \"&#x2705; - `[CONNECTED] == true`<br/>&#x2705; - `[STATUS] == 200`<br/>\"\n }\n ]\n}",
},
} }
if !strings.Contains(customAlertProvider.Body, "resolved") { for _, scenario := range scenarios {
t.Error("customAlertProvider.Body should've contained the substring resolved") t.Run(scenario.Name, func(t *testing.T) {
} body := scenario.Provider.buildRequestBody(
if customAlertProvider.URL != "http://example.org" { &core.Endpoint{Name: "endpoint-name"},
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL) &scenario.Alert,
} &core.Result{
if customAlertProvider.Method != http.MethodPost { ConditionResults: []*core.ConditionResult{
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
} {Condition: "[STATUS] == 200", Success: scenario.Resolved},
body := make(map[string]interface{}) },
err := json.Unmarshal([]byte(customAlertProvider.Body), &body) },
if err != nil { scenario.Resolved,
t.Error("expected body to be valid JSON, got error:", err.Error()) )
} if body != scenario.ExpectedBody {
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 %s, got %s", scenario.ExpectedBody, body)
t.Errorf("expected $.text to be %s, got %s", expected, body["text"]) }
} 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())
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())
} }
} }

View File

@ -1,11 +1,15 @@
package telegram package telegram
import ( import (
"bytes"
"errors"
"fmt" "fmt"
"io/ioutil"
"net/http" "net/http"
"os"
"github.com/TwiN/gatus/v3/alerting/alert" "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" "github.com/TwiN/gatus/v3/core"
) )
@ -23,8 +27,36 @@ func (provider *AlertProvider) IsValid() bool {
return len(provider.Token) > 0 && len(provider.ID) > 0 return len(provider.Token) > 0 && len(provider.ID) > 0
} }
// ToCustomAlertProvider converts the provider into a custom.AlertProvider // Send an alert using the provider
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider { 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 var message, results string
if resolved { 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) 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 { } else {
text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Condition results*\\n%s", message, results) text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Condition results*\\n%s", message, results)
} }
return &custom.AlertProvider{ return fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text)
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"},
}
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration

View File

@ -2,9 +2,6 @@ package telegram
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http"
"strings"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v3/alerting/alert"
@ -22,70 +19,51 @@ func TestAlertProvider_IsValid(t *testing.T) {
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { func TestAlertProvider_buildRequestBody(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"} firstDescription := "description-1"
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true) secondDescription := "description-2"
if customAlertProvider == nil { scenarios := []struct {
t.Fatal("customAlertProvider shouldn't have been nil") 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") { for _, scenario := range scenarios {
t.Error("customAlertProvider.Body should've contained the substring resolved") t.Run(scenario.Name, func(t *testing.T) {
} body := scenario.Provider.buildRequestBody(
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) { &core.Endpoint{Name: "endpoint-name"},
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL) &scenario.Alert,
} &core.Result{
if customAlertProvider.Method != http.MethodPost { ConditionResults: []*core.ConditionResult{
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
} {Condition: "[STATUS] == 200", Success: scenario.Resolved},
body := make(map[string]interface{}) },
err := json.Unmarshal([]byte(customAlertProvider.Body), &body) },
//_, err := json.Marshal(customAlertProvider.Body) scenario.Resolved,
if err != nil { )
t.Error("expected body to be valid JSON, got error:", err.Error()) if body != scenario.ExpectedBody {
} t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
} }
out := make(map[string]interface{})
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { if err := json.Unmarshal([]byte(body), &out); err != nil {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"} t.Error("expected body to be valid JSON, got error:", err.Error())
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())
} }
} }

View File

@ -1,13 +1,17 @@
package twilio package twilio
import ( import (
"bytes"
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"io/ioutil"
"net/http" "net/http"
"net/url" "net/url"
"os"
"github.com/TwiN/gatus/v3/alerting/alert" "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" "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 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 // Send an alert using the provider
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider { 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 var message string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription())
} }
return &custom.AlertProvider{ return url.Values{
URL: fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), "To": {provider.To},
Method: http.MethodPost, "From": {provider.From},
Body: url.Values{ "Body": {message},
"To": {provider.To}, }.Encode()
"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)))),
},
}
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration

View File

@ -1,8 +1,6 @@
package twilio package twilio
import ( import (
"net/http"
"strings"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v3/alerting/alert"
@ -25,54 +23,47 @@ func TestTwilioAlertProvider_IsValid(t *testing.T) {
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { func TestAlertProvider_buildRequestBody(t *testing.T) {
provider := AlertProvider{ firstDescription := "description-1"
SID: "1", secondDescription := "description-2"
Token: "2", scenarios := []struct {
From: "3", Name string
To: "4", 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" for _, scenario := range scenarios {
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "endpoint-name"}, &alert.Alert{Description: &description}, &core.Result{}, true) t.Run(scenario.Name, func(t *testing.T) {
if customAlertProvider == nil { body := scenario.Provider.buildRequestBody(
t.Fatal("customAlertProvider shouldn't have been nil") &core.Endpoint{Name: "endpoint-name"},
} &scenario.Alert,
if !strings.Contains(customAlertProvider.Body, "RESOLVED") { &core.Result{
t.Error("customAlertProvider.Body should've contained the substring RESOLVED") ConditionResults: []*core.ConditionResult{
} {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
if customAlertProvider.URL != "https://api.twilio.com/2010-04-01/Accounts/1/Messages.json" { {Condition: "[STATUS] == 200", Success: scenario.Resolved},
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 { scenario.Resolved,
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) )
} if body != scenario.ExpectedBody {
if customAlertProvider.Body != "Body=RESOLVED%3A+endpoint-name+-+alert-description&From=3&To=4" { t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
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)
} }
} }

View File

@ -274,6 +274,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
alertTypes := []alert.Type{ alertTypes := []alert.Type{
alert.TypeCustom, alert.TypeCustom,
alert.TypeDiscord, alert.TypeDiscord,
alert.TypeEmail,
alert.TypeMattermost, alert.TypeMattermost,
alert.TypeMessagebird, alert.TypeMessagebird,
alert.TypePagerDuty, alert.TypePagerDuty,

View File

@ -39,7 +39,6 @@ type Config struct {
Every []string `yaml:"every"` Every []string `yaml:"every"`
durationToStartFromMidnight time.Duration durationToStartFromMidnight time.Duration
timeLocation *time.Location
} }
func GetDefaultConfig() *Config { func GetDefaultConfig() *Config {

2
go.mod
View File

@ -32,6 +32,8 @@ require (
golang.org/x/tools v0.1.7 // indirect golang.org/x/tools v0.1.7 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/protobuf v1.27.1 // 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 gopkg.in/yaml.v2 v2.4.0
lukechampine.com/uint128 v1.1.1 // indirect lukechampine.com/uint128 v1.1.1 // indirect
modernc.org/cc/v3 v3.35.8 // indirect modernc.org/cc/v3 v3.35.8 // indirect

4
go.sum
View File

@ -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 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 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/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 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-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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 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/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.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

20
vendor/gopkg.in/alexcesaro/quotedprintable.v3/LICENSE generated vendored Normal file
View File

@ -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.

View File

@ -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

View File

@ -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
}

26
vendor/gopkg.in/alexcesaro/quotedprintable.v3/pool.go generated vendored Normal file
View File

@ -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)
}

View File

@ -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:
}
}

121
vendor/gopkg.in/alexcesaro/quotedprintable.v3/reader.go generated vendored Normal file
View File

@ -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
}

166
vendor/gopkg.in/alexcesaro/quotedprintable.v3/writer.go generated vendored Normal file
View File

@ -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'
}

17
vendor/gopkg.in/mail.v2/.gitignore generated vendored Normal file
View File

@ -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/

25
vendor/gopkg.in/mail.v2/.travis.yml generated vendored Normal file
View File

@ -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

88
vendor/gopkg.in/mail.v2/CHANGELOG.md generated vendored Normal file
View File

@ -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 <https://github.com/go-gomail/gomail/> to
<https://github.com/go-mail/mail/>.
### 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.

20
vendor/gopkg.in/mail.v2/CONTRIBUTING.md generated vendored Normal file
View File

@ -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.

20
vendor/gopkg.in/mail.v2/LICENSE generated vendored Normal file
View File

@ -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.

129
vendor/gopkg.in/mail.v2/README.md generated vendored Normal file
View File

@ -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.

49
vendor/gopkg.in/mail.v2/auth.go generated vendored Normal file
View File

@ -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)
}
}

6
vendor/gopkg.in/mail.v2/doc.go generated vendored Normal file
View File

@ -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

16
vendor/gopkg.in/mail.v2/errors.go generated vendored Normal file
View File

@ -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)
}

359
vendor/gopkg.in/mail.v2/message.go generated vendored Normal file
View File

@ -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)
}

21
vendor/gopkg.in/mail.v2/mime.go generated vendored Normal file
View File

@ -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
)

25
vendor/gopkg.in/mail.v2/mime_go14.go generated vendored Normal file
View File

@ -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
}
)

116
vendor/gopkg.in/mail.v2/send.go generated vendored Normal file
View File

@ -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
}

292
vendor/gopkg.in/mail.v2/smtp.go generated vendored Normal file
View File

@ -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
}

313
vendor/gopkg.in/mail.v2/writeto.go generated vendored Normal file
View File

@ -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

6
vendor/modules.txt vendored
View File

@ -154,6 +154,12 @@ google.golang.org/protobuf/types/descriptorpb
google.golang.org/protobuf/types/known/anypb google.golang.org/protobuf/types/known/anypb
google.golang.org/protobuf/types/known/durationpb google.golang.org/protobuf/types/known/durationpb
google.golang.org/protobuf/types/known/timestamppb 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 # gopkg.in/yaml.v2 v2.4.0
## explicit; go 1.15 ## explicit; go 1.15
gopkg.in/yaml.v2 gopkg.in/yaml.v2

View File

@ -1,11 +1,9 @@
package watchdog package watchdog
import ( import (
"encoding/json"
"log" "log"
"github.com/TwiN/gatus/v3/alerting" "github.com/TwiN/gatus/v3/alerting"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v3/core"
) )
@ -38,24 +36,7 @@ func handleAlertsToTrigger(endpoint *core.Endpoint, result *core.Result, alertin
alertProvider := alertingConfig.GetAlertingProviderByAlertType(endpointAlert.Type) alertProvider := alertingConfig.GetAlertingProviderByAlertType(endpointAlert.Type)
if alertProvider != nil && alertProvider.IsValid() { 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()) 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) err := alertProvider.Send(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)
}
if err != nil { if err != nil {
log.Printf("[watchdog][handleAlertsToTrigger] Failed to send an alert for endpoint=%s: %s", endpoint.Name, err.Error()) log.Printf("[watchdog][handleAlertsToTrigger] Failed to send an alert for endpoint=%s: %s", endpoint.Name, err.Error())
} else { } else {
@ -82,15 +63,9 @@ func handleAlertsToResolve(endpoint *core.Endpoint, result *core.Result, alertin
alertProvider := alertingConfig.GetAlertingProviderByAlertType(endpointAlert.Type) alertProvider := alertingConfig.GetAlertingProviderByAlertType(endpointAlert.Type)
if alertProvider != nil && alertProvider.IsValid() { 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()) 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) err := alertProvider.Send(endpoint, endpointAlert, result, true)
// TODO: retry on error
_, err := customAlertProvider.Send(endpoint.Name, endpointAlert.GetDescription(), true)
if err != nil { if err != nil {
log.Printf("[watchdog][handleAlertsToResolve] Failed to send an alert for endpoint=%s: %s", endpoint.Name, err.Error()) 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 { } else {
log.Printf("[watchdog][handleAlertsToResolve] Not sending alert of type=%s despite being RESOLVED, because the provider wasn't configured properly", endpointAlert.Type) 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 endpoint.NumberOfFailuresInARow = 0
} }
type pagerDutyResponse struct {
Status string `json:"status"`
Message string `json:"message"`
DedupKey string `json:"dedup_key"`
}

View File

@ -74,6 +74,7 @@ func execute(endpoint *core.Endpoint, alertingConfig *alerting.Config, maintenan
result.Duration.Round(time.Millisecond), result.Duration.Round(time.Millisecond),
) )
if !maintenanceConfig.IsUnderMaintenance() { 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) HandleAlerting(endpoint, result, alertingConfig, debug)
} else if debug { } else if debug {
log.Println("[watchdog][execute] Not handling alerting because currently in the maintenance window") log.Println("[watchdog][execute] Not handling alerting because currently in the maintenance window")