Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46c0039a16 | ||
|
|
d5497908bb | ||
|
|
dac88391c1 | ||
|
|
a46a520bca | ||
|
|
04719f8dee | ||
|
|
113053a9e3 | ||
|
|
7cfe909644 | ||
|
|
01a1d981cf | ||
|
|
e7f8fc93e4 | ||
|
|
b45ca6f2c0 | ||
|
|
be17294dc2 | ||
|
|
7eaa92cb20 | ||
|
|
3001e57bcc | ||
|
|
43a2acb756 | ||
|
|
bcc424f2aa | ||
|
|
ec7e58a6a2 | ||
|
|
9a0f1f22b8 | ||
|
|
d6762276f5 | ||
|
|
41514cd557 | ||
|
|
63a29380a9 | ||
|
|
eeb378cfdc | ||
|
|
7a23779d07 | ||
|
|
29628a66a6 |
@@ -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)
|
||||||
|
|||||||
39
cmd/serve.go
39
cmd/serve.go
@@ -22,10 +22,13 @@ 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-addr", EnvVars: []string{"NTFY_SMTP_ADDR"}, Usage: "SMTP server address (host:port) to allow email sending"}),
|
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-user", EnvVars: []string{"NTFY_SMTP_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
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-pass", EnvVars: []string{"NTFY_SMTP_PASS"}, Usage: "SMTP password (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-from", EnvVars: []string{"NTFY_SMTP_FROM"}, Usage: "SMTP sender address (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", 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", 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"}),
|
||||||
@@ -68,10 +71,13 @@ 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")
|
||||||
smtpAddr := c.String("smtp-addr")
|
smtpSenderAddr := c.String("smtp-sender-addr")
|
||||||
smtpUser := c.String("smtp-user")
|
smtpSenderUser := c.String("smtp-sender-user")
|
||||||
smtpPass := c.String("smtp-pass")
|
smtpSenderPass := c.String("smtp-sender-pass")
|
||||||
smtpFrom := c.String("smtp-from")
|
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")
|
||||||
@@ -95,8 +101,10 @@ 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 smtpAddr != "" && (baseURL == "" || smtpUser == "" || smtpPass == "" || smtpFrom == "") {
|
} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderUser == "" || smtpSenderPass == "" || smtpSenderFrom == "") {
|
||||||
return errors.New("if smtp-addr is set, base-url, smtp-user, smtp-pass and smtp-from must also be set")
|
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
|
||||||
@@ -111,10 +119,13 @@ 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.SMTPAddr = smtpAddr
|
conf.SMTPSenderAddr = smtpSenderAddr
|
||||||
conf.SMTPUser = smtpUser
|
conf.SMTPSenderUser = smtpSenderUser
|
||||||
conf.SMTPPass = smtpPass
|
conf.SMTPSenderPass = smtpSenderPass
|
||||||
conf.SMTPFrom = smtpFrom
|
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
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscri
|
|||||||
[`since=` parameter](subscribe/api.md#fetch-cached-messages).
|
[`since=` parameter](subscribe/api.md#fetch-cached-messages).
|
||||||
|
|
||||||
## E-mail notifications
|
## E-mail notifications
|
||||||
To allow forwarding messages via e-mail, you can configure an SMTP server for outgoing messages. Once configured,
|
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.
|
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`).
|
`curl -d "hi there" -H "X-Email: phil@example.com" ntfy.sh/mytopic`).
|
||||||
|
|
||||||
@@ -44,13 +44,57 @@ As of today, only SMTP servers with PLAIN auth and STARTLS are supported. To ena
|
|||||||
following settings:
|
following settings:
|
||||||
|
|
||||||
* `base-url` is the root URL for the ntfy server; this is needed for e-mail footer
|
* `base-url` is the root URL for the ntfy server; this is needed for e-mail footer
|
||||||
* `smtp-addr` is the hostname:port of the SMTP server
|
* `smtp-sender-addr` is the hostname:port of the SMTP server
|
||||||
* `smtp-user` and `smtp-pass` are the username and password of the SMTP user
|
* `smtp-sender-user` and `smtp-sender-pass` are the username and password of the SMTP user
|
||||||
* `smtp-from` is the e-mail address of the sender
|
* `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`
|
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.
|
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
|
||||||
@@ -66,7 +110,7 @@ as opposed to the remote IP address. If the `behind-proxy` flag is not set, all
|
|||||||
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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -26,21 +26,21 @@ deb/rpm packages.
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.9.0/ntfy_1.9.0_linux_x86_64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.10.0/ntfy_1.10.0_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.9.0/ntfy_1.9.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.10.0/ntfy_1.10.0_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.9.0/ntfy_1.9.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.10.0/ntfy_1.10.0_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.9.0/ntfy_1.9.0_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.10.0/ntfy_1.10.0_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.9.0/ntfy_1.9.0_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.10.0/ntfy_1.10.0_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.9.0/ntfy_1.9.0_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.10.0/ntfy_1.10.0_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.9.0/ntfy_1.9.0_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.10.0/ntfy_1.10.0_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.9.0/ntfy_1.9.0_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.10.0/ntfy_1.10.0_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.9.0/ntfy_1.9.0_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.10.0/ntfy_1.10.0_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.
|
||||||
|
|||||||
@@ -691,6 +691,28 @@ Here's what that looks like in Google Mail:
|
|||||||
<figcaption>E-mail notification</figcaption>
|
<figcaption>E-mail notification</figcaption>
|
||||||
</figure>
|
</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
|
||||||
@@ -846,7 +868,7 @@ but just in case, let's list them all:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **Message length** | Each message can be up to 512 bytes long. Longer messages are truncated. |
|
| **Message length** | Each message can be up to 512 bytes long. Longer messages are truncated. |
|
||||||
| **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. |
|
| **Requests** | By default, the server is configured to allow 60 requests at once, and then refills the your allowed requests bucket at a rate of one request per 10 seconds. You can read more about this in the [rate limiting](config.md#rate-limiting) section. |
|
||||||
| **E-mails** | By default, the server is configured to allow 16 e-mails at once, and then refills the your allowed e-mail bucket at a rate of one per hour. You can read more about this in the [rate limiting](config.md#rate-limiting) section. |
|
| **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. |
|
||||||
|
|
||||||
|
|||||||
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 |
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=
|
||||||
|
|||||||
@@ -45,10 +45,13 @@ type Config struct {
|
|||||||
ManagerInterval time.Duration
|
ManagerInterval time.Duration
|
||||||
AtSenderInterval time.Duration
|
AtSenderInterval time.Duration
|
||||||
FirebaseKeepaliveInterval time.Duration
|
FirebaseKeepaliveInterval time.Duration
|
||||||
SMTPAddr string
|
SMTPSenderAddr string
|
||||||
SMTPUser string
|
SMTPSenderUser string
|
||||||
SMTPPass string
|
SMTPSenderPass string
|
||||||
SMTPFrom 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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
185
server/server.go
185
server/server.go
@@ -5,9 +5,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"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,6 +33,8 @@ 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
|
||||||
@@ -42,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 {
|
||||||
@@ -75,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(|/.*)$`)
|
||||||
@@ -104,9 +117,22 @@ 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 (
|
||||||
@@ -126,8 +152,8 @@ func New(conf *Config) (*Server, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var mailer mailer
|
var mailer mailer
|
||||||
if conf.SMTPAddr != "" {
|
if conf.SMTPSenderAddr != "" {
|
||||||
mailer = &smtpMailer{config: conf}
|
mailer = &smtpSender{config: conf}
|
||||||
}
|
}
|
||||||
cache, err := createCache(conf)
|
cache, err := createCache(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -202,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)
|
||||||
@@ -218,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,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
|
||||||
@@ -284,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
|
||||||
}
|
}
|
||||||
@@ -314,17 +368,17 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m := newDefaultMessage(t.ID, strings.TrimSpace(string(b)))
|
m := newDefaultMessage(t.ID, strings.TrimSpace(string(b)))
|
||||||
cache, firebase, email, 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 email != "" {
|
||||||
if err := v.EmailAllowed(); err != nil {
|
if err := v.EmailAllowed(); err != nil {
|
||||||
return err
|
return errHTTPTooManyRequestsLimitEmails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.mailer == nil && email != "" {
|
if s.mailer == nil && email != "" {
|
||||||
return errHTTPBadRequest
|
return errHTTPBadRequestEmailDisabled
|
||||||
}
|
}
|
||||||
if m.Message == "" {
|
if m.Message == "" {
|
||||||
m.Message = emptyMessageBody
|
m.Message = emptyMessageBody
|
||||||
@@ -363,7 +417,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) parseParams(r *http.Request, m *message) (cache bool, firebase bool, email string, 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")
|
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||||
@@ -374,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 != "" {
|
||||||
@@ -386,21 +440,25 @@ 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 != "" {
|
if email != "" {
|
||||||
return false, false, "", errHTTPBadRequest // we cannot store the email address (yet)
|
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()
|
||||||
}
|
}
|
||||||
|
unifiedpush := readParam(r, "x-unifiedpush", "unifiedpush", "up") == "1" // see GET too!
|
||||||
|
if unifiedpush {
|
||||||
|
firebase = false
|
||||||
|
}
|
||||||
return cache, firebase, email, nil
|
return cache, firebase, email, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,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
|
||||||
@@ -603,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 {
|
||||||
@@ -615,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 {
|
||||||
@@ -630,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)
|
||||||
}
|
}
|
||||||
@@ -677,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() {
|
||||||
@@ -752,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)
|
||||||
}
|
}
|
||||||
@@ -784,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,7 +1,7 @@
|
|||||||
# 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)
|
# 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 feature.
|
# This setting is currently only used by the e-mail sending feature (outgoing mail only).
|
||||||
#
|
#
|
||||||
# base-url:
|
# base-url:
|
||||||
|
|
||||||
@@ -46,18 +46,32 @@
|
|||||||
#
|
#
|
||||||
# behind-proxy: false
|
# behind-proxy: false
|
||||||
|
|
||||||
# If enabled, allow e-mail notifications via the 'X-Email' header. As of today, only SMTP servers
|
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
|
||||||
# with plain text auth and STARTLS are supported. Please also refer to the rate limiting settings
|
# 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).
|
# below (visitor-email-limit-burst & visitor-email-limit-burst).
|
||||||
#
|
#
|
||||||
# - smtp-addr is the hostname:port of the SMTP server
|
# - smtp-sender-addr is the hostname:port of the SMTP server
|
||||||
# - smtp-user/smtp-pass are the username and password of the SMTP user
|
# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user
|
||||||
# - smtp-from is the e-mail address of the sender
|
# - smtp-sender-from is the e-mail address of the sender
|
||||||
#
|
#
|
||||||
# smtp-addr:
|
# smtp-sender-addr:
|
||||||
# smtp-user:
|
# smtp-sender-user:
|
||||||
# smtp-pass:
|
# smtp-sender-pass:
|
||||||
# smtp-from:
|
# 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.
|
||||||
|
|||||||
@@ -252,6 +252,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) {
|
||||||
@@ -582,6 +583,13 @@ func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {
|
|||||||
require.Equal(t, 400, response.Code)
|
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")
|
||||||
@@ -644,6 +652,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")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
|
"mime"
|
||||||
"net"
|
"net"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,21 +16,21 @@ type mailer interface {
|
|||||||
Send(from, to string, m *message) error
|
Send(from, to string, m *message) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type smtpMailer struct {
|
type smtpSender struct {
|
||||||
config *Config
|
config *Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpMailer) Send(senderIP, to string, m *message) error {
|
func (s *smtpSender) Send(senderIP, to string, m *message) error {
|
||||||
host, _, err := net.SplitHostPort(s.config.SMTPAddr)
|
host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
message, err := formatMail(s.config.BaseURL, senderIP, s.config.SMTPFrom, to, m)
|
message, err := formatMail(s.config.BaseURL, senderIP, s.config.SMTPSenderFrom, to, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
auth := smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host)
|
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
|
||||||
return smtp.SendMail(s.config.SMTPAddr, auth, s.config.SMTPFrom, []string{to}, []byte(message))
|
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) {
|
func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) {
|
||||||
@@ -66,10 +67,11 @@ func formatMail(baseURL, senderIP, from, to string, m *message) (string, error)
|
|||||||
if trailer != "" {
|
if trailer != "" {
|
||||||
message += "\n\n" + trailer
|
message += "\n\n" + trailer
|
||||||
}
|
}
|
||||||
body := `Content-Type: text/plain; charset="utf-8"
|
subject = mime.BEncoding.Encode("utf-8", subject)
|
||||||
From: "{shortTopicURL}" <{from}>
|
body := `From: "{shortTopicURL}" <{from}>
|
||||||
To: {to}
|
To: {to}
|
||||||
Subject: {subject}
|
Subject: {subject}
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
{message}
|
{message}
|
||||||
|
|
||||||
@@ -13,10 +13,10 @@ func TestFormatMail_Basic(t *testing.T) {
|
|||||||
Topic: "alerts",
|
Topic: "alerts",
|
||||||
Message: "A simple message",
|
Message: "A simple message",
|
||||||
})
|
})
|
||||||
expected := `Content-Type: text/plain; charset="utf-8"
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
|
||||||
To: phil@example.com
|
To: phil@example.com
|
||||||
Subject: A simple message
|
Subject: A simple message
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
A simple message
|
A simple message
|
||||||
|
|
||||||
@@ -34,10 +34,10 @@ func TestFormatMail_JustEmojis(t *testing.T) {
|
|||||||
Message: "A simple message",
|
Message: "A simple message",
|
||||||
Tags: []string{"grinning"},
|
Tags: []string{"grinning"},
|
||||||
})
|
})
|
||||||
expected := `Content-Type: text/plain; charset="utf-8"
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
|
||||||
To: phil@example.com
|
To: phil@example.com
|
||||||
Subject: 😀 A simple message
|
Subject: =?utf-8?b?8J+YgCBBIHNpbXBsZSBtZXNzYWdl?=
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
A simple message
|
A simple message
|
||||||
|
|
||||||
@@ -55,10 +55,10 @@ func TestFormatMail_JustOtherTags(t *testing.T) {
|
|||||||
Message: "A simple message",
|
Message: "A simple message",
|
||||||
Tags: []string{"not-an-emoji"},
|
Tags: []string{"not-an-emoji"},
|
||||||
})
|
})
|
||||||
expected := `Content-Type: text/plain; charset="utf-8"
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
|
||||||
To: phil@example.com
|
To: phil@example.com
|
||||||
Subject: A simple message
|
Subject: A simple message
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
A simple message
|
A simple message
|
||||||
|
|
||||||
@@ -78,10 +78,10 @@ func TestFormatMail_JustPriority(t *testing.T) {
|
|||||||
Message: "A simple message",
|
Message: "A simple message",
|
||||||
Priority: 2,
|
Priority: 2,
|
||||||
})
|
})
|
||||||
expected := `Content-Type: text/plain; charset="utf-8"
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
|
||||||
To: phil@example.com
|
To: phil@example.com
|
||||||
Subject: A simple message
|
Subject: A simple message
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
A simple message
|
A simple message
|
||||||
|
|
||||||
@@ -101,10 +101,10 @@ func TestFormatMail_UTF8Subject(t *testing.T) {
|
|||||||
Message: "A simple message",
|
Message: "A simple message",
|
||||||
Title: " :: A not so simple title öäüß ¡Hola, señor!",
|
Title: " :: A not so simple title öäüß ¡Hola, señor!",
|
||||||
})
|
})
|
||||||
expected := `Content-Type: text/plain; charset="utf-8"
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
|
||||||
To: phil@example.com
|
To: phil@example.com
|
||||||
Subject: :: A not so simple title öäüß ¡Hola, señor!
|
Subject: =?utf-8?b?IDo6IEEgbm90IHNvIHNpbXBsZSB0aXRsZSDDtsOkw7zDnyDCoUhvbGEsIHNl?= =?utf-8?b?w7FvciE=?=
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
A simple message
|
A simple message
|
||||||
|
|
||||||
@@ -124,10 +124,10 @@ func TestFormatMail_WithAllTheThings(t *testing.T) {
|
|||||||
Title: "Oh no 🙈\nThis is a message across\nmultiple lines",
|
Title: "Oh no 🙈\nThis is a message across\nmultiple lines",
|
||||||
Message: "A message that contains monkeys 🙉\nNo really, though. Monkeys!",
|
Message: "A message that contains monkeys 🙉\nNo really, though. Monkeys!",
|
||||||
})
|
})
|
||||||
expected := `Content-Type: text/plain; charset="utf-8"
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
|
||||||
To: phil@example.com
|
To: phil@example.com
|
||||||
Subject: ⚠️ 💀 Oh no 🙈 This is a message across multiple lines
|
Subject: =?utf-8?b?4pqg77iPIPCfkoAgT2ggbm8g8J+ZiCBUaGlzIGlzIGEgbWVzc2FnZSBhY3Jv?= =?utf-8?b?c3MgbXVsdGlwbGUgbGluZXM=?=
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
A message that contains monkeys 🙉
|
A message that contains monkeys 🙉
|
||||||
No really, though. Monkeys!
|
No really, though. Monkeys!
|
||||||
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
|
||||||
|
}
|
||||||
190
server/smtp_server_test.go
Normal file
190
server/smtp_server_test.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
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 512 bytes,
|
||||||
|
which some people say is too short
|
||||||
|
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 512 bytes,
|
||||||
|
which some people say is too short
|
||||||
|
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 `
|
||||||
|
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"
|
||||||
@@ -14,6 +15,10 @@ const (
|
|||||||
visitorExpungeAfter = 24 * time.Hour
|
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
|
||||||
@@ -42,23 +47,23 @@ func (v *visitor) IP() string {
|
|||||||
|
|
||||||
func (v *visitor) RequestAllowed() error {
|
func (v *visitor) RequestAllowed() error {
|
||||||
if !v.requests.Allow() {
|
if !v.requests.Allow() {
|
||||||
return errHTTPTooManyRequests
|
return errVisitorLimitReached
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) EmailAllowed() error {
|
func (v *visitor) EmailAllowed() error {
|
||||||
if !v.emails.Allow() {
|
if !v.emails.Allow() {
|
||||||
return errHTTPTooManyRequests
|
return errVisitorLimitReached
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) AddSubscription() error {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user