From 140bdd6c2074b4c54c0a592a31250724d6be76fa Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Fri, 20 Oct 2023 23:15:58 +1300 Subject: [PATCH] Feature: Set optional webhook for received messages (#195) --- README.md | 1 + cmd/root.go | 11 ++++++ config/config.go | 17 +++++++++ go.mod | 1 + go.sum | 2 + internal/storage/database.go | 2 + server/webhook/webhook.go | 72 ++++++++++++++++++++++++++++++++++++ server/websockets/hub.go | 1 + 8 files changed, 107 insertions(+) create mode 100644 server/webhook/webhook.go diff --git a/README.md b/README.md index f31efcbff..01201c127 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Mailpit was originally **inspired** by MailHog which is now [no longer maintaine - Optional HTTPS for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/HTTPS)) - Optional basic authentication for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/Basic-authentication)) - A simple REST API ([see docs](docs/apiv1/README.md)) +- Optional webhook for received messages ([see docs](https://github.com/axllent/mailpit/wiki/Webhook)) - Multi-architecture [Docker images](https://github.com/axllent/mailpit/wiki/Docker-images) diff --git a/cmd/root.go b/cmd/root.go index 1f4d623cb..d5715c1b2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,6 +13,7 @@ import ( "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/server" "github.com/axllent/mailpit/server/smtpd" + "github.com/axllent/mailpit/server/webhook" "github.com/spf13/cobra" ) @@ -105,6 +106,8 @@ func init() { rootCmd.Flags().StringVar(&config.SMTPRelayConfigFile, "smtp-relay-config", config.SMTPRelayConfigFile, "SMTP configuration file to allow releasing messages") rootCmd.Flags().BoolVar(&config.SMTPRelayAllIncoming, "smtp-relay-all", config.SMTPRelayAllIncoming, "Relay all incoming messages via external SMTP server (caution!)") + rootCmd.Flags().StringVar(&config.WebhookURL, "webhook-url", config.WebhookURL, "Send a webhook request for new messages") + rootCmd.Flags().IntVar(&webhook.RateLimit, "webhook-limit", webhook.RateLimit, "Limit webhook requests per second") rootCmd.Flags().StringVarP(&config.SMTPCLITags, "tag", "t", config.SMTPCLITags, "Tag new messages matching filters") rootCmd.Flags().BoolVarP(&logger.QuietLogging, "quiet", "q", logger.QuietLogging, "Quiet logging (errors only)") @@ -169,6 +172,14 @@ func initConfigFromEnv() { config.SMTPRelayAllIncoming = true } + // Webhook + if len(os.Getenv("MP_WEBHOOK_URL")) > 0 { + config.WebhookURL = os.Getenv("MP_WEBHOOK_URL") + } + if len(os.Getenv("MP_WEBHOOK_LIMIT")) > 0 { + webhook.RateLimit, _ = strconv.Atoi(os.Getenv("MP_WEBHOOK_LIMIT")) + } + // Misc options if len(os.Getenv("MP_WEBROOT")) > 0 { config.Webroot = os.Getenv("MP_WEBROOT") diff --git a/config/config.go b/config/config.go index 7e0738976..a908a1260 100644 --- a/config/config.go +++ b/config/config.go @@ -4,6 +4,7 @@ package config import ( "errors" "fmt" + "net/url" "os" "path" "path/filepath" @@ -94,6 +95,9 @@ var ( // Use with extreme caution! SMTPRelayAllIncoming = false + // WebhookURL for calling + WebhookURL string + // ContentSecurityPolicy for HTTP server - set via VerifyConfig() ContentSecurityPolicy string @@ -223,6 +227,10 @@ func VerifyConfig() error { s := strings.TrimRight(path.Join("/", Webroot, "/"), "/") + "/" Webroot = s + if WebhookURL != "" && !isValidURL(WebhookURL) { + return fmt.Errorf("Webhook URL does not appear to be a valid URL (%s)", WebhookURL) + } + SMTPTags = []AutoTag{} if SMTPCLITags != "" { @@ -349,3 +357,12 @@ func isDir(path string) bool { return true } + +func isValidURL(s string) bool { + u, err := url.ParseRequestURI(s) + if err != nil { + return false + } + + return strings.HasPrefix(u.Scheme, "http") +} diff --git a/go.mod b/go.mod index a678cb787..af6cf09b3 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/vanng822/go-premailer v1.20.2 golang.org/x/net v0.17.0 golang.org/x/text v0.13.0 + golang.org/x/time v0.3.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.26.0 ) diff --git a/go.sum b/go.sum index 29659da3d..c7a8ea1a7 100644 --- a/go.sum +++ b/go.sum @@ -192,6 +192,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/storage/database.go b/internal/storage/database.go index 9e8e0f7f2..bcfb9f672 100644 --- a/internal/storage/database.go +++ b/internal/storage/database.go @@ -21,6 +21,7 @@ import ( "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/tools" + "github.com/axllent/mailpit/server/webhook" "github.com/axllent/mailpit/server/websockets" "github.com/google/uuid" "github.com/jhillyerd/enmime" @@ -234,6 +235,7 @@ func Store(body []byte) (string, error) { c.Snippet = snippet websockets.Broadcast("new", c) + webhook.Send(c) dbLastAction = time.Now() diff --git a/server/webhook/webhook.go b/server/webhook/webhook.go new file mode 100644 index 000000000..b0ad4fa4a --- /dev/null +++ b/server/webhook/webhook.go @@ -0,0 +1,72 @@ +// Package webhook will optionally call a preconfigured endpoint +package webhook + +import ( + "bytes" + "encoding/json" + "net/http" + "time" + + "github.com/axllent/mailpit/config" + "github.com/axllent/mailpit/internal/logger" + "golang.org/x/time/rate" +) + +var ( + // RateLimit is the minimum number of seconds between requests + RateLimit = 1 + + rl rate.Sometimes + + rateLimiterSet bool +) + +// Send will post the MessageSummary to a webhook (if configured) +func Send(msg interface{}) { + if config.WebhookURL == "" { + return + } + + if !rateLimiterSet { + if RateLimit > 0 { + rl = rate.Sometimes{Interval: time.Duration(RateLimit) * time.Second} + } else { + // run 1000 per second - ie: do not limit + rl = rate.Sometimes{First: 1000, Interval: time.Second} + } + rateLimiterSet = true + } + + go func() { + rl.Do(func() { + b, err := json.Marshal(msg) + if err != nil { + logger.Log().Errorf("[webhook] invalid data: %s", err) + return + } + + req, err := http.NewRequest("POST", config.WebhookURL, bytes.NewBuffer(b)) + if err != nil { + logger.Log().Errorf("[webhook] error: %s", err) + return + } + + req.Header.Set("User-Agent", "Mailpit/"+config.Version) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + logger.Log().Errorf("[webhook] error sending data: %s", err) + return + } + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + logger.Log().Warningf("[webhook] %s returned a %d status", config.WebhookURL, resp.StatusCode) + return + } + + defer resp.Body.Close() + }) + }() +} diff --git a/server/websockets/hub.go b/server/websockets/hub.go index 4e492e18a..218a03ebf 100644 --- a/server/websockets/hub.go +++ b/server/websockets/hub.go @@ -80,6 +80,7 @@ func Broadcast(t string, msg interface{}) { if err != nil { logger.Log().Errorf("[websocket] broadcast received invalid data: %s", err) + return } go func() { MessageHub.Broadcast <- b }()