Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
521aad7db5 | ||
|
|
fe2988bb38 | ||
|
|
65a53c1100 | ||
|
|
a53f18ca7d | ||
|
|
595ea87465 | ||
|
|
7b37141e07 | ||
|
|
1fd327325f | ||
|
|
96ad49f675 | ||
|
|
35b2ca51d8 | ||
|
|
76a28b4e8b | ||
|
|
9752bd7c30 | ||
|
|
46c0039a16 | ||
|
|
d5497908bb | ||
|
|
dac88391c1 | ||
|
|
a46a520bca | ||
|
|
04719f8dee | ||
|
|
113053a9e3 | ||
|
|
7cfe909644 | ||
|
|
01a1d981cf | ||
|
|
e7f8fc93e4 | ||
|
|
b45ca6f2c0 | ||
|
|
be17294dc2 | ||
|
|
7eaa92cb20 | ||
|
|
3001e57bcc | ||
|
|
43a2acb756 | ||
|
|
bcc424f2aa | ||
|
|
ec7e58a6a2 | ||
|
|
9a0f1f22b8 | ||
|
|
d6762276f5 | ||
|
|
41514cd557 | ||
|
|
63a29380a9 | ||
|
|
eeb378cfdc | ||
|
|
7a23779d07 | ||
|
|
29628a66a6 | ||
|
|
020c058805 | ||
|
|
8a625ef786 | ||
|
|
3bc8ff0104 | ||
|
|
11b5ac49c0 | ||
|
|
f553cdb282 | ||
|
|
6b46eb46e2 | ||
|
|
7280ae1ebc | ||
|
|
873c57b3d8 |
3
Makefile
3
Makefile
@@ -105,7 +105,8 @@ build-snapshot: build-deps
|
|||||||
goreleaser build --snapshot --rm-dist --debug
|
goreleaser build --snapshot --rm-dist --debug
|
||||||
|
|
||||||
build-simple: clean
|
build-simple: clean
|
||||||
mkdir -p dist/ntfy_linux_amd64
|
mkdir -p dist/ntfy_linux_amd64 server/docs
|
||||||
|
touch server/docs/dummy
|
||||||
export CGO_ENABLED=1
|
export CGO_ENABLED=1
|
||||||
go build \
|
go build \
|
||||||
-o dist/ntfy_linux_amd64/ntfy \
|
-o dist/ntfy_linux_amd64/ntfy \
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ Third party libraries and resources:
|
|||||||
* [Mixkit sound](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) used as notification sound
|
* [Mixkit sound](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) used as notification sound
|
||||||
* [Lato Font](https://www.latofonts.com/) (OFL) is used as a font in the Web UI
|
* [Lato Font](https://www.latofonts.com/) (OFL) is used as a font in the Web UI
|
||||||
* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases
|
* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases
|
||||||
|
* [go-smtp](https://github.com/emersion/go-smtp) (MIT) is used to receive e-mails
|
||||||
|
* [stretchr/testify](https://github.com/stretchr/testify) (MIT) is used for unit and integration tests
|
||||||
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
|
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
|
||||||
* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages
|
* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages
|
||||||
* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file)
|
* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file)
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ func TestClient_Publish_Subscribe(t *testing.T) {
|
|||||||
require.Equal(t, "some delayed message", msg.Message)
|
require.Equal(t, "some delayed message", msg.Message)
|
||||||
require.True(t, time.Now().Add(24*time.Hour).Unix() < msg.Time)
|
require.True(t, time.Now().Add(24*time.Hour).Unix() < msg.Time)
|
||||||
|
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
msg = nextMessage(c)
|
msg = nextMessage(c)
|
||||||
require.NotNil(t, msg)
|
require.NotNil(t, msg)
|
||||||
require.Equal(t, "some message", msg.Message)
|
require.Equal(t, "some message", msg.Message)
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ func WithDelay(delay string) PublishOption {
|
|||||||
return WithHeader("X-Delay", delay)
|
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
|
// WithNoCache instructs the server not to cache the message server-side
|
||||||
func WithNoCache() PublishOption {
|
func WithNoCache() PublishOption {
|
||||||
return WithHeader("X-Cache", "no")
|
return WithHeader("X-Cache", "no")
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"github.com/urfave/cli/v2/altsrc"
|
"github.com/urfave/cli/v2/altsrc"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -60,7 +59,3 @@ func initConfigFileInputSource(configFlag string, flags []cli.Flag) cli.BeforeFu
|
|||||||
return altsrc.ApplyInputSourceValues(context, inputSource, flags)
|
return altsrc.ApplyInputSourceValues(context, inputSource, flags)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func collapseTopicURL(s string) string {
|
|
||||||
return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
|
"heckel.io/ntfy/client"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,3 +27,11 @@ func newTestApp() (*cli.App, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) {
|
|||||||
app.ErrWriter = &stderr
|
app.ErrWriter = &stderr
|
||||||
return app, &stdin, &stdout, &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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: "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: "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: "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-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: "no-firebase", Aliases: []string{"F"}, Usage: "do not forward message to Firebase"},
|
||||||
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, Usage: "do print message"},
|
&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 --tags=warning,skull backups "Backups failed" # Add tags/emojis to message
|
||||||
ntfy pub --delay=10s delayed_topic Laterzz # Delay message by 10s
|
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 --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
|
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,
|
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")
|
priority := c.String("priority")
|
||||||
tags := c.String("tags")
|
tags := c.String("tags")
|
||||||
delay := c.String("delay")
|
delay := c.String("delay")
|
||||||
|
email := c.String("email")
|
||||||
noCache := c.Bool("no-cache")
|
noCache := c.Bool("no-cache")
|
||||||
noFirebase := c.Bool("no-firebase")
|
noFirebase := c.Bool("no-firebase")
|
||||||
quiet := c.Bool("quiet")
|
quiet := c.Bool("quiet")
|
||||||
@@ -75,6 +78,9 @@ func execPublish(c *cli.Context) error {
|
|||||||
if delay != "" {
|
if delay != "" {
|
||||||
options = append(options, client.WithDelay(delay))
|
options = append(options, client.WithDelay(delay))
|
||||||
}
|
}
|
||||||
|
if email != "" {
|
||||||
|
options = append(options, client.WithEmail(email))
|
||||||
|
}
|
||||||
if noCache {
|
if noCache {
|
||||||
options = append(options, client.WithNoCache())
|
options = append(options, client.WithNoCache())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/test"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"testing"
|
"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.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}))
|
||||||
require.Contains(t, stdout.String(), testMessage)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
41
cmd/serve.go
41
cmd/serve.go
@@ -1,4 +1,3 @@
|
|||||||
// Package cmd provides the ntfy CLI application
|
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,6 +12,7 @@ import (
|
|||||||
|
|
||||||
var flagsServe = []cli.Flag{
|
var flagsServe = []cli.Flag{
|
||||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
|
||||||
@@ -22,10 +22,19 @@ var flagsServe = []cli.Flag{
|
|||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultGlobalTopicLimit, Usage: "total number of topics allowed"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultGlobalTopicLimit, Usage: "total number of topics allowed"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"V"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"B"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"R"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||||
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
||||||
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +61,7 @@ func execServe(c *cli.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read all the options
|
// Read all the options
|
||||||
|
baseURL := c.String("base-url")
|
||||||
listenHTTP := c.String("listen-http")
|
listenHTTP := c.String("listen-http")
|
||||||
listenHTTPS := c.String("listen-https")
|
listenHTTPS := c.String("listen-https")
|
||||||
keyFile := c.String("key-file")
|
keyFile := c.String("key-file")
|
||||||
@@ -61,10 +71,19 @@ func execServe(c *cli.Context) error {
|
|||||||
cacheDuration := c.Duration("cache-duration")
|
cacheDuration := c.Duration("cache-duration")
|
||||||
keepaliveInterval := c.Duration("keepalive-interval")
|
keepaliveInterval := c.Duration("keepalive-interval")
|
||||||
managerInterval := c.Duration("manager-interval")
|
managerInterval := c.Duration("manager-interval")
|
||||||
|
smtpSenderAddr := c.String("smtp-sender-addr")
|
||||||
|
smtpSenderUser := c.String("smtp-sender-user")
|
||||||
|
smtpSenderPass := c.String("smtp-sender-pass")
|
||||||
|
smtpSenderFrom := c.String("smtp-sender-from")
|
||||||
|
smtpServerListen := c.String("smtp-server-listen")
|
||||||
|
smtpServerDomain := c.String("smtp-server-domain")
|
||||||
|
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
|
||||||
globalTopicLimit := c.Int("global-topic-limit")
|
globalTopicLimit := c.Int("global-topic-limit")
|
||||||
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
||||||
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
||||||
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
|
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
|
||||||
|
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
|
||||||
|
visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
|
||||||
behindProxy := c.Bool("behind-proxy")
|
behindProxy := c.Bool("behind-proxy")
|
||||||
|
|
||||||
// Check values
|
// Check values
|
||||||
@@ -82,10 +101,15 @@ func execServe(c *cli.Context) error {
|
|||||||
return errors.New("if set, certificate file must exist")
|
return errors.New("if set, certificate file must exist")
|
||||||
} else if listenHTTPS != "" && (keyFile == "" || certFile == "") {
|
} else if listenHTTPS != "" && (keyFile == "" || certFile == "") {
|
||||||
return errors.New("if listen-https is set, both key-file and cert-file must be set")
|
return errors.New("if listen-https is set, both key-file and cert-file must be set")
|
||||||
|
} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderUser == "" || smtpSenderPass == "" || smtpSenderFrom == "") {
|
||||||
|
return errors.New("if smtp-sender-addr is set, base-url, smtp-sender-user, smtp-sender-pass and smtp-sender-from must also be set")
|
||||||
|
} else if smtpServerListen != "" && smtpServerDomain == "" {
|
||||||
|
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run server
|
// Run server
|
||||||
conf := server.NewConfig()
|
conf := server.NewConfig()
|
||||||
|
conf.BaseURL = baseURL
|
||||||
conf.ListenHTTP = listenHTTP
|
conf.ListenHTTP = listenHTTP
|
||||||
conf.ListenHTTPS = listenHTTPS
|
conf.ListenHTTPS = listenHTTPS
|
||||||
conf.KeyFile = keyFile
|
conf.KeyFile = keyFile
|
||||||
@@ -95,10 +119,19 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.CacheDuration = cacheDuration
|
conf.CacheDuration = cacheDuration
|
||||||
conf.KeepaliveInterval = keepaliveInterval
|
conf.KeepaliveInterval = keepaliveInterval
|
||||||
conf.ManagerInterval = managerInterval
|
conf.ManagerInterval = managerInterval
|
||||||
|
conf.SMTPSenderAddr = smtpSenderAddr
|
||||||
|
conf.SMTPSenderUser = smtpSenderUser
|
||||||
|
conf.SMTPSenderPass = smtpSenderPass
|
||||||
|
conf.SMTPSenderFrom = smtpSenderFrom
|
||||||
|
conf.SMTPServerListen = smtpServerListen
|
||||||
|
conf.SMTPServerDomain = smtpServerDomain
|
||||||
|
conf.SMTPServerAddrPrefix = smtpServerAddrPrefix
|
||||||
conf.GlobalTopicLimit = globalTopicLimit
|
conf.GlobalTopicLimit = globalTopicLimit
|
||||||
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
||||||
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
|
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
|
||||||
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
|
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
|
||||||
|
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||||
|
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||||
conf.BehindProxy = behindProxy
|
conf.BehindProxy = behindProxy
|
||||||
s, err := server.New(conf)
|
s, err := server.New(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ func runCommandInternal(c *cli.Context, command string, m *client.Message) error
|
|||||||
defer os.Remove(scriptFile)
|
defer os.Remove(scriptFile)
|
||||||
verbose := c.Bool("verbose")
|
verbose := c.Bool("verbose")
|
||||||
if verbose {
|
if verbose {
|
||||||
log.Printf("[%s] Executing: %s (for message: %s)", collapseTopicURL(m.TopicURL), command, m.Raw)
|
log.Printf("[%s] Executing: %s (for message: %s)", util.ShortTopicURL(m.TopicURL), command, m.Raw)
|
||||||
}
|
}
|
||||||
cmd := exec.Command("sh", "-c", scriptFile)
|
cmd := exec.Command("sh", "-c", scriptFile)
|
||||||
cmd.Stdin = c.App.Reader
|
cmd.Stdin = c.App.Reader
|
||||||
|
|||||||
165
docs/config.md
165
docs/config.md
@@ -13,7 +13,7 @@ $ ntfy serve
|
|||||||
|
|
||||||
You can immediately start [publishing messages](publish.md), or subscribe via the [Android app](subscribe/phone.md),
|
You can immediately start [publishing messages](publish.md), or subscribe via the [Android app](subscribe/phone.md),
|
||||||
[the web UI](subscribe/web.md), or simply via [curl or your favorite HTTP client](subscribe/api.md). To configure
|
[the web UI](subscribe/web.md), or simply via [curl or your favorite HTTP client](subscribe/api.md). To configure
|
||||||
the server further, check out the [config options table](#config-options) or simply type `ntfy --help` to
|
the server further, check out the [config options table](#config-options) or simply type `ntfy serve --help` to
|
||||||
get a list of [command line options](#command-line-options).
|
get a list of [command line options](#command-line-options).
|
||||||
|
|
||||||
## Message cache
|
## Message cache
|
||||||
@@ -35,23 +35,82 @@ the message to the subscribers.
|
|||||||
Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#poll-for-messages), as well as the
|
Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#poll-for-messages), as well as the
|
||||||
[`since=` parameter](subscribe/api.md#fetch-cached-messages).
|
[`since=` parameter](subscribe/api.md#fetch-cached-messages).
|
||||||
|
|
||||||
|
## E-mail notifications
|
||||||
|
To allow forwarding messages via e-mail, you can configure an **SMTP server for outgoing messages**. Once configured,
|
||||||
|
you can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g.
|
||||||
|
`curl -d "hi there" -H "X-Email: phil@example.com" ntfy.sh/mytopic`).
|
||||||
|
|
||||||
|
As of today, only SMTP servers with PLAIN auth and STARTLS are supported. To enable e-mail sending, you must set the
|
||||||
|
following settings:
|
||||||
|
|
||||||
|
* `base-url` is the root URL for the ntfy server; this is needed for e-mail footer
|
||||||
|
* `smtp-sender-addr` is the hostname:port of the SMTP server
|
||||||
|
* `smtp-sender-user` and `smtp-sender-pass` are the username and password of the SMTP user
|
||||||
|
* `smtp-sender-from` is the e-mail address of the sender
|
||||||
|
|
||||||
|
Here's an example config using [Amazon SES](https://aws.amazon.com/ses/) for outgoing mail (this is how it is
|
||||||
|
configured for `ntfy.sh`):
|
||||||
|
|
||||||
|
=== "/etc/ntfy/server.yml"
|
||||||
|
``` yaml
|
||||||
|
base-url: "https://ntfy.sh"
|
||||||
|
smtp-sender-addr: "email-smtp.us-east-2.amazonaws.com:587"
|
||||||
|
smtp-sender-user: "AKIDEADBEEFAFFE12345"
|
||||||
|
smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG."
|
||||||
|
smtp-sender-from: "ntfy@ntfy.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst`
|
||||||
|
and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse.
|
||||||
|
|
||||||
|
## E-mail publishing
|
||||||
|
To allow publishing messages via e-mail, ntfy can run a lightweight **SMTP server for incoming messages**. Once configured,
|
||||||
|
users can [send emails to a topic e-mail address](publish.md#e-mail-publishing) (e.g. `mytopic@ntfy.sh` or
|
||||||
|
`myprefix-mytopic@ntfy.sh`) to publish messages to a topic. This is useful for e-mail based integrations such as for
|
||||||
|
statuspage.io (though these days most services also support webhooks and HTTP calls).
|
||||||
|
|
||||||
|
To configure the SMTP server, you must at least set `smtp-server-listen` and `smtp-server-domain`:
|
||||||
|
|
||||||
|
* `smtp-server-listen` defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25`
|
||||||
|
* `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh`
|
||||||
|
* `smtp-server-addr-prefix` is an optional prefix for the e-mail addresses to prevent spam. If set to `ntfy-`, for instance,
|
||||||
|
only e-mails to `ntfy-$topic@ntfy.sh` will be accepted. If this is not set, all emails to `$topic@ntfy.sh` will be
|
||||||
|
accepted (which may obviously be a spam problem).
|
||||||
|
|
||||||
|
Here's an example config (this is how it is configured for `ntfy.sh`):
|
||||||
|
|
||||||
|
=== "/etc/ntfy/server.yml"
|
||||||
|
``` yaml
|
||||||
|
smtp-server-listen: ":25"
|
||||||
|
smtp-server-domain: "ntfy.sh"
|
||||||
|
smtp-server-addr-prefix: "ntfy-"
|
||||||
|
```
|
||||||
|
|
||||||
|
In addition to configuring the ntfy server, you have to create two DNS records (an [MX record](https://en.wikipedia.org/wiki/MX_record)
|
||||||
|
and a corresponding A record), so incoming mail will find its way to your server. Here's an example of how `ntfy.sh` is
|
||||||
|
configured (in [Amazon Route 53](https://aws.amazon.com/route53/)):
|
||||||
|
|
||||||
|
<figure markdown>
|
||||||
|
{ width=600 }
|
||||||
|
<figcaption>DNS records for incoming mail</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
## Behind a proxy (TLS, etc.)
|
## Behind a proxy (TLS, etc.)
|
||||||
!!! warning
|
!!! warning
|
||||||
If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are
|
If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are
|
||||||
[rate limited](#rate-limiting) as if they are one.
|
[rate limited](#rate-limiting) as if they are one.
|
||||||
|
|
||||||
It may be desirable to run ntfy behind a proxy, e.g. so you can provide TLS certificates using Let's Encrypt using certbot,
|
It may be desirable to run ntfy behind a proxy (e.g. nginx, HAproxy or Apache), so you can provide TLS certificates
|
||||||
or simply because you'd like to share the ports (80/443) with other services. Whatever your reasons may be, there are a
|
using Let's Encrypt using certbot, or simply because you'd like to share the ports (80/443) with other services.
|
||||||
few things to consider.
|
Whatever your reasons may be, there are a few things to consider.
|
||||||
|
|
||||||
### Rate limiting
|
If you are running ntfy behind a proxy, you should set the `behind-proxy` flag. This will instruct the
|
||||||
If you are running ntfy behind a proxy (e.g. nginx, HAproxy or Apache), you should set the `behind-proxy`
|
[rate limiting](#rate-limiting) logic to use the `X-Forwarded-For` header as the primary identifier for a visitor,
|
||||||
flag. This will instruct the [rate limiting](#rate-limiting) logic to use the `X-Forwarded-For` header as the primary
|
as opposed to the remote IP address. If the `behind-proxy` flag is not set, all visitors will
|
||||||
identifier for a visitor, as opposed to the remote IP address. If the `behind-proxy` flag is not set, all visitors will
|
|
||||||
be counted as one, because from the perspective of the ntfy server, they all share the proxy's IP address.
|
be counted as one, because from the perspective of the ntfy server, they all share the proxy's IP address.
|
||||||
|
|
||||||
=== "/etc/ntfy/server.yml"
|
=== "/etc/ntfy/server.yml"
|
||||||
```
|
``` yaml
|
||||||
# Tell ntfy to use "X-Forwarded-For" to identify visitors
|
# Tell ntfy to use "X-Forwarded-For" to identify visitors
|
||||||
behind-proxy: true
|
behind-proxy: true
|
||||||
```
|
```
|
||||||
@@ -149,7 +208,7 @@ or the root domain:
|
|||||||
ProxyPass / http://127.0.0.1:2586/
|
ProxyPass / http://127.0.0.1:2586/
|
||||||
ProxyPassReverse / http://127.0.0.1:2586/
|
ProxyPassReverse / http://127.0.0.1:2586/
|
||||||
|
|
||||||
# Higher than the max message size of 512k
|
# Higher than the max message size of 4096 bytes
|
||||||
LimitRequestBody 102400
|
LimitRequestBody 102400
|
||||||
|
|
||||||
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||||
@@ -173,7 +232,7 @@ or the root domain:
|
|||||||
ProxyPass / http://127.0.0.1:2586/
|
ProxyPass / http://127.0.0.1:2586/
|
||||||
ProxyPassReverse / http://127.0.0.1:2586/
|
ProxyPassReverse / http://127.0.0.1:2586/
|
||||||
|
|
||||||
# Higher than the max message size of 512k
|
# Higher than the max message size of 4096 bytes
|
||||||
LimitRequestBody 102400
|
LimitRequestBody 102400
|
||||||
|
|
||||||
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||||
@@ -214,7 +273,7 @@ firebase-key-file: "/etc/ntfy/ntfy-sh-firebase-adminsdk-ahnce-9f4d6f14b5.json"
|
|||||||
## Rate limiting
|
## Rate limiting
|
||||||
!!! info
|
!!! info
|
||||||
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
||||||
Otherwise all visitors are rate limited as if they are one.
|
Otherwise, all visitors are rate limited as if they are one.
|
||||||
|
|
||||||
By default, ntfy runs without authentication, so it is vitally important that we protect the server from abuse or overload.
|
By default, ntfy runs without authentication, so it is vitally important that we protect the server from abuse or overload.
|
||||||
There are various limits and rate limits in place that you can use to configure the server. Let's do the easy ones first:
|
There are various limits and rate limits in place that you can use to configure the server. Let's do the easy ones first:
|
||||||
@@ -235,9 +294,14 @@ request every 10s (defined by `visitor-request-limit-replenish`)
|
|||||||
* `visitor-request-limit-burst` is the initial bucket of requests each visitor has. This defaults to 60.
|
* `visitor-request-limit-burst` is the initial bucket of requests each visitor has. This defaults to 60.
|
||||||
* `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 10s.
|
* `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 10s.
|
||||||
|
|
||||||
During normal usage, you shouldn't encounter this limit at all, and even if you burst a few requests shortly (e.g. when you
|
Similarly to the request limit, there is also an e-mail limit (only relevant if [e-mail notifications](#e-mail-notifications)
|
||||||
reconnect after a connection drop), it shouldn't have any effect.
|
are enabled):
|
||||||
|
|
||||||
|
* `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16.
|
||||||
|
* `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h.
|
||||||
|
|
||||||
|
During normal usage, you shouldn't encounter these limits at all, and even if you burst a few requests or emails
|
||||||
|
(e.g. when you reconnect after a connection drop), it shouldn't have any effect.
|
||||||
|
|
||||||
## Tuning for scale
|
## Tuning for scale
|
||||||
If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config,
|
If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config,
|
||||||
@@ -300,6 +364,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
|||||||
|
|
||||||
| Config option | Env variable | Format | Default | Description |
|
| Config option | Env variable | Format | Default | Description |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
|
| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) |
|
||||||
| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server |
|
| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server |
|
||||||
| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. |
|
| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. |
|
||||||
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
|
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
|
||||||
@@ -307,42 +372,70 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
|||||||
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). |
|
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). |
|
||||||
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
|
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
|
||||||
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
|
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
|
||||||
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 30s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
|
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
|
||||||
|
| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending |
|
||||||
|
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
|
||||||
|
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
|
||||||
|
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
|
||||||
|
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
|
||||||
|
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
|
||||||
|
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
|
||||||
|
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 55s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
|
||||||
| `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
| `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
||||||
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 5000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 5000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
||||||
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
||||||
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
|
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
|
||||||
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 10s | Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 10s | Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
||||||
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
|
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 |Initial limit of e-mails per visitor |
|
||||||
|
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
|
||||||
|
|
||||||
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
||||||
|
|
||||||
## Command line options
|
## Command line options
|
||||||
```
|
```
|
||||||
$ ntfy --help
|
$ ntfy serve --help
|
||||||
NAME:
|
NAME:
|
||||||
ntfy - Simple pub-sub notification service
|
ntfy serve - Run the ntfy server
|
||||||
|
|
||||||
USAGE:
|
USAGE:
|
||||||
ntfy [OPTION..]
|
ntfy serve [OPTIONS..]
|
||||||
|
|
||||||
GLOBAL OPTIONS:
|
DESCRIPTION:
|
||||||
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
Run the ntfy server and listen for incoming requests
|
||||||
--listen-http value, -l value ip:port used to as listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
|
||||||
--firebase-key-file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
|
The command will load the configuration from /etc/ntfy/server.yml. Config options can
|
||||||
--cache-file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
|
be overridden using the command line options.
|
||||||
--cache-duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
|
|
||||||
--keepalive-interval value, -k value interval of keepalive messages (default: 30s) [$NTFY_KEEPALIVE_INTERVAL]
|
Examples:
|
||||||
--manager-interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
ntfy serve # Starts server in the foreground (on port 80)
|
||||||
--global-topic-limit value, -T value total number of topics allowed (default: 5000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
ntfy serve --listen-http :8080 # Starts server with alternate port
|
||||||
--visitor-subscription-limit value, -V value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
|
||||||
--visitor-request-limit-burst value, -B value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
|
||||||
--visitor-request-limit-replenish value, -R value interval at which burst limit is replenished (one per x) (default: 10s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
|
||||||
--behind-proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
|
||||||
|
|
||||||
Try 'ntfy COMMAND --help' for more information.
|
OPTIONS:
|
||||||
|
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
||||||
ntfy v1.4.8 (7b8185c), runtime go1.17, built at 1637872539
|
--base-url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
|
||||||
Copyright (C) 2021 Philipp C. Heckel, distributed under the Apache License 2.0
|
--listen-http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
||||||
|
--listen-https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
||||||
|
--key-file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
|
||||||
|
--cert-file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
|
||||||
|
--firebase-key-file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
|
||||||
|
--cache-file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
|
||||||
|
--cache-duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
|
||||||
|
--keepalive-interval value, -k value interval of keepalive messages (default: 55s) [$NTFY_KEEPALIVE_INTERVAL]
|
||||||
|
--manager-interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
||||||
|
--smtp-sender-addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
|
||||||
|
--smtp-sender-user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
|
||||||
|
--smtp-sender-pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
|
||||||
|
--smtp-sender-from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
|
||||||
|
--smtp-server-listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
|
||||||
|
--smtp-server-domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
|
||||||
|
--smtp-server-addr-prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
|
||||||
|
--global-topic-limit value, -T value total number of topics allowed (default: 5000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
||||||
|
--visitor-subscription-limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
||||||
|
--visitor-request-limit-burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
||||||
|
--visitor-request-limit-replenish value interval at which burst limit is replenished (one per x) (default: 10s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
||||||
|
--visitor-email-limit-burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
|
||||||
|
--visitor-email-limit-replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
||||||
|
--behind-proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
||||||
|
--help, -h show help (default: false)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -26,21 +26,21 @@ deb/rpm packages.
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_x86_64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_x86_64.tar.gz
|
||||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
||||||
sudo ./ntfy serve
|
sudo ./ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_armv7.tar.gz
|
||||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
||||||
sudo ./ntfy serve
|
sudo ./ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_arm64.tar.gz
|
||||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
||||||
sudo ./ntfy serve
|
sudo ./ntfy serve
|
||||||
```
|
```
|
||||||
@@ -88,7 +88,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_amd64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -96,7 +96,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_armv7.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -104,7 +104,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_arm64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -114,25 +114,39 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_amd64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_armv7.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.11.1/ntfy_1.11.1_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Arch Linux
|
||||||
|
ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/). You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download, build and install ntfy and keep it up to date.
|
||||||
|
```
|
||||||
|
paru -S ntfysh-bin
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, run the following commands to install ntfy manually:
|
||||||
|
```
|
||||||
|
curl https://aur.archlinux.org/cgit/aur.git/snapshot/ntfysh-bin.tar.gz | tar xzv
|
||||||
|
cd ntfysh-bin
|
||||||
|
makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv7 and arm64. It should be pretty
|
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv7 and arm64. It should be pretty
|
||||||
straight forward to use.
|
straight forward to use.
|
||||||
@@ -167,6 +181,14 @@ docker run \
|
|||||||
serve
|
serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
|
||||||
|
```
|
||||||
|
FROM binwiederhier/ntfy
|
||||||
|
COPY server.yml /etc/ntfy/server.yml
|
||||||
|
ENTRYPOINT ["ntfy", "serve"]
|
||||||
|
```
|
||||||
|
This image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port.
|
||||||
|
|
||||||
## Go
|
## Go
|
||||||
To install via Go, simply run:
|
To install via Go, simply run:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
127
docs/publish.md
127
docs/publish.md
@@ -592,6 +592,127 @@ 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');
|
file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull');
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## E-mail notifications
|
||||||
|
You can forward messages to e-mail by specifying an 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.
|
||||||
|
|
||||||
|
Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`).
|
||||||
|
Only one e-mail address is supported.
|
||||||
|
|
||||||
|
Since ntfy does not provide auth (yet), the rate limiting is pretty strict (see [limitations](#limitations)). In the
|
||||||
|
default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of
|
||||||
|
that, your IP address appears in the e-mail body. This is to prevent abuse.
|
||||||
|
|
||||||
|
=== "Command line (curl)"
|
||||||
|
```
|
||||||
|
curl \
|
||||||
|
-H "Email: phil@example.com" \
|
||||||
|
-H "Tags: warning,skull,backup-host,ssh-login" \
|
||||||
|
-H "Priority: high" \
|
||||||
|
-d "Unknown login from 5.31.23.83 to backups.example.com" \
|
||||||
|
ntfy.sh/alerts
|
||||||
|
curl -H "Email: phil@example.com" -d "You've Got Mail"
|
||||||
|
curl -d "You've Got Mail" "ntfy.sh/alerts?email=phil@example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "ntfy CLI"
|
||||||
|
```
|
||||||
|
ntfy publish \
|
||||||
|
--email=phil@example.com \
|
||||||
|
--tags=warning,skull,backup-host,ssh-login \
|
||||||
|
--priority=high \
|
||||||
|
alerts "Unknown login from 5.31.23.83 to backups.example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "HTTP"
|
||||||
|
``` http
|
||||||
|
POST /alerts HTTP/1.1
|
||||||
|
Host: ntfy.sh
|
||||||
|
Email: phil@example.com
|
||||||
|
Tags: warning,skull,backup-host,ssh-login
|
||||||
|
Priority: high
|
||||||
|
|
||||||
|
Unknown login from 5.31.23.83 to backups.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "JavaScript"
|
||||||
|
``` javascript
|
||||||
|
fetch('https://ntfy.sh/alerts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: "Unknown login from 5.31.23.83 to backups.example.com",
|
||||||
|
headers: {
|
||||||
|
'Email': 'phil@example.com',
|
||||||
|
'Tags': 'warning,skull,backup-host,ssh-login',
|
||||||
|
'Priority': 'high'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Go"
|
||||||
|
``` go
|
||||||
|
req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts",
|
||||||
|
strings.NewReader("Unknown login from 5.31.23.83 to backups.example.com"))
|
||||||
|
req.Header.Set("Email", "phil@example.com")
|
||||||
|
req.Header.Set("Tags", "warning,skull,backup-host,ssh-login")
|
||||||
|
req.Header.Set("Priority", "high")
|
||||||
|
http.DefaultClient.Do(req)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Python"
|
||||||
|
``` python
|
||||||
|
requests.post("https://ntfy.sh/alerts",
|
||||||
|
data="Unknown login from 5.31.23.83 to backups.example.com",
|
||||||
|
headers={
|
||||||
|
"Email": "phil@example.com",
|
||||||
|
"Tags": "warning,skull,backup-host,ssh-login",
|
||||||
|
"Priority": "high"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "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\r\n" .
|
||||||
|
"Tags: warning,skull,backup-host,ssh-login\r\n" .
|
||||||
|
"Priority: high",
|
||||||
|
'content' => 'Unknown login from 5.31.23.83 to backups.example.com'
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
```
|
||||||
|
|
||||||
|
Here's what that looks like in Google Mail:
|
||||||
|
|
||||||
|
<figure markdown>
|
||||||
|
{ width=600 }
|
||||||
|
<figcaption>E-mail notification</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
## E-mail publishing
|
||||||
|
You can publish messages to a topic via e-mail, i.e. by sending an email to a specific address. For instance, you can
|
||||||
|
publish a message to the topic `sometopic` by sending an e-mail to `ntfy-sometopic@ntfy.sh`. This is useful for e-mail
|
||||||
|
based integrations such as for statuspage.io (though these days most services also support webhooks and HTTP calls).
|
||||||
|
|
||||||
|
Depending on the [server configuration](config.md#e-mail-publishing), the e-mail address format can have a prefix to
|
||||||
|
prevent spam on topics. For ntfy.sh, the prefix is configured to `ntfy-`, meaning that the general e-mail address
|
||||||
|
format is:
|
||||||
|
|
||||||
|
```
|
||||||
|
ntfy-$topic@ntfy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
As of today, e-mail publishing only supports adding a [message title](#message-title) (the e-mail subject). Tags, priority,
|
||||||
|
delay and other features are not supported (yet). Here's an example that will publish a message with the
|
||||||
|
title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://ntfy.sh/sometopic)):
|
||||||
|
|
||||||
|
<figure markdown>
|
||||||
|
{ width=500 }
|
||||||
|
<figcaption>Publishing a message via e-mail</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
## Advanced features
|
## Advanced features
|
||||||
|
|
||||||
### Message caching
|
### Message caching
|
||||||
@@ -745,8 +866,9 @@ but just in case, let's list them all:
|
|||||||
|
|
||||||
| Limit | Description |
|
| Limit | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Message length** | Each message can be up to 512 bytes long. Longer messages are truncated. |
|
| **Message length** | Each message can be up to 4096 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 sending 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. |
|
| **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. |
|
| **Total number of topics** | By default, the server is configured to allow 5,000 topics. The ntfy.sh server has higher limits though. |
|
||||||
|
|
||||||
@@ -761,5 +883,6 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
|
|||||||
| `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) |
|
| `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) |
|
||||||
| `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) |
|
| `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) |
|
||||||
| `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) |
|
| `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) |
|
||||||
|
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
|
||||||
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
|
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
|
||||||
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
|
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
|
||||||
|
|||||||
6
docs/static/css/extra.css
vendored
6
docs/static/css/extra.css
vendored
@@ -8,6 +8,12 @@
|
|||||||
width: unset !important;
|
width: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-typeset h4 {
|
||||||
|
font-weight: 500 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
font-size: 1.1em !important;
|
||||||
|
}
|
||||||
|
|
||||||
.admonition {
|
.admonition {
|
||||||
font-size: .74rem !important;
|
font-size: .74rem !important;
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
docs/static/img/android-screenshot-unifiedpush-fluffychat.jpg
vendored
Normal file
BIN
docs/static/img/android-screenshot-unifiedpush-fluffychat.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
docs/static/img/android-screenshot-unifiedpush-settings.jpg
vendored
Normal file
BIN
docs/static/img/android-screenshot-unifiedpush-settings.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/static/img/android-screenshot-unifiedpush-subscription.jpg
vendored
Normal file
BIN
docs/static/img/android-screenshot-unifiedpush-subscription.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
docs/static/img/screenshot-email-publishing-dns.png
vendored
Normal file
BIN
docs/static/img/screenshot-email-publishing-dns.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/static/img/screenshot-email-publishing-gmail.png
vendored
Normal file
BIN
docs/static/img/screenshot-email-publishing-gmail.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/static/img/screenshot-email.png
vendored
Normal file
BIN
docs/static/img/screenshot-email.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
@@ -81,11 +81,28 @@ The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in
|
|||||||
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
|
### UnifiedPush
|
||||||
|
[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned
|
||||||
|
[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications
|
||||||
|
in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it.
|
||||||
|
|
||||||
|
To use ntfy as a distributor, simply select it in one of the [supported apps](https://unifiedpush.org/users/apps/).
|
||||||
|
That's it. It's a one-step installation 😀. If desired, you can select your own [selfhosted ntfy server](../install.md)
|
||||||
|
to handle messages. Here's an example with [FluffyChat](https://fluffychat.im/):
|
||||||
|
|
||||||
|
<div id="unifiedpush-screenshots" class="screenshots">
|
||||||
|
<a href="../../static/img/android-screenshot-unifiedpush-fluffychat.jpg"><img src="../../static/img/android-screenshot-unifiedpush-fluffychat.jpg"/></a>
|
||||||
|
<a href="../../static/img/android-screenshot-unifiedpush-subscription.jpg"><img src="../../static/img/android-screenshot-unifiedpush-subscription.jpg"/></a>
|
||||||
|
<a href="../../static/img/android-screenshot-unifiedpush-settings.jpg"><img src="../../static/img/android-screenshot-unifiedpush-settings.jpg"/></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### Automation apps
|
||||||
The ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
The ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||||
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can
|
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can
|
||||||
**react to incoming messages**, as well as **send messages**.
|
**react to incoming messages**, as well as **send messages**.
|
||||||
|
|
||||||
### React to incoming messages
|
#### React to incoming messages
|
||||||
To react on incoming notifications, you have to register to intents with the `io.heckel.ntfy.MESSAGE_RECEIVED` action (see
|
To react on incoming notifications, you have to register to intents with the `io.heckel.ntfy.MESSAGE_RECEIVED` action (see
|
||||||
[code for details](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt)).
|
[code for details](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt)).
|
||||||
Here's an example using [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
Here's an example using [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||||
@@ -127,7 +144,7 @@ Here's a list of extras you can access. Most likely, you'll want to filter for `
|
|||||||
| `tags_map` | *string* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
|
| `tags_map` | *string* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
|
||||||
| `priority` | *int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
| `priority` | *int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||||
|
|
||||||
### Send messages using intents
|
#### Send messages using intents
|
||||||
To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||||
and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm)), you can
|
and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm)), you can
|
||||||
broadcast an intent with the `io.heckel.ntfy.SEND_MESSAGE` action. The ntfy Android app will forward the intent as a HTTP
|
broadcast an intent with the `io.heckel.ntfy.SEND_MESSAGE` action. The ntfy Android app will forward the intent as a HTTP
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -8,6 +8,7 @@ require (
|
|||||||
firebase.google.com/go v3.13.0+incompatible
|
firebase.google.com/go v3.13.0+incompatible
|
||||||
github.com/BurntSushi/toml v0.4.1 // indirect
|
github.com/BurntSushi/toml v0.4.1 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
|
||||||
|
github.com/emersion/go-smtp v0.15.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.9
|
github.com/mattn/go-sqlite3 v1.14.9
|
||||||
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
|
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/stretchr/testify v1.7.0
|
||||||
@@ -26,6 +27,7 @@ require (
|
|||||||
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect
|
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect
|
||||||
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 // indirect
|
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
|
||||||
github.com/envoyproxy/go-control-plane v0.10.1 // indirect
|
github.com/envoyproxy/go-control-plane v0.10.1 // indirect
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
|
github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -89,6 +89,10 @@ github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
|
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
|
||||||
|
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ const (
|
|||||||
createMessagesTableQuery = `
|
createMessagesTableQuery = `
|
||||||
BEGIN;
|
BEGIN;
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id VARCHAR(20) PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
time INT NOT NULL,
|
time INT NOT NULL,
|
||||||
topic VARCHAR(64) NOT NULL,
|
topic TEXT NOT NULL,
|
||||||
message VARCHAR(512) NOT NULL,
|
message TEXT NOT NULL,
|
||||||
title VARCHAR(256) NOT NULL,
|
title TEXT NOT NULL,
|
||||||
priority INT NOT NULL,
|
priority INT NOT NULL,
|
||||||
tags VARCHAR(256) NOT NULL,
|
tags TEXT NOT NULL,
|
||||||
published INT NOT NULL
|
published INT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||||
@@ -68,9 +68,9 @@ const (
|
|||||||
// 0 -> 1
|
// 0 -> 1
|
||||||
migrate0To1AlterMessagesTableQuery = `
|
migrate0To1AlterMessagesTableQuery = `
|
||||||
BEGIN;
|
BEGIN;
|
||||||
ALTER TABLE messages ADD COLUMN title VARCHAR(256) NOT NULL DEFAULT('');
|
ALTER TABLE messages ADD COLUMN title TEXT NOT NULL DEFAULT('');
|
||||||
ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0);
|
ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0);
|
||||||
ALTER TABLE messages ADD COLUMN tags VARCHAR(256) NOT NULL DEFAULT('');
|
ALTER TABLE messages ADD COLUMN tags TEXT NOT NULL DEFAULT('');
|
||||||
COMMIT;
|
COMMIT;
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -8,28 +8,32 @@ import (
|
|||||||
const (
|
const (
|
||||||
DefaultListenHTTP = ":80"
|
DefaultListenHTTP = ":80"
|
||||||
DefaultCacheDuration = 12 * time.Hour
|
DefaultCacheDuration = 12 * time.Hour
|
||||||
DefaultKeepaliveInterval = 30 * time.Second
|
DefaultKeepaliveInterval = 55 * time.Second // Not too frequently to save battery
|
||||||
DefaultManagerInterval = time.Minute
|
DefaultManagerInterval = time.Minute
|
||||||
DefaultAtSenderInterval = 10 * time.Second
|
DefaultAtSenderInterval = 10 * time.Second
|
||||||
DefaultMinDelay = 10 * time.Second
|
DefaultMinDelay = 10 * time.Second
|
||||||
DefaultMaxDelay = 3 * 24 * time.Hour
|
DefaultMaxDelay = 3 * 24 * time.Hour
|
||||||
DefaultMessageLimit = 512
|
DefaultMessageLimit = 4096
|
||||||
DefaultFirebaseKeepaliveInterval = time.Hour
|
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // Not too frequently to save battery
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defines all the limits
|
// Defines all the limits
|
||||||
// - global topic limit: max number of topics overall
|
// - global topic limit: max number of topics overall
|
||||||
// - per visitor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 10 seconds)
|
// - per visitor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 10 seconds)
|
||||||
|
// - per visitor email limit: max number of emails (here: 16 email bucket, replenished at a rate of one per hour)
|
||||||
// - per visitor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
|
// - per visitor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
|
||||||
const (
|
const (
|
||||||
DefaultGlobalTopicLimit = 5000
|
DefaultGlobalTopicLimit = 5000
|
||||||
DefaultVisitorRequestLimitBurst = 60
|
DefaultVisitorRequestLimitBurst = 60
|
||||||
DefaultVisitorRequestLimitReplenish = 10 * time.Second
|
DefaultVisitorRequestLimitReplenish = 10 * time.Second
|
||||||
|
DefaultVisitorEmailLimitBurst = 16
|
||||||
|
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||||
DefaultVisitorSubscriptionLimit = 30
|
DefaultVisitorSubscriptionLimit = 30
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the main config struct for the application. Use New to instantiate a default config struct.
|
// Config is the main config struct for the application. Use New to instantiate a default config struct.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
BaseURL string
|
||||||
ListenHTTP string
|
ListenHTTP string
|
||||||
ListenHTTPS string
|
ListenHTTPS string
|
||||||
KeyFile string
|
KeyFile string
|
||||||
@@ -41,12 +45,21 @@ type Config struct {
|
|||||||
ManagerInterval time.Duration
|
ManagerInterval time.Duration
|
||||||
AtSenderInterval time.Duration
|
AtSenderInterval time.Duration
|
||||||
FirebaseKeepaliveInterval time.Duration
|
FirebaseKeepaliveInterval time.Duration
|
||||||
|
SMTPSenderAddr string
|
||||||
|
SMTPSenderUser string
|
||||||
|
SMTPSenderPass string
|
||||||
|
SMTPSenderFrom string
|
||||||
|
SMTPServerListen string
|
||||||
|
SMTPServerDomain string
|
||||||
|
SMTPServerAddrPrefix string
|
||||||
MessageLimit int
|
MessageLimit int
|
||||||
MinDelay time.Duration
|
MinDelay time.Duration
|
||||||
MaxDelay time.Duration
|
MaxDelay time.Duration
|
||||||
GlobalTopicLimit int
|
GlobalTopicLimit int
|
||||||
VisitorRequestLimitBurst int
|
VisitorRequestLimitBurst int
|
||||||
VisitorRequestLimitReplenish time.Duration
|
VisitorRequestLimitReplenish time.Duration
|
||||||
|
VisitorEmailLimitBurst int
|
||||||
|
VisitorEmailLimitReplenish time.Duration
|
||||||
VisitorSubscriptionLimit int
|
VisitorSubscriptionLimit int
|
||||||
BehindProxy bool
|
BehindProxy bool
|
||||||
}
|
}
|
||||||
@@ -54,6 +67,7 @@ type Config struct {
|
|||||||
// NewConfig instantiates a default new server config
|
// NewConfig instantiates a default new server config
|
||||||
func NewConfig() *Config {
|
func NewConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
|
BaseURL: "",
|
||||||
ListenHTTP: DefaultListenHTTP,
|
ListenHTTP: DefaultListenHTTP,
|
||||||
ListenHTTPS: "",
|
ListenHTTPS: "",
|
||||||
KeyFile: "",
|
KeyFile: "",
|
||||||
@@ -71,6 +85,8 @@ func NewConfig() *Config {
|
|||||||
GlobalTopicLimit: DefaultGlobalTopicLimit,
|
GlobalTopicLimit: DefaultGlobalTopicLimit,
|
||||||
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
||||||
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
||||||
|
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
||||||
|
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
||||||
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
||||||
BehindProxy: false,
|
BehindProxy: false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,7 +198,7 @@
|
|||||||
curl -d "Backup failed" <span id="detailTopicUrl">ntfy.sh/topic</span>
|
curl -d "Backup failed" <span id="detailTopicUrl">ntfy.sh/topic</span>
|
||||||
</code>
|
</code>
|
||||||
<p id="detailNotificationsDisallowed">
|
<p id="detailNotificationsDisallowed">
|
||||||
If you'd like to receive desktop notifications when new messages arrive on this topic, you have
|
If you'd like to receive desktop notifications when new messages arrive on this topic, you have to
|
||||||
<a href="#" onclick="return requestPermission()">grant the browser permission</a> to show notifications.
|
<a href="#" onclick="return requestPermission()">grant the browser permission</a> to show notifications.
|
||||||
Click the link to do so.
|
Click the link to do so.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
1
server/mailer_emoji.json
Normal file
1
server/mailer_emoji.json
Normal file
File diff suppressed because one or more lines are too long
212
server/server.go
212
server/server.go
@@ -3,11 +3,13 @@ package server
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"embed" // required for go:embed
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
firebase "firebase.google.com/go"
|
firebase "firebase.google.com/go"
|
||||||
"firebase.google.com/go/messaging"
|
"firebase.google.com/go/messaging"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/emersion/go-smtp"
|
||||||
"google.golang.org/api/option"
|
"google.golang.org/api/option"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"html/template"
|
"html/template"
|
||||||
@@ -15,6 +17,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -30,9 +33,12 @@ type Server struct {
|
|||||||
config *Config
|
config *Config
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
httpsServer *http.Server
|
httpsServer *http.Server
|
||||||
|
smtpServer *smtp.Server
|
||||||
|
smtpBackend *smtpBackend
|
||||||
topics map[string]*topic
|
topics map[string]*topic
|
||||||
visitors map[string]*visitor
|
visitors map[string]*visitor
|
||||||
firebase subscriber
|
firebase subscriber
|
||||||
|
mailer mailer
|
||||||
messages int64
|
messages int64
|
||||||
cache cache
|
cache cache
|
||||||
closeChan chan bool
|
closeChan chan bool
|
||||||
@@ -41,12 +47,19 @@ type Server struct {
|
|||||||
|
|
||||||
// errHTTP is a generic HTTP error for any non-200 HTTP error
|
// errHTTP is a generic HTTP error for any non-200 HTTP error
|
||||||
type errHTTP struct {
|
type errHTTP struct {
|
||||||
Code int
|
Code int `json:"code,omitempty"`
|
||||||
Status string
|
HTTPCode int `json:"http"`
|
||||||
|
Message string `json:"error"`
|
||||||
|
Link string `json:"link,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e errHTTP) Error() string {
|
func (e errHTTP) Error() string {
|
||||||
return fmt.Sprintf("http: %s", e.Status)
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e errHTTP) JSON() string {
|
||||||
|
b, _ := json.Marshal(&e)
|
||||||
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
type indexPage struct {
|
type indexPage struct {
|
||||||
@@ -74,11 +87,12 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
topicRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
||||||
jsonRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
|
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
||||||
sseRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
|
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
|
||||||
rawRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
|
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
|
||||||
sendRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/(publish|send|trigger)$`)
|
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
|
||||||
|
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/(publish|send|trigger)$`)
|
||||||
|
|
||||||
staticRegex = regexp.MustCompile(`^/static/.+`)
|
staticRegex = regexp.MustCompile(`^/static/.+`)
|
||||||
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
||||||
@@ -103,13 +117,27 @@ var (
|
|||||||
docsStaticFs embed.FS
|
docsStaticFs embed.FS
|
||||||
docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
|
docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
|
||||||
|
|
||||||
errHTTPBadRequest = &errHTTP{http.StatusBadRequest, http.StatusText(http.StatusBadRequest)}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
|
||||||
errHTTPNotFound = &errHTTP{http.StatusNotFound, http.StatusText(http.StatusNotFound)}
|
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPTooManyRequests = &errHTTP{http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)}
|
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
|
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
|
errHTTPTooManyRequestsLimitGlobalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
|
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"}
|
||||||
|
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""}
|
||||||
|
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""}
|
||||||
|
errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||||
|
errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||||
|
errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||||
|
errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"}
|
||||||
|
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"}
|
||||||
|
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""}
|
||||||
|
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""}
|
||||||
|
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
firebaseControlTopic = "~control" // See Android if changed
|
firebaseControlTopic = "~control" // See Android if changed
|
||||||
|
emptyMessageBody = "triggered"
|
||||||
)
|
)
|
||||||
|
|
||||||
// New instantiates a new Server. It creates the cache and adds a Firebase
|
// New instantiates a new Server. It creates the cache and adds a Firebase
|
||||||
@@ -123,6 +151,10 @@ func New(conf *Config) (*Server, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var mailer mailer
|
||||||
|
if conf.SMTPSenderAddr != "" {
|
||||||
|
mailer = &smtpSender{config: conf}
|
||||||
|
}
|
||||||
cache, err := createCache(conf)
|
cache, err := createCache(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -135,6 +167,7 @@ func New(conf *Config) (*Server, error) {
|
|||||||
config: conf,
|
config: conf,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
firebase: firebaseSubscriber,
|
firebase: firebaseSubscriber,
|
||||||
|
mailer: mailer,
|
||||||
topics: topics,
|
topics: topics,
|
||||||
visitors: make(map[string]*visitor),
|
visitors: make(map[string]*visitor),
|
||||||
}, nil
|
}, nil
|
||||||
@@ -195,6 +228,9 @@ func (s *Server) Run() error {
|
|||||||
if s.config.ListenHTTPS != "" {
|
if s.config.ListenHTTPS != "" {
|
||||||
listenStr += fmt.Sprintf(" %s/https", s.config.ListenHTTPS)
|
listenStr += fmt.Sprintf(" %s/https", s.config.ListenHTTPS)
|
||||||
}
|
}
|
||||||
|
if s.config.SMTPServerListen != "" {
|
||||||
|
listenStr += fmt.Sprintf(" %s/smtp", s.config.SMTPServerListen)
|
||||||
|
}
|
||||||
log.Printf("Listening on %s", listenStr)
|
log.Printf("Listening on %s", listenStr)
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/", s.handle)
|
mux.HandleFunc("/", s.handle)
|
||||||
@@ -211,10 +247,16 @@ func (s *Server) Run() error {
|
|||||||
errChan <- s.httpsServer.ListenAndServeTLS(s.config.CertFile, s.config.KeyFile)
|
errChan <- s.httpsServer.ListenAndServeTLS(s.config.CertFile, s.config.KeyFile)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
if s.config.SMTPServerListen != "" {
|
||||||
|
go func() {
|
||||||
|
errChan <- s.runSMTPServer()
|
||||||
|
}()
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
go s.runManager()
|
go s.runManager()
|
||||||
go s.runAtSender()
|
go s.runAtSender()
|
||||||
go s.runFirebaseKeepliver()
|
go s.runFirebaseKeepliver()
|
||||||
|
|
||||||
return <-errChan
|
return <-errChan
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,16 +270,24 @@ func (s *Server) Stop() {
|
|||||||
if s.httpsServer != nil {
|
if s.httpsServer != nil {
|
||||||
s.httpsServer.Close()
|
s.httpsServer.Close()
|
||||||
}
|
}
|
||||||
|
if s.smtpServer != nil {
|
||||||
|
s.smtpServer.Close()
|
||||||
|
}
|
||||||
close(s.closeChan)
|
close(s.closeChan)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := s.handleInternal(w, r); err != nil {
|
if err := s.handleInternal(w, r); err != nil {
|
||||||
if e, ok := err.(*errHTTP); ok {
|
var e *errHTTP
|
||||||
s.fail(w, r, e.Code, e)
|
var ok bool
|
||||||
} else {
|
if e, ok = err.(*errHTTP); !ok {
|
||||||
s.fail(w, r, http.StatusInternalServerError, err)
|
e = errHTTPInternalError
|
||||||
}
|
}
|
||||||
|
log.Printf("[%s] %s - %d - %s", r.RemoteAddr, r.Method, e.HTTPCode, err.Error())
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||||
|
w.WriteHeader(e.HTTPCode)
|
||||||
|
io.WriteString(w, e.JSON()+"\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,17 +304,17 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return s.handleDocs(w, r)
|
return s.handleDocs(w, r)
|
||||||
} else if r.Method == http.MethodOptions {
|
} else if r.Method == http.MethodOptions {
|
||||||
return s.handleOptions(w, r)
|
return s.handleOptions(w, r)
|
||||||
} else if r.Method == http.MethodGet && topicRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && topicPathRegex.MatchString(r.URL.Path) {
|
||||||
return s.handleHome(w, r)
|
return s.handleTopic(w, r)
|
||||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicRegex.MatchString(r.URL.Path) {
|
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
|
||||||
return s.withRateLimit(w, r, s.handlePublish)
|
return s.withRateLimit(w, r, s.handlePublish)
|
||||||
} else if r.Method == http.MethodGet && sendRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
|
||||||
return s.withRateLimit(w, r, s.handlePublish)
|
return s.withRateLimit(w, r, s.handlePublish)
|
||||||
} else if r.Method == http.MethodGet && jsonRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) {
|
||||||
return s.withRateLimit(w, r, s.handleSubscribeJSON)
|
return s.withRateLimit(w, r, s.handleSubscribeJSON)
|
||||||
} else if r.Method == http.MethodGet && sseRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && ssePathRegex.MatchString(r.URL.Path) {
|
||||||
return s.withRateLimit(w, r, s.handleSubscribeSSE)
|
return s.withRateLimit(w, r, s.handleSubscribeSSE)
|
||||||
} else if r.Method == http.MethodGet && rawRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && rawPathRegex.MatchString(r.URL.Path) {
|
||||||
return s.withRateLimit(w, r, s.handleSubscribeRaw)
|
return s.withRateLimit(w, r, s.handleSubscribeRaw)
|
||||||
}
|
}
|
||||||
return errHTTPNotFound
|
return errHTTPNotFound
|
||||||
@@ -277,6 +327,17 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
unifiedpush := readParam(r, "x-unifiedpush", "unifiedpush", "up") == "1" // see PUT/POST too!
|
||||||
|
if unifiedpush {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||||
|
_, err := io.WriteString(w, `{"unifiedpush":{"version":1}}`+"\n")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.handleHome(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request) error {
|
func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -296,7 +357,7 @@ func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
t, err := s.topicFromPath(r.URL.Path)
|
t, err := s.topicFromPath(r.URL.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -307,12 +368,20 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, _ *visito
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m := newDefaultMessage(t.ID, strings.TrimSpace(string(b)))
|
m := newDefaultMessage(t.ID, strings.TrimSpace(string(b)))
|
||||||
cache, firebase, err := s.parseParams(r, m)
|
cache, firebase, email, err := s.parsePublishParams(r, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if email != "" {
|
||||||
|
if err := v.EmailAllowed(); err != nil {
|
||||||
|
return errHTTPTooManyRequestsLimitEmails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.mailer == nil && email != "" {
|
||||||
|
return errHTTPBadRequestEmailDisabled
|
||||||
|
}
|
||||||
if m.Message == "" {
|
if m.Message == "" {
|
||||||
m.Message = "triggered"
|
m.Message = emptyMessageBody
|
||||||
}
|
}
|
||||||
delayed := m.Time > time.Now().Unix()
|
delayed := m.Time > time.Now().Unix()
|
||||||
if !delayed {
|
if !delayed {
|
||||||
@@ -327,6 +396,13 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, _ *visito
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
if s.mailer != nil && email != "" && !delayed {
|
||||||
|
go func() {
|
||||||
|
if err := s.mailer.Send(v.ip, email, m); err != nil {
|
||||||
|
log.Printf("Unable to send email: %v", err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
if cache {
|
if cache {
|
||||||
if err := s.cache.AddMessage(m); err != nil {
|
if err := s.cache.AddMessage(m); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -341,9 +417,10 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, _ *visito
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) parseParams(r *http.Request, m *message) (cache bool, firebase bool, err error) {
|
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email string, err error) {
|
||||||
cache = readParam(r, "x-cache", "cache") != "no"
|
cache = readParam(r, "x-cache", "cache") != "no"
|
||||||
firebase = readParam(r, "x-firebase", "firebase") != "no"
|
firebase = readParam(r, "x-firebase", "firebase") != "no"
|
||||||
|
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||||
m.Title = readParam(r, "x-title", "title", "t")
|
m.Title = readParam(r, "x-title", "title", "t")
|
||||||
messageStr := readParam(r, "x-message", "message", "m")
|
messageStr := readParam(r, "x-message", "message", "m")
|
||||||
if messageStr != "" {
|
if messageStr != "" {
|
||||||
@@ -351,7 +428,7 @@ func (s *Server) parseParams(r *http.Request, m *message) (cache bool, firebase
|
|||||||
}
|
}
|
||||||
m.Priority, err = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
m.Priority, err = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, errHTTPBadRequest
|
return false, false, "", errHTTPBadRequestPriorityInvalid
|
||||||
}
|
}
|
||||||
tagsStr := readParam(r, "x-tags", "tags", "tag", "ta")
|
tagsStr := readParam(r, "x-tags", "tags", "tag", "ta")
|
||||||
if tagsStr != "" {
|
if tagsStr != "" {
|
||||||
@@ -363,19 +440,26 @@ func (s *Server) parseParams(r *http.Request, m *message) (cache bool, firebase
|
|||||||
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||||
if delayStr != "" {
|
if delayStr != "" {
|
||||||
if !cache {
|
if !cache {
|
||||||
return false, false, errHTTPBadRequest
|
return false, false, "", errHTTPBadRequestDelayNoCache
|
||||||
|
}
|
||||||
|
if email != "" {
|
||||||
|
return false, false, "", errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
||||||
}
|
}
|
||||||
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, errHTTPBadRequest
|
return false, false, "", errHTTPBadRequestDelayCannotParse
|
||||||
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
|
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
|
||||||
return false, false, errHTTPBadRequest
|
return false, false, "", errHTTPBadRequestDelayTooSmall
|
||||||
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
|
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
|
||||||
return false, false, errHTTPBadRequest
|
return false, false, "", errHTTPBadRequestDelayTooLarge
|
||||||
}
|
}
|
||||||
m.Time = delay.Unix()
|
m.Time = delay.Unix()
|
||||||
}
|
}
|
||||||
return cache, firebase, nil
|
unifiedpush := readParam(r, "x-unifiedpush", "unifiedpush", "up") == "1" // see GET too!
|
||||||
|
if unifiedpush {
|
||||||
|
firebase = false
|
||||||
|
}
|
||||||
|
return cache, firebase, email, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readParam(r *http.Request, names ...string) string {
|
func readParam(r *http.Request, names ...string) string {
|
||||||
@@ -430,8 +514,8 @@ func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *v
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visitor, format string, contentType string, encoder messageEncoder) error {
|
func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visitor, format string, contentType string, encoder messageEncoder) error {
|
||||||
if err := v.AddSubscription(); err != nil {
|
if err := v.SubscriptionAllowed(); err != nil {
|
||||||
return errHTTPTooManyRequests
|
return errHTTPTooManyRequestsLimitSubscriptions
|
||||||
}
|
}
|
||||||
defer v.RemoveSubscription()
|
defer v.RemoveSubscription()
|
||||||
topicsStr := strings.TrimSuffix(r.URL.Path[1:], "/"+format) // Hack
|
topicsStr := strings.TrimSuffix(r.URL.Path[1:], "/"+format) // Hack
|
||||||
@@ -577,7 +661,7 @@ func parseSince(r *http.Request, poll bool) (sinceTime, error) {
|
|||||||
} else if d, err := time.ParseDuration(since); err == nil {
|
} else if d, err := time.ParseDuration(since); err == nil {
|
||||||
return sinceTime(time.Now().Add(-1 * d)), nil
|
return sinceTime(time.Now().Add(-1 * d)), nil
|
||||||
}
|
}
|
||||||
return sinceNoMessages, errHTTPBadRequest
|
return sinceNoMessages, errHTTPBadRequestSinceInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error {
|
func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error {
|
||||||
@@ -589,7 +673,7 @@ func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error {
|
|||||||
func (s *Server) topicFromPath(path string) (*topic, error) {
|
func (s *Server) topicFromPath(path string) (*topic, error) {
|
||||||
parts := strings.Split(path, "/")
|
parts := strings.Split(path, "/")
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
return nil, errHTTPBadRequest
|
return nil, errHTTPBadRequestTopicInvalid
|
||||||
}
|
}
|
||||||
topics, err := s.topicsFromIDs(parts[1])
|
topics, err := s.topicsFromIDs(parts[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -604,11 +688,11 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
|
|||||||
topics := make([]*topic, 0)
|
topics := make([]*topic, 0)
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if util.InStringList(disallowedTopics, id) {
|
if util.InStringList(disallowedTopics, id) {
|
||||||
return nil, errHTTPBadRequest
|
return nil, errHTTPBadRequestTopicDisallowed
|
||||||
}
|
}
|
||||||
if _, ok := s.topics[id]; !ok {
|
if _, ok := s.topics[id]; !ok {
|
||||||
if len(s.topics) >= s.config.GlobalTopicLimit {
|
if len(s.topics) >= s.config.GlobalTopicLimit {
|
||||||
return nil, errHTTPTooManyRequests
|
return nil, errHTTPTooManyRequestsLimitGlobalTopics
|
||||||
}
|
}
|
||||||
s.topics[id] = newTopic(id)
|
s.topics[id] = newTopic(id)
|
||||||
}
|
}
|
||||||
@@ -651,9 +735,44 @@ func (s *Server) updateStatsAndPrune() {
|
|||||||
messages += msgs
|
messages += msgs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mail stats
|
||||||
|
var mailSuccess, mailFailure int64
|
||||||
|
if s.smtpBackend != nil {
|
||||||
|
mailSuccess, mailFailure = s.smtpBackend.Counts()
|
||||||
|
}
|
||||||
|
|
||||||
// Print stats
|
// Print stats
|
||||||
log.Printf("Stats: %d message(s) published, %d topic(s) active, %d subscriber(s), %d message(s) buffered, %d visitor(s)",
|
log.Printf("Stats: %d message(s) published, %d in cache, %d successful mails, %d failed, %d topic(s) active, %d subscriber(s), %d visitor(s)",
|
||||||
s.messages, len(s.topics), subscribers, messages, len(s.visitors))
|
s.messages, messages, mailSuccess, mailFailure, len(s.topics), subscribers, len(s.visitors))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) runSMTPServer() error {
|
||||||
|
sub := func(m *message) error {
|
||||||
|
url := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic)
|
||||||
|
req, err := http.NewRequest("PUT", url, strings.NewReader(m.Message))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if m.Title != "" {
|
||||||
|
req.Header.Set("Title", m.Title)
|
||||||
|
}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handle(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
return errors.New("error: " + rr.Body.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.smtpBackend = newMailBackend(s.config, sub)
|
||||||
|
s.smtpServer = smtp.NewServer(s.smtpBackend)
|
||||||
|
s.smtpServer.Addr = s.config.SMTPServerListen
|
||||||
|
s.smtpServer.Domain = s.config.SMTPServerDomain
|
||||||
|
s.smtpServer.ReadTimeout = 10 * time.Second
|
||||||
|
s.smtpServer.WriteTimeout = 10 * time.Second
|
||||||
|
s.smtpServer.MaxMessageBytes = 1024 * 1024 // Must be much larger than message size (headers, multipart, etc.)
|
||||||
|
s.smtpServer.MaxRecipients = 1
|
||||||
|
s.smtpServer.AllowInsecureAuth = true
|
||||||
|
return s.smtpServer.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) runManager() {
|
func (s *Server) runManager() {
|
||||||
@@ -714,6 +833,7 @@ func (s *Server) sendDelayedMessages() error {
|
|||||||
log.Printf("unable to publish to Firebase: %v", err.Error())
|
log.Printf("unable to publish to Firebase: %v", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// TODO delayed email sending
|
||||||
}
|
}
|
||||||
if err := s.cache.MarkPublished(m); err != nil {
|
if err := s.cache.MarkPublished(m); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -725,7 +845,7 @@ func (s *Server) sendDelayedMessages() error {
|
|||||||
func (s *Server) withRateLimit(w http.ResponseWriter, r *http.Request, handler func(w http.ResponseWriter, r *http.Request, v *visitor) error) error {
|
func (s *Server) withRateLimit(w http.ResponseWriter, r *http.Request, handler func(w http.ResponseWriter, r *http.Request, v *visitor) error) error {
|
||||||
v := s.visitor(r)
|
v := s.visitor(r)
|
||||||
if err := v.RequestAllowed(); err != nil {
|
if err := v.RequestAllowed(); err != nil {
|
||||||
return err
|
return errHTTPTooManyRequestsLimitRequests
|
||||||
}
|
}
|
||||||
return handler(w, r, v)
|
return handler(w, r, v)
|
||||||
}
|
}
|
||||||
@@ -745,7 +865,7 @@ func (s *Server) visitor(r *http.Request) *visitor {
|
|||||||
}
|
}
|
||||||
v, exists := s.visitors[ip]
|
v, exists := s.visitors[ip]
|
||||||
if !exists {
|
if !exists {
|
||||||
s.visitors[ip] = newVisitor(s.config)
|
s.visitors[ip] = newVisitor(s.config, ip)
|
||||||
return s.visitors[ip]
|
return s.visitors[ip]
|
||||||
}
|
}
|
||||||
v.Keepalive()
|
v.Keepalive()
|
||||||
@@ -757,9 +877,3 @@ func (s *Server) inc(counter *int64) {
|
|||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
*counter++
|
*counter++
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) fail(w http.ResponseWriter, r *http.Request, code int, err error) {
|
|
||||||
log.Printf("[%s] %s - %d - %s", r.RemoteAddr, r.Method, code, err.Error())
|
|
||||||
w.WriteHeader(code)
|
|
||||||
_, _ = io.WriteString(w, fmt.Sprintf("%s\n", http.StatusText(code)))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
# ntfy server config file
|
# ntfy server config file
|
||||||
|
|
||||||
|
# Public facing base URL of the service (e.g. https://ntfy.sh or https://ntfy.example.com)
|
||||||
|
# This setting is currently only used by the e-mail sending feature (outgoing mail only).
|
||||||
|
#
|
||||||
|
# base-url:
|
||||||
|
|
||||||
# Listen address for the HTTP & HTTPS web server. If "listen-https" is set, you must also
|
# Listen address for the HTTP & HTTPS web server. If "listen-https" is set, you must also
|
||||||
# set "key-file" and "cert-file".
|
# set "key-file" and "cert-file". Format: <hostname>:<port>
|
||||||
# Format: <hostname>:<port>
|
|
||||||
#
|
#
|
||||||
# listen-http: ":80"
|
# listen-http: ":80"
|
||||||
# listen-https:
|
# listen-https:
|
||||||
@@ -34,6 +38,41 @@
|
|||||||
#
|
#
|
||||||
# cache-duration: 12h
|
# cache-duration: 12h
|
||||||
|
|
||||||
|
# If set, the X-Forwarded-For header is used to determine the visitor IP address
|
||||||
|
# instead of the remote address of the connection.
|
||||||
|
#
|
||||||
|
# WARNING: If you are behind a proxy, you must set this, otherwise all visitors are rate limited
|
||||||
|
# as if they are one.
|
||||||
|
#
|
||||||
|
# behind-proxy: false
|
||||||
|
|
||||||
|
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
|
||||||
|
# messages will additionally be sent out as e-mail using an external SMTP server. As of today, only
|
||||||
|
# SMTP servers with plain text auth and STARTLS are supported. Please also refer to the rate limiting settings
|
||||||
|
# below (visitor-email-limit-burst & visitor-email-limit-burst).
|
||||||
|
#
|
||||||
|
# - smtp-sender-addr is the hostname:port of the SMTP server
|
||||||
|
# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user
|
||||||
|
# - smtp-sender-from is the e-mail address of the sender
|
||||||
|
#
|
||||||
|
# smtp-sender-addr:
|
||||||
|
# smtp-sender-user:
|
||||||
|
# smtp-sender-pass:
|
||||||
|
# smtp-sender-from:
|
||||||
|
|
||||||
|
# If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send
|
||||||
|
# emails to a topic e-mail address to publish messages to a topic.
|
||||||
|
#
|
||||||
|
# - smtp-server-listen defines the IP address and port the SMTP server will listen on, e.g. :25 or 1.2.3.4:25
|
||||||
|
# - smtp-server-domain is the e-mail domain, e.g. ntfy.sh
|
||||||
|
# - smtp-server-addr-prefix is an optional prefix for the e-mail addresses to prevent spam. If set to "ntfy-",
|
||||||
|
# for instance, only e-mails to ntfy-$topic@ntfy.sh will be accepted. If this is not set, all emails to
|
||||||
|
# $topic@ntfy.sh will be accepted (which may obviously be a spam problem).
|
||||||
|
#
|
||||||
|
# smtp-server-listen:
|
||||||
|
# smtp-server-domain:
|
||||||
|
# smtp-server-addr-prefix:
|
||||||
|
|
||||||
# Interval in which keepalive messages are sent to the client. This is to prevent
|
# Interval in which keepalive messages are sent to the client. This is to prevent
|
||||||
# intermediaries closing the connection for inactivity.
|
# intermediaries closing the connection for inactivity.
|
||||||
#
|
#
|
||||||
@@ -61,10 +100,9 @@
|
|||||||
# visitor-request-limit-burst: 60
|
# visitor-request-limit-burst: 60
|
||||||
# visitor-request-limit-replenish: 10s
|
# visitor-request-limit-replenish: 10s
|
||||||
|
|
||||||
# If set, the X-Forwarded-For header is used to determine the visitor IP address
|
# Rate limiting: Allowed emails per visitor:
|
||||||
# instead of the remote address of the connection.
|
# - visitor-email-limit-burst is the initial bucket of emails each visitor has
|
||||||
|
# - visitor-email-limit-replenish is the rate at which the bucket is refilled
|
||||||
#
|
#
|
||||||
# WARNING: If you are behind a proxy, you must set this, otherwise all visitors are rate limited
|
# visitor-email-limit-burst: 16
|
||||||
# as if they are one.
|
# visitor-email-limit-replenish: 1h
|
||||||
#
|
|
||||||
# behind-proxy: false
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -163,12 +164,13 @@ func TestServer_StaticSites(t *testing.T) {
|
|||||||
func TestServer_PublishLargeMessage(t *testing.T) {
|
func TestServer_PublishLargeMessage(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
body := strings.Repeat("this is a large message", 1000)
|
body := strings.Repeat("this is a large message", 5000)
|
||||||
truncated := body[0:512]
|
truncated := body[0:4096]
|
||||||
response := request(t, s, "PUT", "/mytopic", body, nil)
|
response := request(t, s, "PUT", "/mytopic", body, nil)
|
||||||
msg := toMessage(t, response.Body.String())
|
msg := toMessage(t, response.Body.String())
|
||||||
require.NotEmpty(t, msg.ID)
|
require.NotEmpty(t, msg.ID)
|
||||||
require.Equal(t, truncated, msg.Message)
|
require.Equal(t, truncated, msg.Message)
|
||||||
|
require.Equal(t, 4096, len(msg.Message))
|
||||||
|
|
||||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||||
messages := toMessages(t, response.Body.String())
|
messages := toMessages(t, response.Body.String())
|
||||||
@@ -251,6 +253,7 @@ func TestServer_PublishAtWithCacheError(t *testing.T) {
|
|||||||
"In": "30 min",
|
"In": "30 min",
|
||||||
})
|
})
|
||||||
require.Equal(t, 400, response.Code)
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, errHTTPBadRequestDelayNoCache, toHTTPError(t, response.Body.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishAtTooShortDelay(t *testing.T) {
|
func TestServer_PublishAtTooShortDelay(t *testing.T) {
|
||||||
@@ -508,6 +511,86 @@ func TestServer_Curl_Publish_Poll(t *testing.T) {
|
|||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
type testMailer struct {
|
||||||
|
count int
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testMailer) Send(from, to string, m *message) error {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.count++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
s.mailer = &testMailer{}
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
||||||
|
"E-Mail": "test@example.com",
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "one too many", map[string]string{
|
||||||
|
"E-Mail": "test@example.com",
|
||||||
|
})
|
||||||
|
require.Equal(t, 429, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.VisitorEmailLimitReplenish = 500 * time.Millisecond
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
s.mailer = &testMailer{}
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
||||||
|
"E-Mail": "test@example.com",
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "one too many", map[string]string{
|
||||||
|
"E-Mail": "test@example.com",
|
||||||
|
})
|
||||||
|
require.Equal(t, 429, response.Code)
|
||||||
|
|
||||||
|
time.Sleep(510 * time.Millisecond)
|
||||||
|
response = request(t, s, "PUT", "/mytopic", "this should be okay again too many", map[string]string{
|
||||||
|
"E-Mail": "test@example.com",
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
response = request(t, s, "PUT", "/mytopic", "and bad again", map[string]string{
|
||||||
|
"E-Mail": "test@example.com",
|
||||||
|
})
|
||||||
|
require.Equal(t, 429, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
s.mailer = &testMailer{}
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
|
||||||
|
"E-Mail": "test@example.com",
|
||||||
|
"Delay": "20 min",
|
||||||
|
})
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
|
||||||
|
"E-Mail": "test@example.com",
|
||||||
|
})
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_UnifiedPushDiscovery(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "GET", "/mytopic?up=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, `{"unifiedpush":{"version":1}}`+"\n", response.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
func newTestConfig(t *testing.T) *Config {
|
func newTestConfig(t *testing.T) *Config {
|
||||||
conf := NewConfig()
|
conf := NewConfig()
|
||||||
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
||||||
@@ -570,6 +653,12 @@ func toMessage(t *testing.T, s string) *message {
|
|||||||
return &m
|
return &m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toHTTPError(t *testing.T, s string) *errHTTP {
|
||||||
|
var e errHTTP
|
||||||
|
require.Nil(t, json.NewDecoder(strings.NewReader(s)).Decode(&e))
|
||||||
|
return &e
|
||||||
|
}
|
||||||
|
|
||||||
func firebaseServiceAccountFile(t *testing.T) string {
|
func firebaseServiceAccountFile(t *testing.T) string {
|
||||||
if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") != "" {
|
if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") != "" {
|
||||||
return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE")
|
return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE")
|
||||||
|
|||||||
119
server/smtp_sender.go
Normal file
119
server/smtp_sender.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed" // required by go:embed
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"mime"
|
||||||
|
"net"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mailer interface {
|
||||||
|
Send(from, to string, m *message) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type smtpSender struct {
|
||||||
|
config *Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSender) Send(senderIP, to string, m *message) error {
|
||||||
|
host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
message, err := formatMail(s.config.BaseURL, senderIP, s.config.SMTPSenderFrom, to, m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
|
||||||
|
return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) {
|
||||||
|
topicURL := baseURL + "/" + m.Topic
|
||||||
|
subject := m.Title
|
||||||
|
if subject == "" {
|
||||||
|
subject = m.Message
|
||||||
|
}
|
||||||
|
subject = strings.ReplaceAll(strings.ReplaceAll(subject, "\r", ""), "\n", " ")
|
||||||
|
message := m.Message
|
||||||
|
trailer := ""
|
||||||
|
if len(m.Tags) > 0 {
|
||||||
|
emojis, tags, err := toEmojis(m.Tags)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(emojis) > 0 {
|
||||||
|
subject = strings.Join(emojis, " ") + " " + subject
|
||||||
|
}
|
||||||
|
if len(tags) > 0 {
|
||||||
|
trailer = "Tags: " + strings.Join(tags, ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Priority != 0 && m.Priority != 3 {
|
||||||
|
priority, err := util.PriorityString(m.Priority)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if trailer != "" {
|
||||||
|
trailer += "\n"
|
||||||
|
}
|
||||||
|
trailer += fmt.Sprintf("Priority: %s", priority)
|
||||||
|
}
|
||||||
|
if trailer != "" {
|
||||||
|
message += "\n\n" + trailer
|
||||||
|
}
|
||||||
|
subject = mime.BEncoding.Encode("utf-8", subject)
|
||||||
|
body := `From: "{shortTopicURL}" <{from}>
|
||||||
|
To: {to}
|
||||||
|
Subject: {subject}
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
|
{message}
|
||||||
|
|
||||||
|
--
|
||||||
|
This message was sent by {ip} at {time} via {topicURL}`
|
||||||
|
body = strings.ReplaceAll(body, "{from}", from)
|
||||||
|
body = strings.ReplaceAll(body, "{to}", to)
|
||||||
|
body = strings.ReplaceAll(body, "{subject}", subject)
|
||||||
|
body = strings.ReplaceAll(body, "{message}", message)
|
||||||
|
body = strings.ReplaceAll(body, "{topicURL}", topicURL)
|
||||||
|
body = strings.ReplaceAll(body, "{shortTopicURL}", util.ShortTopicURL(topicURL))
|
||||||
|
body = strings.ReplaceAll(body, "{time}", time.Unix(m.Time, 0).UTC().Format(time.RFC1123))
|
||||||
|
body = strings.ReplaceAll(body, "{ip}", senderIP)
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed "mailer_emoji.json"
|
||||||
|
emojisJSON string
|
||||||
|
)
|
||||||
|
|
||||||
|
type emoji struct {
|
||||||
|
Emoji string `json:"emoji"`
|
||||||
|
Aliases []string `json:"aliases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) {
|
||||||
|
var emojis []emoji
|
||||||
|
if err = json.Unmarshal([]byte(emojisJSON), &emojis); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
tagsOut = make([]string, 0)
|
||||||
|
emojisOut = make([]string, 0)
|
||||||
|
nextTag:
|
||||||
|
for _, t := range tags { // TODO Super inefficient; we should just create a .json file with a map
|
||||||
|
for _, e := range emojis {
|
||||||
|
if util.InStringList(e.Aliases, t) {
|
||||||
|
emojisOut = append(emojisOut, e.Emoji)
|
||||||
|
continue nextTag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tagsOut = append(tagsOut, t)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
141
server/smtp_sender_test.go
Normal file
141
server/smtp_sender_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatMail_Basic(t *testing.T) {
|
||||||
|
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||||
|
ID: "abc",
|
||||||
|
Time: 1640382204,
|
||||||
|
Event: "message",
|
||||||
|
Topic: "alerts",
|
||||||
|
Message: "A simple message",
|
||||||
|
})
|
||||||
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
|
To: phil@example.com
|
||||||
|
Subject: A simple message
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
|
A simple message
|
||||||
|
|
||||||
|
--
|
||||||
|
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||||
|
require.Equal(t, expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatMail_JustEmojis(t *testing.T) {
|
||||||
|
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||||
|
ID: "abc",
|
||||||
|
Time: 1640382204,
|
||||||
|
Event: "message",
|
||||||
|
Topic: "alerts",
|
||||||
|
Message: "A simple message",
|
||||||
|
Tags: []string{"grinning"},
|
||||||
|
})
|
||||||
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
|
To: phil@example.com
|
||||||
|
Subject: =?utf-8?b?8J+YgCBBIHNpbXBsZSBtZXNzYWdl?=
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
|
A simple message
|
||||||
|
|
||||||
|
--
|
||||||
|
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||||
|
require.Equal(t, expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatMail_JustOtherTags(t *testing.T) {
|
||||||
|
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||||
|
ID: "abc",
|
||||||
|
Time: 1640382204,
|
||||||
|
Event: "message",
|
||||||
|
Topic: "alerts",
|
||||||
|
Message: "A simple message",
|
||||||
|
Tags: []string{"not-an-emoji"},
|
||||||
|
})
|
||||||
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
|
To: phil@example.com
|
||||||
|
Subject: A simple message
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
|
A simple message
|
||||||
|
|
||||||
|
Tags: not-an-emoji
|
||||||
|
|
||||||
|
--
|
||||||
|
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||||
|
require.Equal(t, expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatMail_JustPriority(t *testing.T) {
|
||||||
|
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||||
|
ID: "abc",
|
||||||
|
Time: 1640382204,
|
||||||
|
Event: "message",
|
||||||
|
Topic: "alerts",
|
||||||
|
Message: "A simple message",
|
||||||
|
Priority: 2,
|
||||||
|
})
|
||||||
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
|
To: phil@example.com
|
||||||
|
Subject: A simple message
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
|
A simple message
|
||||||
|
|
||||||
|
Priority: low
|
||||||
|
|
||||||
|
--
|
||||||
|
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||||
|
require.Equal(t, expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatMail_UTF8Subject(t *testing.T) {
|
||||||
|
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||||
|
ID: "abc",
|
||||||
|
Time: 1640382204,
|
||||||
|
Event: "message",
|
||||||
|
Topic: "alerts",
|
||||||
|
Message: "A simple message",
|
||||||
|
Title: " :: A not so simple title öäüß ¡Hola, señor!",
|
||||||
|
})
|
||||||
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
|
To: phil@example.com
|
||||||
|
Subject: =?utf-8?b?IDo6IEEgbm90IHNvIHNpbXBsZSB0aXRsZSDDtsOkw7zDnyDCoUhvbGEsIHNl?= =?utf-8?b?w7FvciE=?=
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
|
A simple message
|
||||||
|
|
||||||
|
--
|
||||||
|
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||||
|
require.Equal(t, expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatMail_WithAllTheThings(t *testing.T) {
|
||||||
|
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||||
|
ID: "abc",
|
||||||
|
Time: 1640382204,
|
||||||
|
Event: "message",
|
||||||
|
Topic: "alerts",
|
||||||
|
Priority: 5,
|
||||||
|
Tags: []string{"warning", "skull", "tag123", "other"},
|
||||||
|
Title: "Oh no 🙈\nThis is a message across\nmultiple lines",
|
||||||
|
Message: "A message that contains monkeys 🙉\nNo really, though. Monkeys!",
|
||||||
|
})
|
||||||
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
|
To: phil@example.com
|
||||||
|
Subject: =?utf-8?b?4pqg77iPIPCfkoAgT2ggbm8g8J+ZiCBUaGlzIGlzIGEgbWVzc2FnZSBhY3Jv?= =?utf-8?b?c3MgbXVsdGlwbGUgbGluZXM=?=
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
|
A message that contains monkeys 🙉
|
||||||
|
No really, though. Monkeys!
|
||||||
|
|
||||||
|
Tags: tag123, other
|
||||||
|
Priority: max
|
||||||
|
|
||||||
|
--
|
||||||
|
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||||
|
require.Equal(t, expected, actual)
|
||||||
|
}
|
||||||
195
server/smtp_server.go
Normal file
195
server/smtp_server.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"github.com/emersion/go-smtp"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInvalidDomain = errors.New("invalid domain")
|
||||||
|
errInvalidAddress = errors.New("invalid address")
|
||||||
|
errInvalidTopic = errors.New("invalid topic")
|
||||||
|
errTooManyRecipients = errors.New("too many recipients")
|
||||||
|
errUnsupportedContentType = errors.New("unsupported content type")
|
||||||
|
)
|
||||||
|
|
||||||
|
// smtpBackend implements SMTP server methods.
|
||||||
|
type smtpBackend struct {
|
||||||
|
config *Config
|
||||||
|
sub subscriber
|
||||||
|
success int64
|
||||||
|
failure int64
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMailBackend(conf *Config, sub subscriber) *smtpBackend {
|
||||||
|
return &smtpBackend{
|
||||||
|
config: conf,
|
||||||
|
sub: sub,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
|
||||||
|
return &smtpSession{backend: b}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
|
||||||
|
return &smtpSession{backend: b}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *smtpBackend) Counts() (success int64, failure int64) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.success, b.failure
|
||||||
|
}
|
||||||
|
|
||||||
|
// smtpSession is returned after EHLO.
|
||||||
|
type smtpSession struct {
|
||||||
|
backend *smtpBackend
|
||||||
|
topic string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSession) AuthPlain(username, password string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSession) Rcpt(to string) error {
|
||||||
|
return s.withFailCount(func() error {
|
||||||
|
conf := s.backend.config
|
||||||
|
addressList, err := mail.ParseAddressList(to)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if len(addressList) != 1 {
|
||||||
|
return errTooManyRecipients
|
||||||
|
}
|
||||||
|
to = addressList[0].Address
|
||||||
|
if !strings.HasSuffix(to, "@"+conf.SMTPServerDomain) {
|
||||||
|
return errInvalidDomain
|
||||||
|
}
|
||||||
|
to = strings.TrimSuffix(to, "@"+conf.SMTPServerDomain)
|
||||||
|
if conf.SMTPServerAddrPrefix != "" {
|
||||||
|
if !strings.HasPrefix(to, conf.SMTPServerAddrPrefix) {
|
||||||
|
return errInvalidAddress
|
||||||
|
}
|
||||||
|
to = strings.TrimPrefix(to, conf.SMTPServerAddrPrefix)
|
||||||
|
}
|
||||||
|
if !topicRegex.MatchString(to) {
|
||||||
|
return errInvalidTopic
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.topic = to
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSession) Data(r io.Reader) error {
|
||||||
|
return s.withFailCount(func() error {
|
||||||
|
conf := s.backend.config
|
||||||
|
b, err := io.ReadAll(r) // Protected by MaxMessageBytes
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msg, err := mail.ReadMessage(bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := readMailBody(msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body = strings.TrimSpace(body)
|
||||||
|
if len(body) > conf.MessageLimit {
|
||||||
|
body = body[:conf.MessageLimit]
|
||||||
|
}
|
||||||
|
m := newDefaultMessage(s.topic, body)
|
||||||
|
subject := strings.TrimSpace(msg.Header.Get("Subject"))
|
||||||
|
if subject != "" {
|
||||||
|
dec := mime.WordDecoder{}
|
||||||
|
subject, err := dec.DecodeHeader(subject)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Title = subject
|
||||||
|
}
|
||||||
|
if m.Title != "" && m.Message == "" {
|
||||||
|
m.Message = m.Title // Flip them, this makes more sense
|
||||||
|
m.Title = ""
|
||||||
|
}
|
||||||
|
if err := s.backend.sub(m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.backend.mu.Lock()
|
||||||
|
s.backend.success++
|
||||||
|
s.backend.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSession) Reset() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.topic = ""
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSession) Logout() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSession) withFailCount(fn func() error) error {
|
||||||
|
err := fn()
|
||||||
|
s.backend.mu.Lock()
|
||||||
|
defer s.backend.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
s.backend.failure++
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMailBody(msg *mail.Message) (string, error) {
|
||||||
|
contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if contentType == "text/plain" {
|
||||||
|
body, err := io.ReadAll(msg.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(contentType, "multipart/") {
|
||||||
|
mr := multipart.NewReader(msg.Body, params["boundary"])
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err != nil { // may be io.EOF
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if partContentType != "text/plain" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(part)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errUnsupportedContentType
|
||||||
|
}
|
||||||
290
server/smtp_server_test.go
Normal file
290
server/smtp_server_test.go
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/emersion/go-smtp"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSmtpBackend_Multipart(t *testing.T) {
|
||||||
|
email := `MIME-Version: 1.0
|
||||||
|
Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||||
|
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||||
|
Subject: and one more
|
||||||
|
From: Phil <phil@example.com>
|
||||||
|
To: ntfy-mytopic@ntfy.sh
|
||||||
|
Content-Type: multipart/alternative; boundary="000000000000f3320b05d42915c9"
|
||||||
|
|
||||||
|
--000000000000f3320b05d42915c9
|
||||||
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
|
what's up
|
||||||
|
|
||||||
|
--000000000000f3320b05d42915c9
|
||||||
|
Content-Type: text/html; charset="UTF-8"
|
||||||
|
|
||||||
|
<div dir="ltr">what's up<br clear="all"><div><br></div></div>
|
||||||
|
|
||||||
|
--000000000000f3320b05d42915c9--`
|
||||||
|
_, backend := newTestBackend(t, func(m *message) error {
|
||||||
|
require.Equal(t, "mytopic", m.Topic)
|
||||||
|
require.Equal(t, "and one more", m.Title)
|
||||||
|
require.Equal(t, "what's up", m.Message)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
session, _ := backend.AnonymousLogin(nil)
|
||||||
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
|
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
|
||||||
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmtpBackend_MultipartNoBody(t *testing.T) {
|
||||||
|
email := `MIME-Version: 1.0
|
||||||
|
Date: Tue, 28 Dec 2021 01:33:34 +0100
|
||||||
|
Message-ID: <CAAvm7ABCDsi9vsuu0WTRXzZQBC8dXrDOLT8iCWdqrsmg@mail.gmail.com>
|
||||||
|
Subject: This email has a subject but no body
|
||||||
|
From: Phil <phil@example.com>
|
||||||
|
To: ntfy-emailtest@ntfy.sh
|
||||||
|
Content-Type: multipart/alternative; boundary="000000000000bcf4a405d429f8d4"
|
||||||
|
|
||||||
|
--000000000000bcf4a405d429f8d4
|
||||||
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
--000000000000bcf4a405d429f8d4
|
||||||
|
Content-Type: text/html; charset="UTF-8"
|
||||||
|
|
||||||
|
<div dir="ltr"><br></div>
|
||||||
|
|
||||||
|
--000000000000bcf4a405d429f8d4--`
|
||||||
|
_, backend := newTestBackend(t, func(m *message) error {
|
||||||
|
require.Equal(t, "emailtest", m.Topic)
|
||||||
|
require.Equal(t, "", m.Title) // We flipped message and body
|
||||||
|
require.Equal(t, "This email has a subject but no body", m.Message)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
session, _ := backend.AnonymousLogin(nil)
|
||||||
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
|
require.Nil(t, session.Rcpt("ntfy-emailtest@ntfy.sh"))
|
||||||
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmtpBackend_Plaintext(t *testing.T) {
|
||||||
|
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||||
|
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||||
|
Subject: and one more
|
||||||
|
From: Phil <phil@example.com>
|
||||||
|
To: mytopic@ntfy.sh
|
||||||
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
|
what's up
|
||||||
|
`
|
||||||
|
conf, backend := newTestBackend(t, func(m *message) error {
|
||||||
|
require.Equal(t, "mytopic", m.Topic)
|
||||||
|
require.Equal(t, "and one more", m.Title)
|
||||||
|
require.Equal(t, "what's up", m.Message)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
conf.SMTPServerAddrPrefix = ""
|
||||||
|
session, _ := backend.AnonymousLogin(nil)
|
||||||
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
|
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||||
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmtpBackend_Plaintext_EncodedSubject(t *testing.T) {
|
||||||
|
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||||
|
Subject: =?UTF-8?B?VGhyZWUgc2FudGFzIPCfjoXwn46F8J+OhQ==?=
|
||||||
|
From: Phil <phil@example.com>
|
||||||
|
To: ntfy-mytopic@ntfy.sh
|
||||||
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
|
what's up
|
||||||
|
`
|
||||||
|
_, backend := newTestBackend(t, func(m *message) error {
|
||||||
|
require.Equal(t, "Three santas 🎅🎅🎅", m.Title)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
session, _ := backend.AnonymousLogin(nil)
|
||||||
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
|
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
|
||||||
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmtpBackend_Plaintext_TooLongTruncate(t *testing.T) {
|
||||||
|
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||||
|
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||||
|
Subject: and one more
|
||||||
|
From: Phil <phil@example.com>
|
||||||
|
To: mytopic@ntfy.sh
|
||||||
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
|
you know this is a string.
|
||||||
|
it's a long string.
|
||||||
|
it's supposed to be longer than the max message length
|
||||||
|
which is 4096 bytes,
|
||||||
|
it used to be 512 bytes, but I increased that for the UnifiedPush support
|
||||||
|
the 512 bytes was a little short, some people said
|
||||||
|
but it kinda makes sense when you look at what it looks like one a phone
|
||||||
|
heck this wasn't even half of it so far.
|
||||||
|
so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
|
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
|
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
|
that should do it
|
||||||
|
`
|
||||||
|
conf, backend := newTestBackend(t, func(m *message) error {
|
||||||
|
expected := `you know this is a string.
|
||||||
|
it's a long string.
|
||||||
|
it's supposed to be longer than the max message length
|
||||||
|
which is 4096 bytes,
|
||||||
|
it used to be 512 bytes, but I increased that for the UnifiedPush support
|
||||||
|
the 512 bytes was a little short, some people said
|
||||||
|
but it kinda makes sense when you look at what it looks like one a phone
|
||||||
|
heck this wasn't even half of it so far.
|
||||||
|
so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
|
BBBBBBBBBBBBBBBBBBBBBBBB`
|
||||||
|
require.Equal(t, 4096, len(expected)) // Sanity check
|
||||||
|
require.Equal(t, expected, m.Message)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
conf.SMTPServerAddrPrefix = ""
|
||||||
|
session, _ := backend.AnonymousLogin(nil)
|
||||||
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
|
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||||
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmtpBackend_Unsupported(t *testing.T) {
|
||||||
|
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||||
|
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||||
|
Subject: and one more
|
||||||
|
From: Phil <phil@example.com>
|
||||||
|
To: mytopic@ntfy.sh
|
||||||
|
Content-Type: text/SOMETHINGELSE
|
||||||
|
|
||||||
|
what's up
|
||||||
|
`
|
||||||
|
conf, backend := newTestBackend(t, func(m *message) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
conf.SMTPServerAddrPrefix = ""
|
||||||
|
session, _ := backend.Login(nil, "user", "pass")
|
||||||
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
|
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||||
|
require.Equal(t, errUnsupportedContentType, session.Data(strings.NewReader(email)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestBackend(t *testing.T, sub subscriber) (*Config, *smtpBackend) {
|
||||||
|
conf := newTestConfig(t)
|
||||||
|
conf.SMTPServerListen = ":25"
|
||||||
|
conf.SMTPServerDomain = "ntfy.sh"
|
||||||
|
conf.SMTPServerAddrPrefix = "ntfy-"
|
||||||
|
backend := newMailBackend(conf, sub)
|
||||||
|
return conf, backend
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -8,39 +9,61 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
visitorExpungeAfter = 30 * time.Minute
|
// visitorExpungeAfter defines how long a visitor is active before it is removed from memory. This number
|
||||||
|
// has to be very high to prevent e-mail abuse, but it doesn't really affect the other limits anyway, since
|
||||||
|
// they are replenished faster (typically).
|
||||||
|
visitorExpungeAfter = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errVisitorLimitReached = errors.New("limit reached")
|
||||||
)
|
)
|
||||||
|
|
||||||
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
|
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
|
||||||
type visitor struct {
|
type visitor struct {
|
||||||
config *Config
|
config *Config
|
||||||
limiter *rate.Limiter
|
ip string
|
||||||
|
requests *rate.Limiter
|
||||||
|
emails *rate.Limiter
|
||||||
subscriptions *util.Limiter
|
subscriptions *util.Limiter
|
||||||
seen time.Time
|
seen time.Time
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func newVisitor(conf *Config) *visitor {
|
func newVisitor(conf *Config, ip string) *visitor {
|
||||||
return &visitor{
|
return &visitor{
|
||||||
config: conf,
|
config: conf,
|
||||||
limiter: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
|
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)),
|
subscriptions: util.NewLimiter(int64(conf.VisitorSubscriptionLimit)),
|
||||||
seen: time.Now(),
|
seen: time.Now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *visitor) IP() string {
|
||||||
|
return v.ip
|
||||||
|
}
|
||||||
|
|
||||||
func (v *visitor) RequestAllowed() error {
|
func (v *visitor) RequestAllowed() error {
|
||||||
if !v.limiter.Allow() {
|
if !v.requests.Allow() {
|
||||||
return errHTTPTooManyRequests
|
return errVisitorLimitReached
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) AddSubscription() error {
|
func (v *visitor) EmailAllowed() error {
|
||||||
|
if !v.emails.Allow() {
|
||||||
|
return errVisitorLimitReached
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *visitor) SubscriptionAllowed() error {
|
||||||
v.mu.Lock()
|
v.mu.Lock()
|
||||||
defer v.mu.Unlock()
|
defer v.mu.Unlock()
|
||||||
if err := v.subscriptions.Add(1); err != nil {
|
if err := v.subscriptions.Add(1); err != nil {
|
||||||
return errHTTPTooManyRequests
|
return errVisitorLimitReached
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rand.Seed(time.Now().Unix())
|
rand.Seed(time.Now().UnixMilli())
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartServer starts a server.Server with a random port and waits for the server to be up
|
// 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) {
|
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)
|
port := 10000 + rand.Intn(20000)
|
||||||
conf := server.NewConfig()
|
|
||||||
conf.ListenHTTP = fmt.Sprintf(":%d", port)
|
conf.ListenHTTP = fmt.Sprintf(":%d", port)
|
||||||
s, err := server.New(conf)
|
s, err := server.New(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
25
util/util.go
25
util/util.go
@@ -134,7 +134,32 @@ func ParsePriority(priority string) (int, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PriorityString converts a priority number to a string
|
||||||
|
func PriorityString(priority int) (string, error) {
|
||||||
|
switch priority {
|
||||||
|
case 0:
|
||||||
|
return "default", nil
|
||||||
|
case 1:
|
||||||
|
return "min", nil
|
||||||
|
case 2:
|
||||||
|
return "low", nil
|
||||||
|
case 3:
|
||||||
|
return "default", nil
|
||||||
|
case 4:
|
||||||
|
return "high", nil
|
||||||
|
case 5:
|
||||||
|
return "max", nil
|
||||||
|
default:
|
||||||
|
return "", errInvalidPriority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ExpandHome replaces "~" with the user's home directory
|
// ExpandHome replaces "~" with the user's home directory
|
||||||
func ExpandHome(path string) string {
|
func ExpandHome(path string) string {
|
||||||
return os.ExpandEnv(strings.ReplaceAll(path, "~", "$HOME"))
|
return os.ExpandEnv(strings.ReplaceAll(path, "~", "$HOME"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShortTopicURL shortens the topic URL to be human-friendly, removing the http:// or https://
|
||||||
|
func ShortTopicURL(s string) string {
|
||||||
|
return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://")
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,3 +100,24 @@ func TestParsePriority_Invalid(t *testing.T) {
|
|||||||
require.Equal(t, errInvalidPriority, err)
|
require.Equal(t, errInvalidPriority, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPriorityString(t *testing.T) {
|
||||||
|
priorities := []int{0, 1, 2, 3, 4, 5}
|
||||||
|
expected := []string{"default", "min", "low", "default", "high", "max"}
|
||||||
|
for i, priority := range priorities {
|
||||||
|
actual, err := PriorityString(priority)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, expected[i], actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPriorityString_Invalid(t *testing.T) {
|
||||||
|
_, err := PriorityString(99)
|
||||||
|
require.Equal(t, err, errInvalidPriority)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShortTopicURL(t *testing.T) {
|
||||||
|
require.Equal(t, "ntfy.sh/mytopic", ShortTopicURL("https://ntfy.sh/mytopic"))
|
||||||
|
require.Equal(t, "ntfy.sh/mytopic", ShortTopicURL("http://ntfy.sh/mytopic"))
|
||||||
|
require.Equal(t, "lalala", ShortTopicURL("lalala"))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user