diff --git a/client/options.go b/client/options.go index dd180f79..298a5332 100644 --- a/client/options.go +++ b/client/options.go @@ -45,6 +45,11 @@ func WithDelay(delay string) PublishOption { return WithHeader("X-Delay", delay) } +// WithEmail instructs the server to also send the message to the given e-mail address +func WithEmail(email string) PublishOption { + return WithHeader("X-Email", email) +} + // WithNoCache instructs the server not to cache the message server-side func WithNoCache() PublishOption { return WithHeader("X-Cache", "no") diff --git a/cmd/app_test.go b/cmd/app_test.go index 52eafa77..c02ef4f2 100644 --- a/cmd/app_test.go +++ b/cmd/app_test.go @@ -2,10 +2,13 @@ package cmd import ( "bytes" + "encoding/json" "github.com/urfave/cli/v2" + "heckel.io/ntfy/client" "io" "log" "os" + "strings" "testing" ) @@ -24,3 +27,11 @@ func newTestApp() (*cli.App, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) { app.ErrWriter = &stderr return app, &stdin, &stdout, &stderr } + +func toMessage(t *testing.T, s string) *client.Message { + var m *client.Message + if err := json.NewDecoder(strings.NewReader(s)).Decode(&m); err != nil { + t.Fatal(err) + } + return m +} diff --git a/cmd/publish.go b/cmd/publish.go index 3dd15dda..5817ccc2 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -20,6 +20,7 @@ var cmdPublish = &cli.Command{ &cli.StringFlag{Name: "priority", Aliases: []string{"p"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"}, &cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, Usage: "comma separated list of tags and emojis"}, &cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, Usage: "delay/schedule message"}, + &cli.StringFlag{Name: "email", Aliases: []string{"e-mail", "mail", "e"}, Usage: "also send to e-mail address"}, &cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, Usage: "do not cache message server-side"}, &cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, Usage: "do not forward message to Firebase"}, &cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, Usage: "do print message"}, @@ -33,6 +34,7 @@ Examples: ntfy pub --tags=warning,skull backups "Backups failed" # Add tags/emojis to message ntfy pub --delay=10s delayed_topic Laterzz # Delay message by 10s ntfy pub --at=8:30am delayed_topic Laterzz # Send message at 8:30am + ntfy pub -e phil@example.com alerts 'App is down!' # Also send email to phil@example.com ntfy trigger mywebhook # Sending without message, useful for webhooks Please also check out the docs on publishing messages. Especially for the --tags and --delay options, @@ -54,6 +56,7 @@ func execPublish(c *cli.Context) error { priority := c.String("priority") tags := c.String("tags") delay := c.String("delay") + email := c.String("email") noCache := c.Bool("no-cache") noFirebase := c.Bool("no-firebase") quiet := c.Bool("quiet") @@ -75,6 +78,9 @@ func execPublish(c *cli.Context) error { if delay != "" { options = append(options, client.WithDelay(delay)) } + if email != "" { + options = append(options, client.WithEmail(email)) + } if noCache { options = append(options, client.WithNoCache()) } diff --git a/cmd/publish_test.go b/cmd/publish_test.go index dc2545ce..80d84f8c 100644 --- a/cmd/publish_test.go +++ b/cmd/publish_test.go @@ -1,7 +1,9 @@ package cmd import ( + "fmt" "github.com/stretchr/testify/require" + "heckel.io/ntfy/test" "heckel.io/ntfy/util" "testing" ) @@ -16,3 +18,19 @@ func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) { require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"})) require.Contains(t, stdout.String(), testMessage) } + +func TestCLI_Publish_Subscribe_Poll(t *testing.T) { + s, port := test.StartServer(t) + defer test.StopServer(t, s, port) + topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{"ntfy", "publish", topic, "some message"})) + m := toMessage(t, stdout.String()) + require.Equal(t, "some message", m.Message) + + app2, _, stdout, _ := newTestApp() + require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", topic})) + m = toMessage(t, stdout.String()) + require.Equal(t, "some message", m.Message) +} diff --git a/docs/publish.md b/docs/publish.md index bf033dba..e634d7ab 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -592,6 +592,69 @@ Here's an example with a custom message, tags and a priority: file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull'); ``` +## Publish as e-mail +You can forward messages to e-mail by specifying an e-mail address in the header. This can be useful for messages that +you'd like to persist longer, or to blast-notify yourself on all possible channels. Since ntfy does not provide auth, +the [rate limiting](#limitations) is pretty strict (see below). + +=== "Command line (curl)" + ``` + curl -H "Email: phil@example.com" -d "You've Got Mail" ntfy.sh/alerts + curl -d "You've Got Mail" "ntfy.sh/alerts?email=phil@example.com" + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --email=phil@example.com \ + alerts "You've Got Mail" + ``` + +=== "HTTP" + ``` http + POST /alerts HTTP/1.1 + Host: ntfy.sh + Email: phil@example.com + + You've Got Mail + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/alerts', { + method: 'POST', + body: "You've Got Mail", + headers: { 'Email': 'phil@example.com' } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts", strings.NewReader("You've Got Mail")) + req.Header.Set("Email", "phil@example.com") + http.DefaultClient.Do(req) + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/alerts", + data="You've Got Mail", + headers={ "Email": "phil@example.com" }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + "Email: phil@example.com", + 'content' => 'You've Got Mail' + ] + ])); + ``` + ## Advanced features ### Message caching @@ -746,7 +809,8 @@ but just in case, let's list them all: | Limit | Description | |---|---| | **Message length** | Each message can be up to 512 bytes long. Longer messages are truncated. | -| **Requests per second** | By default, the server is configured to allow 60 requests at once, and then refills the your allowed requests bucket at a rate of one request per 10 seconds. You can read more about this in the [rate limiting](config.md#rate-limiting) section. | +| **Requests** | By default, the server is configured to allow 60 requests at once, and then refills the your allowed requests bucket at a rate of one request per 10 seconds. You can read more about this in the [rate limiting](config.md#rate-limiting) section. | +| **E-mails** | By default, the server is configured to allow 16 e-mails at once, and then refills the your allowed e-mail bucket at a rate of one per hour. You can read more about this in the [rate limiting](config.md#rate-limiting) section. | | **Subscription limits** | By default, the server allows each visitor to keep 30 connections to the server open. | | **Total number of topics** | By default, the server is configured to allow 5,000 topics. The ntfy.sh server has higher limits though. | diff --git a/server/mailer.go b/server/mailer.go index 1d3af232..716e0d2d 100644 --- a/server/mailer.go +++ b/server/mailer.go @@ -8,14 +8,14 @@ import ( ) type mailer interface { - Send(to string, m *message) error + Send(from, to string, m *message) error } type smtpMailer struct { config *Config } -func (s *smtpMailer) Send(to string, m *message) error { +func (s *smtpMailer) Send(from, to string, m *message) error { host, _, err := net.SplitHostPort(s.config.SMTPAddr) if err != nil { return err @@ -26,10 +26,18 @@ func (s *smtpMailer) Send(to string, m *message) error { } subject += " - " + m.Topic subject = strings.ReplaceAll(strings.ReplaceAll(subject, "\r", ""), "\n", " ") + message := m.Message + if len(m.Tags) > 0 { + message += "\nTags: " + strings.Join(m.Tags, ", ") // FIXME emojis + } + if m.Priority != 0 && m.Priority != 3 { + message += fmt.Sprintf("\nPriority: %d", m.Priority) // FIXME to string + } + message += fmt.Sprintf("\n\n--\nMessage was sent via %s by client %s", m.Topic, from) // FIXME short URL msg := []byte(fmt.Sprintf("From: %s\r\n"+ "To: %s\r\n"+ "Subject: %s\r\n\r\n"+ - "%s\r\n", s.config.SMTPFrom, to, subject, m.Message)) + "%s\r\n", s.config.SMTPFrom, to, subject, message)) auth := smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host) return smtp.SendMail(s.config.SMTPAddr, auth, s.config.SMTPFrom, []string{to}, msg) } diff --git a/server/server.go b/server/server.go index ee95e5e3..0c0f0f0e 100644 --- a/server/server.go +++ b/server/server.go @@ -344,7 +344,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito } if s.mailer != nil && email != "" && !delayed { go func() { - if err := s.mailer.Send(email, m); err != nil { + if err := s.mailer.Send(v.ip, email, m); err != nil { log.Printf("Unable to send email: %v", err.Error()) } }() @@ -772,7 +772,7 @@ func (s *Server) visitor(r *http.Request) *visitor { } v, exists := s.visitors[ip] if !exists { - s.visitors[ip] = newVisitor(s.config) + s.visitors[ip] = newVisitor(s.config, ip) return s.visitors[ip] } v.Keepalive() diff --git a/server/server_test.go b/server/server_test.go index 9ee8f22f..c290cca9 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -511,10 +511,10 @@ func TestServer_Curl_Publish_Poll(t *testing.T) { type testMailer struct { count int - mu sync.Mutex + mu sync.Mutex } -func (t *testMailer) Send(to string, m *message) error { +func (t *testMailer) Send(from, to string, m *message) error { t.mu.Lock() defer t.mu.Unlock() t.count++ diff --git a/server/visitor.go b/server/visitor.go index 269b3162..0dfa6cef 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -17,6 +17,7 @@ const ( // visitor represents an API user, and its associated rate.Limiter used for rate limiting type visitor struct { config *Config + ip string requests *rate.Limiter emails *rate.Limiter subscriptions *util.Limiter @@ -24,9 +25,10 @@ type visitor struct { mu sync.Mutex } -func newVisitor(conf *Config) *visitor { +func newVisitor(conf *Config, ip string) *visitor { return &visitor{ config: conf, + ip: ip, requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst), emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst), subscriptions: util.NewLimiter(int64(conf.VisitorSubscriptionLimit)), @@ -34,6 +36,10 @@ func newVisitor(conf *Config) *visitor { } } +func (v *visitor) IP() string { + return v.ip +} + func (v *visitor) RequestAllowed() error { if !v.requests.Allow() { return errHTTPTooManyRequests diff --git a/test/server.go b/test/server.go index 5c5e4b31..a0f65a11 100644 --- a/test/server.go +++ b/test/server.go @@ -15,8 +15,12 @@ func init() { // StartServer starts a server.Server with a random port and waits for the server to be up func StartServer(t *testing.T) (*server.Server, int) { + return StartServerWithConfig(t, server.NewConfig()) +} + +// StartServerWithConfig starts a server.Server with a random port and waits for the server to be up +func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) { port := 10000 + rand.Intn(20000) - conf := server.NewConfig() conf.ListenHTTP = fmt.Sprintf(":%d", port) s, err := server.New(conf) if err != nil {