From f553cdb282081b6cb173394a6a40796a59e89183 Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Fri, 24 Dec 2021 15:01:29 +0100
Subject: [PATCH] Continued e-mail support

---
 client/options.go     |  5 ++++
 cmd/app_test.go       | 11 ++++++++
 cmd/publish.go        |  6 ++++
 cmd/publish_test.go   | 18 ++++++++++++
 docs/publish.md       | 66 ++++++++++++++++++++++++++++++++++++++++++-
 server/mailer.go      | 14 +++++++--
 server/server.go      |  4 +--
 server/server_test.go |  4 +--
 server/visitor.go     |  8 +++++-
 test/server.go        |  6 +++-
 10 files changed, 132 insertions(+), 10 deletions(-)

diff --git a/client/options.go b/client/options.go
index dd180f7..298a533 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 52eafa7..c02ef4f 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 3dd15dd..5817ccc 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 dc2545c..80d84f8 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 bf033db..e634d7a 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 1d3af23..716e0d2 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 ee95e5e..0c0f0f0 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 9ee8f22..c290cca 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 269b316..0dfa6ce 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 5c5e4b3..a0f65a1 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 {
-- 
GitLab