mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-01-19 00:27:25 +01:00
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
|
||||
* [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
|
||||
* [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
|
||||
* [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)
|
||||
|
||||
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: "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.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-user", EnvVars: []string{"NTFY_SMTP_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-from", EnvVars: []string{"NTFY_SMTP_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
|
||||
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: "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"}),
|
||||
@@ -68,10 +71,13 @@ func execServe(c *cli.Context) error {
|
||||
cacheDuration := c.Duration("cache-duration")
|
||||
keepaliveInterval := c.Duration("keepalive-interval")
|
||||
managerInterval := c.Duration("manager-interval")
|
||||
smtpAddr := c.String("smtp-addr")
|
||||
smtpUser := c.String("smtp-user")
|
||||
smtpPass := c.String("smtp-pass")
|
||||
smtpFrom := c.String("smtp-from")
|
||||
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")
|
||||
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
||||
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")
|
||||
} else if listenHTTPS != "" && (keyFile == "" || certFile == "") {
|
||||
return errors.New("if listen-https is set, both key-file and cert-file must be set")
|
||||
} else if smtpAddr != "" && (baseURL == "" || smtpUser == "" || smtpPass == "" || smtpFrom == "") {
|
||||
return errors.New("if smtp-addr is set, base-url, smtp-user, smtp-pass and smtp-from must also 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
|
||||
@@ -111,10 +119,13 @@ func execServe(c *cli.Context) error {
|
||||
conf.CacheDuration = cacheDuration
|
||||
conf.KeepaliveInterval = keepaliveInterval
|
||||
conf.ManagerInterval = managerInterval
|
||||
conf.SMTPAddr = smtpAddr
|
||||
conf.SMTPUser = smtpUser
|
||||
conf.SMTPPass = smtpPass
|
||||
conf.SMTPFrom = smtpFrom
|
||||
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.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
||||
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).
|
||||
|
||||
## 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.
|
||||
`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:
|
||||
|
||||
* `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-user` and `smtp-pass` are the username and password of the SMTP user
|
||||
* `smtp-from` is the e-mail address of the sender
|
||||
* `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.)
|
||||
!!! warning
|
||||
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.
|
||||
|
||||
=== "/etc/ntfy/server.yml"
|
||||
```
|
||||
``` yaml
|
||||
# Tell ntfy to use "X-Forwarded-For" to identify visitors
|
||||
behind-proxy: true
|
||||
```
|
||||
|
||||
@@ -26,21 +26,21 @@ deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```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 ./ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```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 ./ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```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 ./ntfy serve
|
||||
```
|
||||
@@ -88,7 +88,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```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 systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -96,7 +96,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```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 systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -104,7 +104,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```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 systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -114,25 +114,39 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```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 start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```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 start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```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 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
|
||||
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.
|
||||
|
||||
@@ -691,6 +691,28 @@ Here's what that looks like in Google Mail:
|
||||
<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
|
||||
|
||||
### 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. |
|
||||
| **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. |
|
||||
| **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
|
||||
github.com/BurntSushi/toml v0.4.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/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
|
||||
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/xds/go v0.0.0-20211130200136-a8f946100490 // 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/protoc-gen-validate v0.6.2 // 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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
|
||||
@@ -45,10 +45,13 @@ type Config struct {
|
||||
ManagerInterval time.Duration
|
||||
AtSenderInterval time.Duration
|
||||
FirebaseKeepaliveInterval time.Duration
|
||||
SMTPAddr string
|
||||
SMTPUser string
|
||||
SMTPPass string
|
||||
SMTPFrom string
|
||||
SMTPSenderAddr string
|
||||
SMTPSenderUser string
|
||||
SMTPSenderPass string
|
||||
SMTPSenderFrom string
|
||||
SMTPServerListen string
|
||||
SMTPServerDomain string
|
||||
SMTPServerAddrPrefix string
|
||||
MessageLimit int
|
||||
MinDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
|
||||
@@ -198,7 +198,7 @@
|
||||
curl -d "Backup failed" <span id="detailTopicUrl">ntfy.sh/topic</span>
|
||||
</code>
|
||||
<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.
|
||||
Click the link to do so.
|
||||
</p>
|
||||
|
||||
185
server/server.go
185
server/server.go
@@ -5,9 +5,11 @@ import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
firebase "firebase.google.com/go"
|
||||
"firebase.google.com/go/messaging"
|
||||
"fmt"
|
||||
"github.com/emersion/go-smtp"
|
||||
"google.golang.org/api/option"
|
||||
"heckel.io/ntfy/util"
|
||||
"html/template"
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -30,6 +33,8 @@ type Server struct {
|
||||
config *Config
|
||||
httpServer *http.Server
|
||||
httpsServer *http.Server
|
||||
smtpServer *smtp.Server
|
||||
smtpBackend *smtpBackend
|
||||
topics map[string]*topic
|
||||
visitors map[string]*visitor
|
||||
firebase subscriber
|
||||
@@ -42,12 +47,19 @@ type Server struct {
|
||||
|
||||
// errHTTP is a generic HTTP error for any non-200 HTTP error
|
||||
type errHTTP struct {
|
||||
Code int
|
||||
Status string
|
||||
Code int `json:"code,omitempty"`
|
||||
HTTPCode int `json:"http"`
|
||||
Message string `json:"error"`
|
||||
Link string `json:"link,omitempty"`
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -75,11 +87,12 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
topicRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
||||
jsonRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
|
||||
sseRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
|
||||
rawRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
|
||||
sendRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/(publish|send|trigger)$`)
|
||||
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
||||
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
||||
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
|
||||
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
|
||||
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/.+`)
|
||||
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
||||
@@ -104,9 +117,22 @@ var (
|
||||
docsStaticFs embed.FS
|
||||
docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
|
||||
|
||||
errHTTPBadRequest = &errHTTP{http.StatusBadRequest, http.StatusText(http.StatusBadRequest)}
|
||||
errHTTPNotFound = &errHTTP{http.StatusNotFound, http.StatusText(http.StatusNotFound)}
|
||||
errHTTPTooManyRequests = &errHTTP{http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)}
|
||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
|
||||
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
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 (
|
||||
@@ -126,8 +152,8 @@ func New(conf *Config) (*Server, error) {
|
||||
}
|
||||
}
|
||||
var mailer mailer
|
||||
if conf.SMTPAddr != "" {
|
||||
mailer = &smtpMailer{config: conf}
|
||||
if conf.SMTPSenderAddr != "" {
|
||||
mailer = &smtpSender{config: conf}
|
||||
}
|
||||
cache, err := createCache(conf)
|
||||
if err != nil {
|
||||
@@ -202,6 +228,9 @@ func (s *Server) Run() error {
|
||||
if 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)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", s.handle)
|
||||
@@ -218,10 +247,16 @@ func (s *Server) Run() error {
|
||||
errChan <- s.httpsServer.ListenAndServeTLS(s.config.CertFile, s.config.KeyFile)
|
||||
}()
|
||||
}
|
||||
if s.config.SMTPServerListen != "" {
|
||||
go func() {
|
||||
errChan <- s.runSMTPServer()
|
||||
}()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
go s.runManager()
|
||||
go s.runAtSender()
|
||||
go s.runFirebaseKeepliver()
|
||||
|
||||
return <-errChan
|
||||
}
|
||||
|
||||
@@ -235,16 +270,24 @@ func (s *Server) Stop() {
|
||||
if s.httpsServer != nil {
|
||||
s.httpsServer.Close()
|
||||
}
|
||||
if s.smtpServer != nil {
|
||||
s.smtpServer.Close()
|
||||
}
|
||||
close(s.closeChan)
|
||||
}
|
||||
|
||||
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.handleInternal(w, r); err != nil {
|
||||
if e, ok := err.(*errHTTP); ok {
|
||||
s.fail(w, r, e.Code, e)
|
||||
} else {
|
||||
s.fail(w, r, http.StatusInternalServerError, err)
|
||||
var e *errHTTP
|
||||
var ok bool
|
||||
if e, ok = err.(*errHTTP); !ok {
|
||||
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)
|
||||
} else if r.Method == http.MethodOptions {
|
||||
return s.handleOptions(w, r)
|
||||
} else if r.Method == http.MethodGet && topicRegex.MatchString(r.URL.Path) {
|
||||
return s.handleHome(w, r)
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicRegex.MatchString(r.URL.Path) {
|
||||
} else if r.Method == http.MethodGet && topicPathRegex.MatchString(r.URL.Path) {
|
||||
return s.handleTopic(w, r)
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
|
||||
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)
|
||||
} 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)
|
||||
} 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)
|
||||
} 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 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 {
|
||||
return nil
|
||||
}
|
||||
@@ -314,17 +368,17 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if email != "" {
|
||||
if err := v.EmailAllowed(); err != nil {
|
||||
return err
|
||||
return errHTTPTooManyRequestsLimitEmails
|
||||
}
|
||||
}
|
||||
if s.mailer == nil && email != "" {
|
||||
return errHTTPBadRequest
|
||||
return errHTTPBadRequestEmailDisabled
|
||||
}
|
||||
if m.Message == "" {
|
||||
m.Message = emptyMessageBody
|
||||
@@ -363,7 +417,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||
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"
|
||||
firebase = readParam(r, "x-firebase", "firebase") != "no"
|
||||
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"))
|
||||
if err != nil {
|
||||
return false, false, "", errHTTPBadRequest
|
||||
return false, false, "", errHTTPBadRequestPriorityInvalid
|
||||
}
|
||||
tagsStr := readParam(r, "x-tags", "tags", "tag", "ta")
|
||||
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")
|
||||
if delayStr != "" {
|
||||
if !cache {
|
||||
return false, false, "", errHTTPBadRequest
|
||||
return false, false, "", errHTTPBadRequestDelayNoCache
|
||||
}
|
||||
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())
|
||||
if err != nil {
|
||||
return false, false, "", errHTTPBadRequest
|
||||
return false, false, "", errHTTPBadRequestDelayCannotParse
|
||||
} 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() {
|
||||
return false, false, "", errHTTPBadRequest
|
||||
return false, false, "", errHTTPBadRequestDelayTooLarge
|
||||
}
|
||||
m.Time = delay.Unix()
|
||||
}
|
||||
unifiedpush := readParam(r, "x-unifiedpush", "unifiedpush", "up") == "1" // see GET too!
|
||||
if unifiedpush {
|
||||
firebase = false
|
||||
}
|
||||
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 {
|
||||
if err := v.AddSubscription(); err != nil {
|
||||
return errHTTPTooManyRequests
|
||||
if err := v.SubscriptionAllowed(); err != nil {
|
||||
return errHTTPTooManyRequestsLimitSubscriptions
|
||||
}
|
||||
defer v.RemoveSubscription()
|
||||
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 {
|
||||
return sinceTime(time.Now().Add(-1 * d)), nil
|
||||
}
|
||||
return sinceNoMessages, errHTTPBadRequest
|
||||
return sinceNoMessages, errHTTPBadRequestSinceInvalid
|
||||
}
|
||||
|
||||
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) {
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 {
|
||||
return nil, errHTTPBadRequest
|
||||
return nil, errHTTPBadRequestTopicInvalid
|
||||
}
|
||||
topics, err := s.topicsFromIDs(parts[1])
|
||||
if err != nil {
|
||||
@@ -630,11 +688,11 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
|
||||
topics := make([]*topic, 0)
|
||||
for _, id := range ids {
|
||||
if util.InStringList(disallowedTopics, id) {
|
||||
return nil, errHTTPBadRequest
|
||||
return nil, errHTTPBadRequestTopicDisallowed
|
||||
}
|
||||
if _, ok := s.topics[id]; !ok {
|
||||
if len(s.topics) >= s.config.GlobalTopicLimit {
|
||||
return nil, errHTTPTooManyRequests
|
||||
return nil, errHTTPTooManyRequestsLimitGlobalTopics
|
||||
}
|
||||
s.topics[id] = newTopic(id)
|
||||
}
|
||||
@@ -677,9 +735,44 @@ func (s *Server) updateStatsAndPrune() {
|
||||
messages += msgs
|
||||
}
|
||||
|
||||
// Mail stats
|
||||
var mailSuccess, mailFailure int64
|
||||
if s.smtpBackend != nil {
|
||||
mailSuccess, mailFailure = s.smtpBackend.Counts()
|
||||
}
|
||||
|
||||
// Print stats
|
||||
log.Printf("Stats: %d message(s) published, %d topic(s) active, %d subscriber(s), %d message(s) buffered, %d visitor(s)",
|
||||
s.messages, len(s.topics), subscribers, messages, len(s.visitors))
|
||||
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, 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() {
|
||||
@@ -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 {
|
||||
v := s.visitor(r)
|
||||
if err := v.RequestAllowed(); err != nil {
|
||||
return err
|
||||
return errHTTPTooManyRequestsLimitRequests
|
||||
}
|
||||
return handler(w, r, v)
|
||||
}
|
||||
@@ -784,9 +877,3 @@ func (s *Server) inc(counter *int64) {
|
||||
defer s.mu.Unlock()
|
||||
*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
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -46,18 +46,32 @@
|
||||
#
|
||||
# behind-proxy: false
|
||||
|
||||
# If enabled, allow e-mail notifications via the 'X-Email' header. As of today, only SMTP servers
|
||||
# with plain text auth and STARTLS are supported. Please also refer to the rate limiting settings
|
||||
# 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-addr is the hostname:port of the SMTP server
|
||||
# - smtp-user/smtp-pass are the username and password of the SMTP user
|
||||
# - smtp-from is the e-mail address of the sender
|
||||
# - 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-addr:
|
||||
# smtp-user:
|
||||
# smtp-pass:
|
||||
# smtp-from:
|
||||
# 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
|
||||
# intermediaries closing the connection for inactivity.
|
||||
|
||||
@@ -252,6 +252,7 @@ func TestServer_PublishAtWithCacheError(t *testing.T) {
|
||||
"In": "30 min",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, errHTTPBadRequestDelayNoCache, toHTTPError(t, response.Body.String()))
|
||||
}
|
||||
|
||||
func TestServer_PublishAtTooShortDelay(t *testing.T) {
|
||||
@@ -582,6 +583,13 @@ func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {
|
||||
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 {
|
||||
conf := NewConfig()
|
||||
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
||||
@@ -644,6 +652,12 @@ func toMessage(t *testing.T, s string) *message {
|
||||
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 {
|
||||
if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") != "" {
|
||||
return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE")
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/util"
|
||||
"mime"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
@@ -15,21 +16,21 @@ type mailer interface {
|
||||
Send(from, to string, m *message) error
|
||||
}
|
||||
|
||||
type smtpMailer struct {
|
||||
type smtpSender struct {
|
||||
config *Config
|
||||
}
|
||||
|
||||
func (s *smtpMailer) Send(senderIP, to string, m *message) error {
|
||||
host, _, err := net.SplitHostPort(s.config.SMTPAddr)
|
||||
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.SMTPFrom, to, m)
|
||||
message, err := formatMail(s.config.BaseURL, senderIP, s.config.SMTPSenderFrom, to, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auth := smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host)
|
||||
return smtp.SendMail(s.config.SMTPAddr, auth, s.config.SMTPFrom, []string{to}, []byte(message))
|
||||
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) {
|
||||
@@ -66,10 +67,11 @@ func formatMail(baseURL, senderIP, from, to string, m *message) (string, error)
|
||||
if trailer != "" {
|
||||
message += "\n\n" + trailer
|
||||
}
|
||||
body := `Content-Type: text/plain; charset="utf-8"
|
||||
From: "{shortTopicURL}" <{from}>
|
||||
subject = mime.BEncoding.Encode("utf-8", subject)
|
||||
body := `From: "{shortTopicURL}" <{from}>
|
||||
To: {to}
|
||||
Subject: {subject}
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
{message}
|
||||
|
||||
@@ -13,10 +13,10 @@ func TestFormatMail_Basic(t *testing.T) {
|
||||
Topic: "alerts",
|
||||
Message: "A simple message",
|
||||
})
|
||||
expected := `Content-Type: text/plain; charset="utf-8"
|
||||
From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||
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
|
||||
|
||||
@@ -34,10 +34,10 @@ func TestFormatMail_JustEmojis(t *testing.T) {
|
||||
Message: "A simple message",
|
||||
Tags: []string{"grinning"},
|
||||
})
|
||||
expected := `Content-Type: text/plain; charset="utf-8"
|
||||
From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||
To: phil@example.com
|
||||
Subject: 😀 A simple message
|
||||
Subject: =?utf-8?b?8J+YgCBBIHNpbXBsZSBtZXNzYWdl?=
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
A simple message
|
||||
|
||||
@@ -55,10 +55,10 @@ func TestFormatMail_JustOtherTags(t *testing.T) {
|
||||
Message: "A simple message",
|
||||
Tags: []string{"not-an-emoji"},
|
||||
})
|
||||
expected := `Content-Type: text/plain; charset="utf-8"
|
||||
From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||
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
|
||||
|
||||
@@ -78,10 +78,10 @@ func TestFormatMail_JustPriority(t *testing.T) {
|
||||
Message: "A simple message",
|
||||
Priority: 2,
|
||||
})
|
||||
expected := `Content-Type: text/plain; charset="utf-8"
|
||||
From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||
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
|
||||
|
||||
@@ -101,10 +101,10 @@ func TestFormatMail_UTF8Subject(t *testing.T) {
|
||||
Message: "A simple message",
|
||||
Title: " :: A not so simple title öäüß ¡Hola, señor!",
|
||||
})
|
||||
expected := `Content-Type: text/plain; charset="utf-8"
|
||||
From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||
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
|
||||
|
||||
@@ -124,10 +124,10 @@ func TestFormatMail_WithAllTheThings(t *testing.T) {
|
||||
Title: "Oh no 🙈\nThis is a message across\nmultiple lines",
|
||||
Message: "A message that contains monkeys 🙉\nNo really, though. Monkeys!",
|
||||
})
|
||||
expected := `Content-Type: text/plain; charset="utf-8"
|
||||
From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||
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 🙉
|
||||
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
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"golang.org/x/time/rate"
|
||||
"heckel.io/ntfy/util"
|
||||
"sync"
|
||||
@@ -14,6 +15,10 @@ const (
|
||||
visitorExpungeAfter = 24 * time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
errVisitorLimitReached = errors.New("limit reached")
|
||||
)
|
||||
|
||||
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
|
||||
type visitor struct {
|
||||
config *Config
|
||||
@@ -42,23 +47,23 @@ func (v *visitor) IP() string {
|
||||
|
||||
func (v *visitor) RequestAllowed() error {
|
||||
if !v.requests.Allow() {
|
||||
return errHTTPTooManyRequests
|
||||
return errVisitorLimitReached
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *visitor) EmailAllowed() error {
|
||||
if !v.emails.Allow() {
|
||||
return errHTTPTooManyRequests
|
||||
return errVisitorLimitReached
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *visitor) AddSubscription() error {
|
||||
func (v *visitor) SubscriptionAllowed() error {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if err := v.subscriptions.Add(1); err != nil {
|
||||
return errHTTPTooManyRequests
|
||||
return errVisitorLimitReached
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user