diff --git a/cmd/serve.go b/cmd/serve.go
index 2af1f389..0c0b1139 100644
--- a/cmd/serve.go
+++ b/cmd/serve.go
@@ -71,6 +71,7 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"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", Aliases: []string{"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", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
+ altsrc.NewBoolFlag(&cli.BoolFlag{Name: "smtp-sender-verify", Aliases: []string{"smtp_sender_verify"}, EnvVars: []string{"NTFY_SMTP_SENDER_VERIFY"}, Value: false, Usage: "require verified email addresses for sending email notifications"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"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", Aliases: []string{"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", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
@@ -184,6 +185,7 @@ func execServe(c *cli.Context) error {
smtpSenderUser := c.String("smtp-sender-user")
smtpSenderPass := c.String("smtp-sender-pass")
smtpSenderFrom := c.String("smtp-sender-from")
+ smtpSenderVerify := c.Bool("smtp-sender-verify")
smtpServerListen := c.String("smtp-server-listen")
smtpServerDomain := c.String("smtp-server-domain")
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
@@ -310,6 +312,8 @@ func execServe(c *cli.Context) error {
return errors.New("if listen-https is set, both key-file and cert-file must be set")
} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") {
return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set")
+ } else if smtpSenderVerify && smtpSenderAddr == "" {
+ return errors.New("if smtp-sender-verify is set, smtp-sender-addr must also be set")
} else if smtpServerListen != "" && smtpServerDomain == "" {
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
} else if attachmentCacheDir != "" && baseURL == "" {
@@ -471,6 +475,7 @@ func execServe(c *cli.Context) error {
conf.SMTPSenderUser = smtpSenderUser
conf.SMTPSenderPass = smtpSenderPass
conf.SMTPSenderFrom = smtpSenderFrom
+ conf.SMTPSenderVerify = smtpSenderVerify
conf.SMTPServerListen = smtpServerListen
conf.SMTPServerDomain = smtpServerDomain
conf.SMTPServerAddrPrefix = smtpServerAddrPrefix
diff --git a/docs/config.md b/docs/config.md
index e7a98774..61e7eb92 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -353,6 +353,14 @@ This generator helps you configure your self-hosted ntfy instance. It's not full
+
@@ -1011,6 +1019,10 @@ To allow forwarding messages via e-mail, you can configure an **SMTP server for
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`).
+!!! info
+ On ntfy.sh, anonymous email sending was disabled due to abuse. To use the email notification feature,
+ you must verify your email in the web app's [Account section](https://ntfy.sh/account).
+
As of today, only SMTP servers with PLAIN auth and STARTLS are supported. To enable e-mail sending, you must set the
following settings:
@@ -1018,6 +1030,8 @@ following settings:
* `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
+* `smtp-sender-verify` is a flag that forces email recipient verification when enabled. If set to true,
+ only verified email recipients can be used in the `X-Email` header.
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`):
@@ -1029,9 +1043,15 @@ configured for `ntfy.sh`):
smtp-sender-user: "AKIDEADBEEFAFFE12345"
smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG."
smtp-sender-from: "ntfy@ntfy.sh"
+ smtp-sender-verify: true
```
-Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst`
+By default, any user (including anonymous users) can send email notifications to any address. To require email
+address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails,
+and authenticated users can only send to email addresses they have verified in their account settings. Users can
+also use `yes`/`true`/`1` as the `X-Email` value to send to their first verified address.
+
+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
@@ -2200,6 +2220,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
+| `smtp-sender-verify` | `NTFY_SMTP_SENDER_VERIFY` | *bool* | `false` | If true, require verified email addresses for email notifications; anonymous email sending is disabled |
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
diff --git a/docs/install.md b/docs/install.md
index 4deed09b..b57d522d 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -34,37 +34,37 @@ as a service starting at boot time.
=== "x86_64/amd64"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_amd64.tar.gz
- tar zxvf ntfy_2.20.1_linux_amd64.tar.gz
- sudo cp -a ntfy_2.20.1_linux_amd64/ntfy /usr/local/bin/ntfy
- sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.1_linux_amd64/{client,server}/*.yml /etc/ntfy
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_amd64.tar.gz
+ tar zxvf ntfy_2.21.0_linux_amd64.tar.gz
+ sudo cp -a ntfy_2.21.0_linux_amd64/ntfy /usr/local/bin/ntfy
+ sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv6.tar.gz
- tar zxvf ntfy_2.20.1_linux_armv6.tar.gz
- sudo cp -a ntfy_2.20.1_linux_armv6/ntfy /usr/bin/ntfy
- sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.1_linux_armv6/{client,server}/*.yml /etc/ntfy
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv6.tar.gz
+ tar zxvf ntfy_2.21.0_linux_armv6.tar.gz
+ sudo cp -a ntfy_2.21.0_linux_armv6/ntfy /usr/bin/ntfy
+ sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv7.tar.gz
- tar zxvf ntfy_2.20.1_linux_armv7.tar.gz
- sudo cp -a ntfy_2.20.1_linux_armv7/ntfy /usr/bin/ntfy
- sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.1_linux_armv7/{client,server}/*.yml /etc/ntfy
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv7.tar.gz
+ tar zxvf ntfy_2.21.0_linux_armv7.tar.gz
+ sudo cp -a ntfy_2.21.0_linux_armv7/ntfy /usr/bin/ntfy
+ sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_arm64.tar.gz
- tar zxvf ntfy_2.20.1_linux_arm64.tar.gz
- sudo cp -a ntfy_2.20.1_linux_arm64/ntfy /usr/bin/ntfy
- sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.1_linux_arm64/{client,server}/*.yml /etc/ntfy
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_arm64.tar.gz
+ tar zxvf ntfy_2.21.0_linux_arm64.tar.gz
+ sudo cp -a ntfy_2.21.0_linux_arm64/ntfy /usr/bin/ntfy
+ sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic
=== "x86_64/amd64"
```bash
- sudo mv ntfy_2.20.1_linux_amd64/server/ntfy.service /etc/systemd/system/
+ sudo mv ntfy_2.21.0_linux_amd64/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "armv6"
```bash
- sudo mv ntfy_2.20.1_linux_armv6/server/ntfy.service /etc/systemd/system/
+ sudo mv ntfy_2.21.0_linux_armv6/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "armv7/armhf"
```bash
- sudo mv ntfy_2.20.1_linux_armv7/server/ntfy.service /etc/systemd/system/
+ sudo mv ntfy_2.21.0_linux_armv7/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "arm64"
```bash
- sudo mv ntfy_2.20.1_linux_arm64/server/ntfy.service /etc/systemd/system/
+ sudo mv ntfy_2.21.0_linux_arm64/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
@@ -118,25 +118,25 @@ Install the ntfy server service script:
=== "x86_64/amd64"
```bash
- sudo mv ntfy_2.20.1_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
+ sudo mv ntfy_2.21.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "armv6"
```bash
- sudo mv ntfy_2.20.1_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
+ sudo mv ntfy_2.21.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "armv7/armhf"
```bash
- sudo mv ntfy_2.20.1_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
+ sudo mv ntfy_2.21.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "arm64"
```bash
- sudo mv ntfy_2.20.1_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
+ sudo mv ntfy_2.21.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
@@ -204,7 +204,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_amd64.deb
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -212,7 +212,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv6.deb
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -220,7 +220,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv7.deb
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -228,7 +228,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
- wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_arm64.deb
+ wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -238,28 +238,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
- sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_amd64.rpm
+ sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv6"
```bash
- sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv6.rpm
+ sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv6.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv7/armhf"
```bash
- sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv7.rpm
+ sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
- sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_arm64.rpm
+ sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@@ -301,18 +301,18 @@ pkg install go-ntfy
## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
-To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_darwin_all.tar.gz),
+To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_darwin_all.tar.gz),
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash
-curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_darwin_all.tar.gz > ntfy_2.20.1_darwin_all.tar.gz
-tar zxvf ntfy_2.20.1_darwin_all.tar.gz
-sudo cp -a ntfy_2.20.1_darwin_all/ntfy /usr/local/bin/ntfy
+curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_darwin_all.tar.gz > ntfy_2.21.0_darwin_all.tar.gz
+tar zxvf ntfy_2.21.0_darwin_all.tar.gz
+sudo cp -a ntfy_2.21.0_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
-cp ntfy_2.20.1_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
+cp ntfy_2.21.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
@@ -333,7 +333,7 @@ brew install ntfy
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
To install, you can either
-* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_windows_amd64.zip),
+* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_windows_amd64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
diff --git a/docs/publish.md b/docs/publish.md
index 00c43b5e..5cef10cf 100644
--- a/docs/publish.md
+++ b/docs/publish.md
@@ -3213,12 +3213,18 @@ You can forward messages to e-mail by specifying an address in the header. This
you'd like to persist longer, or to blast-notify yourself on all possible channels.
Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`).
-Only one e-mail address is supported.
+Only one e-mail address is supported. If the server has [`smtp-sender-verify`](config.md#e-mail-notifications) enabled (ntfy.sh has this enabled),
+you can also pass `yes`, `true`, or `1` to send to your first verified email address.
-Since ntfy does not provide auth (yet), the rate limiting is pretty strict (see [limitations](#limitations)). In the
-default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of
+ntfy allows anonymous email sending (if enabled), so the rate limiting is pretty strict (see [limitations](#limitations)). In the
+default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of
that, your IP address appears in the e-mail body. This is to prevent abuse.
+!!! info
+ On ntfy.sh, anonymous email sending was disabled due to abuse. To use the email notification feature,
+ you must verify your email in the web app's [Account section](https://ntfy.sh/account). The daily limit for
+ free users is **5 emails per visitor per day**.
+
=== "Command line (curl)"
```
curl \
@@ -3658,7 +3664,7 @@ all the supported fields:
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
-| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
+| `email` | - | *e-mail address or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use first verified address |
| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) |
@@ -4871,7 +4877,7 @@ table in their canonical form.
| `X-Markdown` | `Markdown`, `md` | Enable [Markdown formatting](#markdown-formatting) in the notification body |
| `X-Icon` | `Icon` | URL to use as notification [icon](#icons) |
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
-| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
+| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address (or `yes`) for [e-mail notifications](#e-mail-notifications) |
| `X-Call` | `Call` | Phone number for [phone calls](#phone-calls) |
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
diff --git a/docs/releases.md b/docs/releases.md
index a8024923..72bfefed 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -6,12 +6,24 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
| Component | Version | Release date |
|------------------|---------|--------------|
-| ntfy server | v2.20.1 | Mar 27, 2026 |
+| ntfy server | v2.21.0 | Mar 30, 2026 |
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
| ntfy iOS app | v1.3 | Nov 26, 2023 |
Please check out the release notes for [upcoming releases](#not-released-yet) below.
+### ntfy server v2.21.0
+Released March 30, 2026
+
+This release adds the ability to verify email addresses using the `smtp-sender-verify` flag. This is a change that is
+required because ntfy.sh was used to send unsolicited emails and the AWS SES account was suspended. Going forward,
+ntfy.sh won't be able to send emails unless the email address was verified ahead of time.
+
+**Features:**
+
+* Add verified email recipients feature with `smtp-sender-verify` config flag, allowing server admins to require email
+ address verification before sending email notifications ([#1681](https://github.com/binwiederhier/ntfy/pull/1681))
+
### ntfy server v2.20.1
Released March 27, 2026
@@ -1832,4 +1844,9 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
-_Nothing._
\ No newline at end of file
+## ntfy Android v1.25.x (UNRELEASED)
+
+**Features:**
+
+* Add configurable "Alert when connection is lost" setting ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
+* Suppress connection alerts and stop foreground service when there is no network ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution)
diff --git a/docs/static/js/config-generator.js b/docs/static/js/config-generator.js
index 7c093ead..ffada277 100644
--- a/docs/static/js/config-generator.js
+++ b/docs/static/js/config-generator.js
@@ -125,6 +125,7 @@
{ key: "smtp-sender-from", env: "NTFY_SMTP_SENDER_FROM", section: "smtp-out" },
{ key: "smtp-sender-user", env: "NTFY_SMTP_SENDER_USER", section: "smtp-out" },
{ key: "smtp-sender-pass", env: "NTFY_SMTP_SENDER_PASS", section: "smtp-out" },
+ { key: "smtp-sender-verify", env: "NTFY_SMTP_SENDER_VERIFY", section: "smtp-out", type: "bool" },
{ key: "smtp-server-listen", env: "NTFY_SMTP_SERVER_LISTEN", section: "smtp-in" },
{ key: "smtp-server-domain", env: "NTFY_SMTP_SERVER_DOMAIN", section: "smtp-in" },
{ key: "smtp-server-addr-prefix", env: "NTFY_SMTP_SERVER_ADDR_PREFIX", section: "smtp-in" },
@@ -171,6 +172,7 @@
requireLoginHidden: modal.querySelector("#cg-require-login-hidden"),
signupHidden: modal.querySelector("#cg-enable-signup-hidden"),
proxyCheckbox: modal.querySelector("#cg-behind-proxy"),
+ smtpSenderVerifyHidden: modal.querySelector("#cg-smtp-sender-verify-hidden"),
dbStep: modal.querySelector("#cg-wizard-db"),
navDb: modal.querySelector("#cg-nav-database"),
navEmail: modal.querySelector("#cg-nav-email"),
@@ -743,6 +745,10 @@
const signupYes = modal.querySelector("input[name=\"cg-enable-signup\"][value=\"yes\"]");
if (signupYes && signupHidden) signupHidden.checked = signupYes.checked;
+ // SMTP sender verify radio → hidden checkbox
+ const smtpVerifyYes = modal.querySelector("input[name=\"cg-smtp-sender-verify\"][value=\"yes\"]");
+ if (smtpVerifyYes && els.smtpSenderVerifyHidden) els.smtpSenderVerifyHidden.checked = smtpVerifyYes.checked;
+
return loginModeVal;
}
diff --git a/go.mod b/go.mod
index 2f23f0cd..c5879636 100644
--- a/go.mod
+++ b/go.mod
@@ -10,7 +10,7 @@ require (
github.com/emersion/go-smtp v0.18.0
github.com/gabriel-vasile/mimetype v1.4.13
github.com/gorilla/websocket v1.5.3
- github.com/mattn/go-sqlite3 v1.14.37
+ github.com/mattn/go-sqlite3 v1.14.38
github.com/olebedev/when v1.1.0
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v2 v2.27.7
diff --git a/go.sum b/go.sum
index 2f67ff78..90d22ee6 100644
--- a/go.sum
+++ b/go.sum
@@ -120,8 +120,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
-github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
+github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
diff --git a/mail/sender.go b/mail/sender.go
new file mode 100644
index 00000000..5511cb04
--- /dev/null
+++ b/mail/sender.go
@@ -0,0 +1,154 @@
+package mail
+
+import (
+ "fmt"
+ "mime"
+ "net"
+ "net/smtp"
+ "strings"
+ "sync"
+ "time"
+
+ "heckel.io/ntfy/v2/log"
+ "heckel.io/ntfy/v2/util"
+)
+
+const (
+ verifyCodeExpiry = 10 * time.Minute
+ verifyCodeLength = 6
+ verifyCodeSubject = "ntfy email verification"
+)
+
+// Config holds the SMTP configuration for the mail sender
+type Config struct {
+ SMTPAddr string // SMTP server address (host:port)
+ SMTPUser string // SMTP auth username
+ SMTPPass string // SMTP auth password
+ From string // Sender email address
+}
+
+// Sender sends emails and manages email verification codes
+type Sender struct {
+ config *Config
+ codes map[string]verifyCode // Verification codes, keyed by email
+ mu sync.Mutex
+ closeChan chan struct{}
+}
+
+type verifyCode struct {
+ code string
+ expires time.Time
+}
+
+// NewSender creates a new mail Sender with the given SMTP config
+func NewSender(config *Config) *Sender {
+ s := &Sender{
+ config: config,
+ codes: make(map[string]verifyCode),
+ closeChan: make(chan struct{}),
+ }
+ go s.expireLoop()
+ return s
+}
+
+// Close stops the background expiry loop
+func (s *Sender) Close() {
+ close(s.closeChan)
+}
+
+// Addr returns the SMTP server address
+func (s *Sender) Addr() string {
+ return s.config.SMTPAddr
+}
+
+// User returns the SMTP username
+func (s *Sender) User() string {
+ return s.config.SMTPUser
+}
+
+// From returns the sender email address
+func (s *Sender) From() string {
+ return s.config.From
+}
+
+// SendRaw sends a raw email message via SMTP
+func (s *Sender) SendRaw(to string, message []byte) error {
+ host, _, err := net.SplitHostPort(s.config.SMTPAddr)
+ if err != nil {
+ return err
+ }
+ var auth smtp.Auth
+ if s.config.SMTPUser != "" {
+ auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host)
+ }
+ return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, message)
+}
+
+// Send sends a plain text email via SMTP
+func (s *Sender) Send(to, subject, body string) error {
+ date := time.Now().UTC().Format(time.RFC1123Z)
+ encodedSubject := mime.BEncoding.Encode("utf-8", subject)
+ message := `From: ntfy <{from}>
+To: {to}
+Date: {date}
+Subject: {subject}
+Content-Type: text/plain; charset="utf-8"
+
+{body}`
+ message = strings.ReplaceAll(message, "{from}", s.config.From)
+ message = strings.ReplaceAll(message, "{to}", to)
+ message = strings.ReplaceAll(message, "{date}", date)
+ message = strings.ReplaceAll(message, "{subject}", encodedSubject)
+ message = strings.ReplaceAll(message, "{body}", body)
+ log.Tag("mail").Field("email_to", to).Debug("Sending email")
+ return s.SendRaw(to, []byte(message))
+}
+
+// SendVerification generates a random code, stores it in-memory, and sends a verification email
+func (s *Sender) SendVerification(to string) error {
+ code := util.RandomString(verifyCodeLength)
+ s.mu.Lock()
+ s.codes[to] = verifyCode{
+ code: code,
+ expires: time.Now().Add(verifyCodeExpiry),
+ }
+ s.mu.Unlock()
+ body := fmt.Sprintf("Your ntfy email verification code is: %s\n\nThis code expires in 10 minutes.", code)
+ return s.Send(to, verifyCodeSubject, body)
+}
+
+// CheckVerification checks if the code matches and hasn't expired. Removes the entry on success.
+func (s *Sender) CheckVerification(email, code string) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ vc, ok := s.codes[email]
+ if !ok || time.Now().After(vc.expires) || vc.code != code {
+ return false
+ }
+ delete(s.codes, email)
+ return true
+}
+
+func (s *Sender) expireLoop() {
+ ticker := time.NewTicker(time.Minute)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ s.expireVerificationCodes()
+ case <-s.closeChan:
+ return
+ }
+ }
+}
+
+func (s *Sender) expireVerificationCodes() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ now := time.Now()
+ for email, vc := range s.codes {
+ if now.After(vc.expires) {
+ delete(s.codes, email)
+ }
+ }
+}
diff --git a/server/config.go b/server/config.go
index 8497b18e..f472930a 100644
--- a/server/config.go
+++ b/server/config.go
@@ -135,6 +135,7 @@ type Config struct {
SMTPSenderUser string
SMTPSenderPass string
SMTPSenderFrom string
+ SMTPSenderVerify bool
SMTPServerListen string
SMTPServerDomain string
SMTPServerAddrPrefix string
@@ -239,6 +240,7 @@ func NewConfig() *Config {
SMTPSenderUser: "",
SMTPSenderPass: "",
SMTPSenderFrom: "",
+ SMTPSenderVerify: false,
SMTPServerListen: "",
SMTPServerDomain: "",
SMTPServerAddrPrefix: "",
diff --git a/server/errors.go b/server/errors.go
index 77caf239..aab51df4 100644
--- a/server/errors.go
+++ b/server/errors.go
@@ -143,6 +143,9 @@ var (
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil}
errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
+ errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification code invalid or expired", "", nil}
+ errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
+ errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
@@ -152,6 +155,7 @@ var (
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil}
errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil}
+ errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil}
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
diff --git a/server/server.go b/server/server.go
index ff631359..78a668bd 100644
--- a/server/server.go
+++ b/server/server.go
@@ -36,6 +36,7 @@ import (
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/log"
+ "heckel.io/ntfy/v2/mail"
"heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/payments"
@@ -57,6 +58,7 @@ type Server struct {
smtpServer *smtp.Server
smtpServerBackend *smtpBackend
smtpSender mailer
+ mailSender *mail.Sender
topics map[string]*topic
visitors map[string]*visitor // ip:
or user:
firebaseClient *firebaseClient
@@ -112,6 +114,8 @@ var (
apiAccountReservationPath = "/v1/account/reservation"
apiAccountPhonePath = "/v1/account/phone"
apiAccountPhoneVerifyPath = "/v1/account/phone/verify"
+ apiAccountEmailPath = "/v1/account/email"
+ apiAccountEmailVerifyPath = "/v1/account/email/verify"
apiAccountBillingPortalPath = "/v1/account/billing/portal"
apiAccountBillingWebhookPath = "/v1/account/billing/webhook"
apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription"
@@ -173,8 +177,15 @@ const (
// subscriber (if configured).
func New(conf *Config) (*Server, error) {
var mailer mailer
+ var mailSender *mail.Sender
if conf.SMTPSenderAddr != "" {
- mailer = &smtpSender{config: conf}
+ mailSender = mail.NewSender(&mail.Config{
+ SMTPAddr: conf.SMTPSenderAddr,
+ SMTPUser: conf.SMTPSenderUser,
+ SMTPPass: conf.SMTPSenderPass,
+ From: conf.SMTPSenderFrom,
+ })
+ mailer = &smtpSender{config: conf, sender: mailSender}
}
var stripe stripeAPI
if payments.Available && conf.StripeSecretKey != "" {
@@ -278,6 +289,7 @@ func New(conf *Config) (*Server, error) {
attachment: attachmentStore,
firebaseClient: firebaseClient,
smtpSender: mailer,
+ mailSender: mailSender,
topics: topics,
userManager: userManager,
messages: messages,
@@ -429,6 +441,9 @@ func (s *Server) Stop() {
if s.smtpServer != nil {
s.smtpServer.Close()
}
+ if s.mailSender != nil {
+ s.mailSender.Close()
+ }
if s.attachment != nil {
s.attachment.Close()
}
@@ -594,6 +609,12 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v)
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath {
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v)
+ } else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailVerifyPath {
+ return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailVerify)))(w, r, v)
+ } else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailPath {
+ return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailAdd)))(w, r, v)
+ } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountEmailPath {
+ return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailDelete)))(w, r, v)
} else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path {
return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v)
} else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path {
@@ -700,6 +721,7 @@ func (s *Server) configResponse() *apiConfigResponse {
EnablePayments: s.config.StripeSecretKey != "",
EnableCalls: s.config.TwilioAccount != "",
EnableEmails: s.config.SMTPSenderFrom != "",
+ EnableEmailVerify: s.config.SMTPSenderVerify,
EnableReservations: s.config.EnableReservations,
EnableWebPush: s.config.WebPushPublicKey != "",
BillingContact: s.config.BillingContact,
@@ -865,9 +887,17 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
} else if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() {
return nil, errHTTPTooManyRequestsLimitMessages.With(t)
- } else if email != "" && !vrate.EmailAllowed() {
- return nil, errHTTPTooManyRequestsLimitEmails.With(t)
- } else if call != "" {
+ }
+ if email != "" {
+ var httpErr *errHTTP
+ email, httpErr = s.convertEmailAddress(v.User(), email)
+ if httpErr != nil {
+ return nil, httpErr.With(t)
+ } else if !vrate.EmailAllowed() {
+ return nil, errHTTPTooManyRequestsLimitEmails.With(t)
+ }
+ }
+ if call != "" {
var httpErr *errHTTP
call, httpErr = s.convertPhoneNumber(v.User(), call)
if httpErr != nil {
@@ -1075,7 +1105,7 @@ func (s *Server) sendToFirebase(v *visitor, m *model.Message) {
}
func (s *Server) sendEmail(v *visitor, m *model.Message, email string) {
- logvm(v, m).Tag(tagEmail).Field("email", email).Debug("Sending email to %s", email)
+ logvm(v, m).Tag(tagEmail).Field("email", email).Info("Sending email to %s", email)
if err := s.smtpSender.Send(v, m, email); err != nil {
logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error())
minc(metricEmailsPublishedFailure)
@@ -1173,7 +1203,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo
m.Icon = icon
}
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
- if email != "" && !emailAddressRegex.MatchString(email) {
+ if email != "" && !emailAddressRegex.MatchString(email) && !toBool(email) {
return false, false, "", "", "", false, "", errHTTPBadRequestEmailAddressInvalid
}
if s.smtpSender == nil && email != "" {
diff --git a/server/server.yml b/server/server.yml
index 9dc92968..833f1bea 100644
--- a/server/server.yml
+++ b/server/server.yml
@@ -193,11 +193,14 @@
# - smtp-sender-addr is the hostname:port of the SMTP server
# - smtp-sender-from is the e-mail address of the sender
# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user (leave blank for no auth)
+# - smtp-sender-verify is a flag that forces email recipient verification when enabled. If set to true,
+# only verified email recipients can be used in the X-Email header.
#
# smtp-sender-addr:
# smtp-sender-from:
# smtp-sender-user:
# smtp-sender-pass:
+# smtp-sender-verify: false
# 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.
diff --git a/server/server_account.go b/server/server_account.go
index 7b719533..7def814d 100644
--- a/server/server_account.go
+++ b/server/server_account.go
@@ -160,6 +160,15 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
response.PhoneNumbers = phoneNumbers
}
}
+ if s.mailSender != nil {
+ emails, err := s.userManager.Emails(u.ID)
+ if err != nil {
+ return err
+ }
+ if len(emails) > 0 {
+ response.Emails = emails
+ }
+ }
} else {
response.Username = user.Everyone
response.Role = string(user.RoleAnonymous)
@@ -606,6 +615,103 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R
return s.writeJSON(w, newSuccessResponse())
}
+func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
+ u := v.User()
+ req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
+ if err != nil {
+ return err
+ } else if !emailAddressRegex.MatchString(req.Email) {
+ return errHTTPBadRequestEmailAddressInvalid
+ }
+ // Check user is allowed to add emails
+ if u == nil {
+ return errHTTPUnauthorized
+ } else if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 {
+ return errHTTPUnauthorized
+ } else if u.IsUser() && u.Tier == nil && s.config.VisitorEmailLimitBurst == 0 {
+ return errHTTPUnauthorized
+ }
+ // Check if email already exists
+ emails, err := s.userManager.Emails(u.ID)
+ if err != nil {
+ return err
+ } else if util.Contains(emails, req.Email) {
+ return errHTTPConflictEmailExists
+ }
+ // Check email rate limit (counts against the user's email quota)
+ if !v.EmailAllowed() {
+ return errHTTPTooManyRequestsLimitEmails
+ }
+ // Send verification email
+ logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Sending email verification")
+ if err := s.mailSender.SendVerification(req.Email); err != nil {
+ return err
+ }
+ return s.writeJSON(w, newSuccessResponse())
+}
+
+func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
+ u := v.User()
+ req, err := readJSONWithLimit[apiAccountEmailAddRequest](r.Body, jsonBodyBytesLimit, false)
+ if err != nil {
+ return err
+ } else if !emailAddressRegex.MatchString(req.Email) {
+ return errHTTPBadRequestEmailAddressInvalid
+ } else if !s.mailSender.CheckVerification(req.Email, req.Code) {
+ return errHTTPBadRequestEmailVerificationCodeInvalid
+ }
+ logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Adding email as verified")
+ if err := s.userManager.AddEmail(u.ID, req.Email); err != nil {
+ return err
+ }
+ return s.writeJSON(w, newSuccessResponse())
+}
+
+func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
+ u := v.User()
+ req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
+ if err != nil {
+ return err
+ }
+ if !emailAddressRegex.MatchString(req.Email) {
+ return errHTTPBadRequestEmailAddressInvalid
+ }
+ logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting verified email")
+ if err := s.userManager.RemoveEmail(u.ID, req.Email); err != nil {
+ return err
+ }
+ return s.writeJSON(w, newSuccessResponse())
+}
+
+// convertEmailAddress checks the email address against the user's verified email list.
+// If smtp-sender-verify is false (default), the email is passed through as-is for
+// backwards compatibility. If true, the user must be authenticated and the email must be
+// in their verified list. "yes"/"true"/"1" resolves to the first verified email.
+func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) {
+ if !s.config.SMTPSenderVerify {
+ if toBool(email) {
+ return "", errHTTPBadRequestEmailAddressInvalid
+ }
+ return email, nil
+ } else if u == nil {
+ return "", errHTTPBadRequestAnonymousEmailNotAllowed
+ } else if s.userManager == nil {
+ return email, nil
+ }
+ emails, err := s.userManager.Emails(u.ID)
+ if err != nil {
+ return "", errHTTPInternalError
+ } else if len(emails) == 0 {
+ return "", errHTTPBadRequestEmailAddressNotVerified
+ }
+ if toBool(email) {
+ return emails[0], nil
+ } else if util.Contains(emails, email) {
+ return email, nil
+ }
+ return "", errHTTPBadRequestEmailAddressNotVerified
+}
+
// publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic
func (s *Server) publishSyncEventAsync(v *visitor) {
go func() {
diff --git a/server/server_middleware.go b/server/server_middleware.go
index 17ae0963..07457f2f 100644
--- a/server/server_middleware.go
+++ b/server/server_middleware.go
@@ -103,6 +103,15 @@ func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc {
}
}
+func (s *Server) ensureEmailsEnabled(next handleFunc) handleFunc {
+ return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
+ if s.mailSender == nil || s.userManager == nil {
+ return errHTTPNotFound
+ }
+ return next(w, r, v)
+ }
+}
+
func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.config.StripeSecretKey == "" || s.stripe == nil {
diff --git a/server/server_test.go b/server/server_test.go
index 384be7dc..133517d9 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -1567,6 +1567,177 @@ func TestServer_PublishEmailAddressInvalid(t *testing.T) {
})
}
+func TestServer_PublishEmailVerify_VerifiedAddress(t *testing.T) {
+ forEachBackend(t, func(t *testing.T, databaseURL string) {
+ conf := newTestConfigWithAuthFile(t, databaseURL)
+ conf.SMTPSenderVerify = true
+ s := newTestServer(t, conf)
+ s.smtpSender = &testMailer{}
+ defer s.closeDatabases()
+
+ require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
+ u, err := s.userManager.User("phil")
+ require.Nil(t, err)
+ require.Nil(t, s.userManager.AddEmail(u.ID, "phil@example.com"))
+
+ // Verified address should succeed
+ response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
+ "Email": "phil@example.com",
+ "Authorization": util.BasicAuth("phil", "phil"),
+ })
+ require.Equal(t, 200, response.Code)
+
+ // Unverified address should fail
+ response = request(t, s, "PUT", "/mytopic", "hi", map[string]string{
+ "Email": "other@example.com",
+ "Authorization": util.BasicAuth("phil", "phil"),
+ })
+ require.Equal(t, 400, response.Code)
+ require.Equal(t, 40052, toHTTPError(t, response.Body.String()).Code)
+ })
+}
+
+func TestServer_PublishEmailVerify_BoolValue(t *testing.T) {
+ forEachBackend(t, func(t *testing.T, databaseURL string) {
+ conf := newTestConfigWithAuthFile(t, databaseURL)
+ conf.SMTPSenderVerify = true
+ s := newTestServer(t, conf)
+ s.smtpSender = &testMailer{}
+ defer s.closeDatabases()
+
+ require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
+ u, err := s.userManager.User("phil")
+ require.Nil(t, err)
+ require.Nil(t, s.userManager.AddEmail(u.ID, "phil@example.com"))
+
+ // "yes" should resolve to first verified email
+ response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
+ "Email": "yes",
+ "Authorization": util.BasicAuth("phil", "phil"),
+ })
+ require.Equal(t, 200, response.Code)
+
+ // "true" and "1" should also work
+ for _, val := range []string{"true", "1"} {
+ response = request(t, s, "PUT", "/mytopic", "hi", map[string]string{
+ "Email": val,
+ "Authorization": util.BasicAuth("phil", "phil"),
+ })
+ require.Equal(t, 200, response.Code, "expected 200 for email: %s", val)
+ }
+ })
+}
+
+func TestServer_PublishEmailVerify_BoolValue_NoVerify(t *testing.T) {
+ forEachBackend(t, func(t *testing.T, databaseURL string) {
+ s := newTestServer(t, newTestConfig(t, databaseURL))
+ s.smtpSender = &testMailer{}
+
+ // "yes" without smtp-sender-verify should fail with invalid address
+ response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
+ "Email": "yes",
+ })
+ require.Equal(t, 400, response.Code)
+ require.Equal(t, 40050, toHTTPError(t, response.Body.String()).Code)
+ })
+}
+
+func TestServer_PublishEmailVerify_Anonymous(t *testing.T) {
+ forEachBackend(t, func(t *testing.T, databaseURL string) {
+ conf := newTestConfigWithAuthFile(t, databaseURL)
+ conf.SMTPSenderVerify = true
+ s := newTestServer(t, conf)
+ s.smtpSender = &testMailer{}
+ defer s.closeDatabases()
+
+ // Anonymous user should be rejected
+ response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
+ "Email": "test@example.com",
+ })
+ require.Equal(t, 400, response.Code)
+ require.Equal(t, 40053, toHTTPError(t, response.Body.String()).Code)
+ })
+}
+
+func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) {
+ forEachBackend(t, func(t *testing.T, databaseURL string) {
+ conf := newTestConfigWithAuthFile(t, databaseURL)
+ conf.SMTPSenderVerify = true
+ s := newTestServer(t, conf)
+ s.smtpSender = &testMailer{}
+ defer s.closeDatabases()
+
+ require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
+
+ // Authenticated user with no verified emails should fail
+ response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
+ "Email": "phil@example.com",
+ "Authorization": util.BasicAuth("phil", "phil"),
+ })
+ require.Equal(t, 400, response.Code)
+ require.Equal(t, 40052, toHTTPError(t, response.Body.String()).Code)
+ })
+}
+
+func TestServer_PublishEmailVerify_Disabled_Backwards_Compatible(t *testing.T) {
+ forEachBackend(t, func(t *testing.T, databaseURL string) {
+ s := newTestServer(t, newTestConfig(t, databaseURL))
+ s.smtpSender = &testMailer{}
+
+ // Without smtp-sender-verify, any email address should work (backwards compatible)
+ response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
+ "Email": "anyone@example.com",
+ })
+ require.Equal(t, 200, response.Code)
+ })
+}
+
+func TestServer_AccountEmailVerify_UserWithoutTier(t *testing.T) {
+ // This test verifies that an authenticated user WITHOUT a tier can verify emails
+ // when the default visitor email limit allows it.
+ forEachBackend(t, func(t *testing.T, databaseURL string) {
+ conf := newTestConfigWithAuthFile(t, databaseURL)
+ conf.SMTPSenderVerify = true
+ conf.SMTPSenderAddr = "localhost:25" // Dummy SMTP server (will fail to send, but that's ok)
+ conf.SMTPSenderFrom = "noreply@example.com"
+ s := newTestServer(t, conf)
+ defer s.closeDatabases()
+
+ // Create a user without a tier
+ require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
+
+ // Verify email request should NOT return 401
+ response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{
+ "Authorization": util.BasicAuth("ben", "ben"),
+ })
+ // The request will fail (SMTP not available), but it must NOT be a 401
+ require.NotEqual(t, 401, response.Code)
+ })
+}
+
+func TestServer_AccountEmailVerify_UserWithoutTier_EmailLimitZero(t *testing.T) {
+ // This test verifies that a tier-less user is rejected when the server's
+ // visitor email limit is zero (email sending disabled).
+ forEachBackend(t, func(t *testing.T, databaseURL string) {
+ conf := newTestConfigWithAuthFile(t, databaseURL)
+ conf.SMTPSenderVerify = true
+ conf.SMTPSenderAddr = "localhost:25"
+ conf.SMTPSenderFrom = "noreply@example.com"
+ conf.VisitorEmailLimitBurst = 0
+ s := newTestServer(t, conf)
+ defer s.closeDatabases()
+
+ // Create a user without a tier
+ require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
+
+ // Should be rejected with 401 since email sending is disabled
+ response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{
+ "Authorization": util.BasicAuth("ben", "ben"),
+ })
+ require.Equal(t, 401, response.Code)
+ })
+}
+
func TestServer_PublishAndExpungeTopicAfter16Hours(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
diff --git a/server/smtp_sender.go b/server/smtp_sender.go
index 4e5988ba..885f806b 100644
--- a/server/smtp_sender.go
+++ b/server/smtp_sender.go
@@ -5,13 +5,12 @@ import (
"encoding/json"
"fmt"
"mime"
- "net"
- "net/smtp"
"strings"
"sync"
"time"
"heckel.io/ntfy/v2/log"
+ "heckel.io/ntfy/v2/mail"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/util"
)
@@ -23,6 +22,7 @@ type mailer interface {
type smtpSender struct {
config *Config
+ sender *mail.Sender
success int64
failure int64
mu sync.Mutex
@@ -30,31 +30,22 @@ type smtpSender struct {
func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error {
return s.withCount(v, m, func() error {
- host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
+ message, err := formatMail(s.config.BaseURL, v.ip.String(), s.sender.From(), to, m)
if err != nil {
return err
}
- message, err := formatMail(s.config.BaseURL, v.ip.String(), s.config.SMTPSenderFrom, to, m)
- if err != nil {
- return err
- }
- var auth smtp.Auth
- if s.config.SMTPSenderUser != "" {
- auth = smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
- }
ev := logvm(v, m).
Tag(tagEmail).
Fields(log.Context{
- "email_via": s.config.SMTPSenderAddr,
- "email_user": s.config.SMTPSenderUser,
+ "email_via": s.sender.Addr(),
+ "email_user": s.sender.User(),
"email_to": to,
})
if ev.IsTrace() {
ev.Field("email_body", message).Trace("Sending email")
- } else if ev.IsDebug() {
- ev.Debug("Sending email")
}
- return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
+ ev.Info("Sending email")
+ return s.sender.SendRaw(to, []byte(message))
})
}
diff --git a/server/types.go b/server/types.go
index 77a3c33e..1f69d3de 100644
--- a/server/types.go
+++ b/server/types.go
@@ -226,6 +226,15 @@ type apiAccountPhoneNumberAddRequest struct {
Code string `json:"code"` // Only set when adding a phone number
}
+type apiAccountEmailVerifyRequest struct {
+ Email string `json:"email"`
+}
+
+type apiAccountEmailAddRequest struct {
+ Email string `json:"email"`
+ Code string `json:"code"`
+}
+
type apiAccountTier struct {
Code string `json:"code"`
Name string `json:"name"`
@@ -282,6 +291,7 @@ type apiAccountResponse struct {
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
PhoneNumbers []string `json:"phone_numbers,omitempty"`
+ Emails []string `json:"emails,omitempty"`
Tier *apiAccountTier `json:"tier,omitempty"`
Limits *apiAccountLimits `json:"limits,omitempty"`
Stats *apiAccountStats `json:"stats,omitempty"`
@@ -302,6 +312,7 @@ type apiConfigResponse struct {
EnablePayments bool `json:"enable_payments"`
EnableCalls bool `json:"enable_calls"`
EnableEmails bool `json:"enable_emails"`
+ EnableEmailVerify bool `json:"enable_email_verify"`
EnableReservations bool `json:"enable_reservations"`
EnableWebPush bool `json:"enable_web_push"`
BillingContact string `json:"billing_contact"`
diff --git a/server/visitor.go b/server/visitor.go
index 6d8fe6d1..d5e774d7 100644
--- a/server/visitor.go
+++ b/server/visitor.go
@@ -440,13 +440,17 @@ func configBasedVisitorLimits(conf *Config) *visitorLimits {
if conf.VisitorMessageDailyLimit > 0 {
messagesLimit = int64(conf.VisitorMessageDailyLimit)
}
+ var emailLimit int64
+ if conf.VisitorEmailLimitBurst > 0 {
+ emailLimit = replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish) // Approximation!
+ }
return &visitorLimits{
Basis: visitorLimitBasisIP,
RequestLimitBurst: conf.VisitorRequestLimitBurst,
RequestLimitReplenish: rate.Every(conf.VisitorRequestLimitReplenish),
MessageLimit: messagesLimit,
MessageExpiryDuration: conf.CacheDuration,
- EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation!
+ EmailLimit: emailLimit,
EmailLimitBurst: conf.VisitorEmailLimitBurst,
EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish),
CallLimit: visitorDefaultCallsLimit,
diff --git a/user/manager.go b/user/manager.go
index 99bd705e..303c7a49 100644
--- a/user/manager.go
+++ b/user/manager.go
@@ -1294,6 +1294,56 @@ func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) {
return phoneNumber, nil
}
+// Emails returns all verified email addresses for the user with the given user ID
+func (a *Manager) Emails(userID string) ([]string, error) {
+ rows, err := a.db.ReadOnly().Query(a.queries.selectEmails, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ emails := make([]string, 0)
+ for {
+ email, err := a.readEmail(rows)
+ if errors.Is(err, ErrEmailNotFound) {
+ break
+ } else if err != nil {
+ return nil, err
+ }
+ emails = append(emails, email)
+ }
+ return emails, nil
+}
+
+// AddEmail adds a verified email address to the user with the given user ID
+func (a *Manager) AddEmail(userID, email string) error {
+ if _, err := a.db.Exec(a.queries.insertEmail, userID, email); err != nil {
+ if isUniqueConstraintError(err) {
+ return ErrEmailExists
+ }
+ return err
+ }
+ return nil
+}
+
+// RemoveEmail deletes a verified email address from the user with the given user ID
+func (a *Manager) RemoveEmail(userID, email string) error {
+ _, err := a.db.Exec(a.queries.deleteEmail, userID, email)
+ return err
+}
+
+func (a *Manager) readEmail(rows *sql.Rows) (string, error) {
+ var email string
+ if !rows.Next() {
+ return "", ErrEmailNotFound
+ }
+ if err := rows.Scan(&email); err != nil {
+ return "", err
+ } else if err := rows.Err(); err != nil {
+ return "", err
+ }
+ return email, nil
+}
+
// ChangeBilling updates a user's billing fields
func (a *Manager) ChangeBilling(username string, billing *Billing) error {
if _, err := a.db.Exec(a.queries.updateBilling, nullString(billing.StripeCustomerID), nullString(billing.StripeSubscriptionID), nullString(string(billing.StripeSubscriptionStatus)), nullString(string(billing.StripeSubscriptionInterval)), nullInt64(billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(billing.StripeSubscriptionCancelAt.Unix()), username); err != nil {
diff --git a/user/manager_postgres.go b/user/manager_postgres.go
index 77c35ece..02cffd84 100644
--- a/user/manager_postgres.go
+++ b/user/manager_postgres.go
@@ -208,6 +208,11 @@ const (
postgresInsertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES ($1, $2)`
postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2`
+ // Email queries
+ postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email`
+ postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)`
+ postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2`
+
// Billing queries
postgresUpdateBillingQuery = `
UPDATE "user"
@@ -274,6 +279,9 @@ var postgresQueries = queries{
selectPhoneNumbers: postgresSelectPhoneNumbersQuery,
insertPhoneNumber: postgresInsertPhoneNumberQuery,
deletePhoneNumber: postgresDeletePhoneNumberQuery,
+ selectEmails: postgresSelectEmailsQuery,
+ insertEmail: postgresInsertEmailQuery,
+ deleteEmail: postgresDeleteEmailQuery,
updateBilling: postgresUpdateBillingQuery,
}
diff --git a/user/manager_postgres_schema.go b/user/manager_postgres_schema.go
index 3684c279..ba8502f2 100644
--- a/user/manager_postgres_schema.go
+++ b/user/manager_postgres_schema.go
@@ -72,6 +72,11 @@ const (
phone_number TEXT NOT NULL,
PRIMARY KEY (user_id, phone_number)
);
+ CREATE TABLE IF NOT EXISTS user_email (
+ user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
+ email TEXT NOT NULL,
+ PRIMARY KEY (user_id, email)
+ );
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY,
version INT NOT NULL
@@ -84,21 +89,55 @@ const (
// Schema table management queries for Postgres
const (
- postgresCurrentSchemaVersion = 6
+ postgresCurrentSchemaVersion = 7
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'`
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)`
)
+const (
+ postgresMigrate6To7UpdateQueries = `
+ CREATE TABLE IF NOT EXISTS user_email (
+ user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
+ email TEXT NOT NULL,
+ PRIMARY KEY (user_id, email)
+ );
+ `
+ postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'user'`
+)
+
+var postgresMigrations = map[int]func(db *sql.DB) error{
+ 6: postgresMigrateFrom6,
+}
+
func setupPostgres(db *sql.DB) error {
var schemaVersion int
err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion)
if err != nil {
return setupNewPostgres(db)
}
- if schemaVersion > postgresCurrentSchemaVersion {
+ if schemaVersion == postgresCurrentSchemaVersion {
+ return nil
+ } else if schemaVersion > postgresCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion)
}
- // Note: PostgreSQL migrations will be added when needed
+ for i := schemaVersion; i < postgresCurrentSchemaVersion; i++ {
+ fn, ok := postgresMigrations[i]
+ if !ok {
+ return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
+ } else if err := fn(db); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func postgresMigrateFrom6(db *sql.DB) error {
+ if _, err := db.Exec(postgresMigrate6To7UpdateQueries); err != nil {
+ return err
+ }
+ if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 7); err != nil {
+ return err
+ }
return nil
}
diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go
index e92c6349..0f1a9227 100644
--- a/user/manager_sqlite.go
+++ b/user/manager_sqlite.go
@@ -207,6 +207,11 @@ const (
sqliteInsertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)`
sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
+ // Email queries
+ sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email`
+ sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)`
+ sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?`
+
// Billing queries
sqliteUpdateBillingQuery = `
UPDATE user
@@ -272,6 +277,9 @@ var sqliteQueries = queries{
selectPhoneNumbers: sqliteSelectPhoneNumbersQuery,
insertPhoneNumber: sqliteInsertPhoneNumberQuery,
deletePhoneNumber: sqliteDeletePhoneNumberQuery,
+ selectEmails: sqliteSelectEmailsQuery,
+ insertEmail: sqliteInsertEmailQuery,
+ deleteEmail: sqliteDeleteEmailQuery,
updateBilling: sqliteUpdateBillingQuery,
}
diff --git a/user/manager_sqlite_schema.go b/user/manager_sqlite_schema.go
index 01942163..6ee24f8c 100644
--- a/user/manager_sqlite_schema.go
+++ b/user/manager_sqlite_schema.go
@@ -85,6 +85,12 @@ const (
PRIMARY KEY (user_id, phone_number),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
+ CREATE TABLE IF NOT EXISTS user_email (
+ user_id TEXT NOT NULL,
+ email TEXT NOT NULL,
+ PRIMARY KEY (user_id, email),
+ FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
+ );
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
@@ -101,7 +107,7 @@ const (
// Schema version table management for SQLite
const (
- sqliteCurrentSchemaVersion = 6
+ sqliteCurrentSchemaVersion = 7
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
@@ -220,6 +226,16 @@ const (
UPDATE user_access SET topic = REPLACE(topic, '_', '\_');
`
+ // 6 -> 7
+ sqliteMigrate6To7UpdateQueries = `
+ CREATE TABLE IF NOT EXISTS user_email (
+ user_id TEXT NOT NULL,
+ email TEXT NOT NULL,
+ PRIMARY KEY (user_id, email),
+ FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
+ );
+ `
+
// 5 -> 6
sqliteMigrate5To6UpdateQueries = `
PRAGMA foreign_keys=off;
@@ -322,6 +338,7 @@ var (
3: sqliteMigrateFrom3,
4: sqliteMigrateFrom4,
5: sqliteMigrateFrom5,
+ 6: sqliteMigrateFrom6,
}
)
@@ -463,3 +480,16 @@ func sqliteMigrateFrom5(sqlDB *sql.DB) error {
return nil
})
}
+
+func sqliteMigrateFrom6(sqlDB *sql.DB) error {
+ log.Tag(tag).Info("Migrating user database schema: from 6 to 7")
+ return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
+ if _, err := tx.Exec(sqliteMigrate6To7UpdateQueries); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 7); err != nil {
+ return err
+ }
+ return nil
+ })
+}
diff --git a/user/manager_test.go b/user/manager_test.go
index c8e619cf..3bdb15b2 100644
--- a/user/manager_test.go
+++ b/user/manager_test.go
@@ -1137,6 +1137,60 @@ func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) {
})
}
+func TestUser_EmailAddListRemove(t *testing.T) {
+ forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
+ a := newTestManager(t, newManager, PermissionDenyAll)
+
+ require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
+ phil, err := a.User("phil")
+ require.Nil(t, err)
+ require.Nil(t, a.AddEmail(phil.ID, "phil@example.com"))
+
+ emails, err := a.Emails(phil.ID)
+ require.Nil(t, err)
+ require.Equal(t, 1, len(emails))
+ require.Equal(t, "phil@example.com", emails[0])
+
+ require.Nil(t, a.RemoveEmail(phil.ID, "phil@example.com"))
+ emails, err = a.Emails(phil.ID)
+ require.Nil(t, err)
+ require.Equal(t, 0, len(emails))
+
+ // Paranoia check: We do NOT want to keep emails in there
+ rows, err := testDB(a).Query(`SELECT * FROM user_email`)
+ require.Nil(t, err)
+ require.False(t, rows.Next())
+ require.Nil(t, rows.Close())
+ })
+}
+
+func TestUser_EmailAdd_Multiple_Users_Same_Email(t *testing.T) {
+ forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
+ a := newTestManager(t, newManager, PermissionDenyAll)
+
+ require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
+ require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
+ phil, err := a.User("phil")
+ require.Nil(t, err)
+ ben, err := a.User("ben")
+ require.Nil(t, err)
+ require.Nil(t, a.AddEmail(phil.ID, "shared@example.com"))
+ require.Nil(t, a.AddEmail(ben.ID, "shared@example.com"))
+ })
+}
+
+func TestUser_EmailAdd_Duplicate(t *testing.T) {
+ forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
+ a := newTestManager(t, newManager, PermissionDenyAll)
+
+ require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
+ phil, err := a.User("phil")
+ require.Nil(t, err)
+ require.Nil(t, a.AddEmail(phil.ID, "phil@example.com"))
+ require.ErrorIs(t, a.AddEmail(phil.ID, "phil@example.com"), ErrEmailExists)
+ })
+}
+
func TestManager_Topic_Wildcard_With_Asterisk_Underscore(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
@@ -2328,6 +2382,27 @@ func TestStorePhoneNumbers(t *testing.T) {
})
}
+func TestStoreEmails(t *testing.T) {
+ forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
+ require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
+ u, err := manager.User("phil")
+ require.Nil(t, err)
+
+ require.Nil(t, manager.AddEmail(u.ID, "phil@example.com"))
+ require.Nil(t, manager.AddEmail(u.ID, "phil2@example.com"))
+
+ emails, err := manager.Emails(u.ID)
+ require.Nil(t, err)
+ require.Len(t, emails, 2)
+
+ require.Nil(t, manager.RemoveEmail(u.ID, "phil@example.com"))
+ emails, err = manager.Emails(u.ID)
+ require.Nil(t, err)
+ require.Len(t, emails, 1)
+ require.Equal(t, "phil2@example.com", emails[0])
+ })
+}
+
func TestStoreChangeSettings(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
diff --git a/user/types.go b/user/types.go
index 08c65220..d0d40e33 100644
--- a/user/types.go
+++ b/user/types.go
@@ -271,6 +271,8 @@ var (
ErrPhoneNumberNotFound = errors.New("phone number not found")
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
ErrPhoneNumberExists = errors.New("phone number already exists")
+ ErrEmailNotFound = errors.New("email not found")
+ ErrEmailExists = errors.New("email already exists")
ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user")
ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token")
)
@@ -343,6 +345,11 @@ type queries struct {
insertPhoneNumber string
deletePhoneNumber string
+ // Email queries
+ selectEmails string
+ insertEmail string
+ deleteEmail string
+
// Billing queries
updateBilling string
}
diff --git a/web/package-lock.json b/web/package-lock.json
index cdf02942..247b84a2 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -2738,9 +2738,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
- "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
+ "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
"cpu": [
"arm"
],
@@ -2752,9 +2752,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
- "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
+ "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
"cpu": [
"arm64"
],
@@ -2766,9 +2766,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
- "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
+ "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
"cpu": [
"arm64"
],
@@ -2780,9 +2780,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
- "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
+ "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
"cpu": [
"x64"
],
@@ -2794,9 +2794,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
- "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
+ "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
"cpu": [
"arm64"
],
@@ -2808,9 +2808,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
- "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
+ "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
"cpu": [
"x64"
],
@@ -2822,9 +2822,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
- "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
+ "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
"cpu": [
"arm"
],
@@ -2839,9 +2839,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
- "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
+ "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
"cpu": [
"arm"
],
@@ -2856,9 +2856,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
- "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
+ "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
"cpu": [
"arm64"
],
@@ -2873,9 +2873,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
- "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
+ "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
"cpu": [
"arm64"
],
@@ -2890,9 +2890,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
- "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
+ "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
"cpu": [
"loong64"
],
@@ -2907,9 +2907,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
- "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
+ "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
"cpu": [
"loong64"
],
@@ -2924,9 +2924,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
- "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
+ "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
"cpu": [
"ppc64"
],
@@ -2941,9 +2941,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
- "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
+ "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
"cpu": [
"ppc64"
],
@@ -2958,9 +2958,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
- "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
+ "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
"cpu": [
"riscv64"
],
@@ -2975,9 +2975,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
- "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
+ "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
"cpu": [
"riscv64"
],
@@ -2992,9 +2992,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
- "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
+ "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
"cpu": [
"s390x"
],
@@ -3009,9 +3009,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
- "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
+ "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
"cpu": [
"x64"
],
@@ -3026,9 +3026,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
- "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
+ "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
"cpu": [
"x64"
],
@@ -3043,9 +3043,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
- "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
+ "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
"cpu": [
"x64"
],
@@ -3057,9 +3057,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
- "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
+ "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
"cpu": [
"arm64"
],
@@ -3071,9 +3071,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
- "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
+ "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
"cpu": [
"arm64"
],
@@ -3085,9 +3085,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
- "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
+ "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
"cpu": [
"ia32"
],
@@ -3099,9 +3099,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
- "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
+ "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
"cpu": [
"x64"
],
@@ -3113,9 +3113,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
- "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
+ "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
"cpu": [
"x64"
],
@@ -3681,9 +3681,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.10.11",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz",
- "integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==",
+ "version": "2.10.12",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz",
+ "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -3694,9 +3694,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
+ "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3805,9 +3805,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001781",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz",
- "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==",
+ "version": "1.0.30001782",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz",
+ "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==",
"dev": true,
"funding": [
{
@@ -4242,9 +4242,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.326",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.326.tgz",
- "integrity": "sha512-uRBlUfKKdsXMkiiOurgaybNC10tjrD+skXLEg7NHbm6h0uAoqj3xMb9uue5BfcSCXJ4mcyJMOucI6q55D7p6KQ==",
+ "version": "1.5.328",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz",
+ "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==",
"dev": true,
"license": "ISC"
},
@@ -5044,9 +5044,9 @@
}
},
"node_modules/filelist/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
+ "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6299,13 +6299,6 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
},
- "node_modules/json-schema": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
- "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
- "dev": true,
- "license": "(AFL-2.1 OR BSD-3-Clause)"
- },
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -7619,9 +7612,9 @@
}
},
"node_modules/rollup": {
- "version": "4.60.0",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
- "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
+ "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7635,31 +7628,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.60.0",
- "@rollup/rollup-android-arm64": "4.60.0",
- "@rollup/rollup-darwin-arm64": "4.60.0",
- "@rollup/rollup-darwin-x64": "4.60.0",
- "@rollup/rollup-freebsd-arm64": "4.60.0",
- "@rollup/rollup-freebsd-x64": "4.60.0",
- "@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
- "@rollup/rollup-linux-arm-musleabihf": "4.60.0",
- "@rollup/rollup-linux-arm64-gnu": "4.60.0",
- "@rollup/rollup-linux-arm64-musl": "4.60.0",
- "@rollup/rollup-linux-loong64-gnu": "4.60.0",
- "@rollup/rollup-linux-loong64-musl": "4.60.0",
- "@rollup/rollup-linux-ppc64-gnu": "4.60.0",
- "@rollup/rollup-linux-ppc64-musl": "4.60.0",
- "@rollup/rollup-linux-riscv64-gnu": "4.60.0",
- "@rollup/rollup-linux-riscv64-musl": "4.60.0",
- "@rollup/rollup-linux-s390x-gnu": "4.60.0",
- "@rollup/rollup-linux-x64-gnu": "4.60.0",
- "@rollup/rollup-linux-x64-musl": "4.60.0",
- "@rollup/rollup-openbsd-x64": "4.60.0",
- "@rollup/rollup-openharmony-arm64": "4.60.0",
- "@rollup/rollup-win32-arm64-msvc": "4.60.0",
- "@rollup/rollup-win32-ia32-msvc": "4.60.0",
- "@rollup/rollup-win32-x64-gnu": "4.60.0",
- "@rollup/rollup-win32-x64-msvc": "4.60.0",
+ "@rollup/rollup-android-arm-eabi": "4.60.1",
+ "@rollup/rollup-android-arm64": "4.60.1",
+ "@rollup/rollup-darwin-arm64": "4.60.1",
+ "@rollup/rollup-darwin-x64": "4.60.1",
+ "@rollup/rollup-freebsd-arm64": "4.60.1",
+ "@rollup/rollup-freebsd-x64": "4.60.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.1",
+ "@rollup/rollup-linux-arm64-musl": "4.60.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.1",
+ "@rollup/rollup-linux-loong64-musl": "4.60.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.1",
+ "@rollup/rollup-linux-x64-gnu": "4.60.1",
+ "@rollup/rollup-linux-x64-musl": "4.60.1",
+ "@rollup/rollup-openbsd-x64": "4.60.1",
+ "@rollup/rollup-openharmony-arm64": "4.60.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.1",
+ "@rollup/rollup-win32-x64-gnu": "4.60.1",
+ "@rollup/rollup-win32-x64-msvc": "4.60.1",
"fsevents": "~2.3.2"
}
},
@@ -9133,14 +9126,13 @@
}
},
"node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": {
- "version": "0.3.6",
- "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz",
- "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==",
+ "version": "0.3.7",
+ "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz",
+ "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "json-schema": "^0.4.0",
- "jsonpointer": "^5.0.0",
+ "jsonpointer": "^5.0.1",
"leven": "^3.1.0"
},
"engines": {
diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json
index 19fe2195..2e06cc64 100644
--- a/web/public/static/langs/en.json
+++ b/web/public/static/langs/en.json
@@ -215,6 +215,19 @@
"account_basics_phone_numbers_dialog_check_verification_button": "Confirm code",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_basics_phone_numbers_dialog_channel_call": "Call",
+ "account_basics_emails_title": "Email addresses",
+ "account_basics_emails_description": "For email notifications",
+ "account_basics_emails_no_emails_yet": "No verified emails yet",
+ "account_basics_emails_copied_to_clipboard": "Email address copied to clipboard",
+ "account_basics_emails_dialog_title": "Add email address",
+ "account_basics_emails_dialog_description": "To receive email notifications, you need to add and verify at least one email address. A verification code will be sent to your email.",
+ "account_basics_emails_dialog_email_label": "Email address",
+ "account_basics_emails_dialog_email_placeholder": "e.g. user@example.com",
+ "account_basics_emails_dialog_verify_button": "Add email",
+ "account_basics_emails_dialog_code_label": "Verification code",
+ "account_basics_emails_dialog_code_placeholder": "e.g. 123456",
+ "account_basics_emails_dialog_code_invalid": "Verification code is invalid or expired",
+ "account_basics_emails_dialog_check_verification_button": "Confirm",
"account_basics_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted",
"account_usage_title": "Usage",
"account_usage_of_limit": "of {{limit}}",
@@ -238,6 +251,7 @@
"account_usage_messages_title": "Published messages",
"account_usage_emails_title": "Emails sent",
"account_usage_calls_title": "Phone calls made",
+ "account_usage_emails_none": "No email notifications can be sent with this account",
"account_usage_calls_none": "No phone calls can be made with this account",
"account_usage_reservations_title": "Reserved topics",
"account_usage_reservations_none": "No reserved topics for this account",
diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js
index d9380438..5b44391d 100644
--- a/web/src/app/AccountApi.js
+++ b/web/src/app/AccountApi.js
@@ -2,6 +2,8 @@ import i18n from "i18next";
import {
accountBillingPortalUrl,
accountBillingSubscriptionUrl,
+ accountEmailUrl,
+ accountEmailVerifyUrl,
accountPasswordUrl,
accountPhoneUrl,
accountPhoneVerifyUrl,
@@ -339,6 +341,43 @@ class AccountApi {
});
}
+ async verifyEmail(email) {
+ const url = accountEmailVerifyUrl(config.base_url);
+ console.log(`[AccountApi] Sending email verification ${url}`);
+ await fetchOrThrow(url, {
+ method: "PUT",
+ headers: withBearerAuth({}, session.token()),
+ body: JSON.stringify({
+ email,
+ }),
+ });
+ }
+
+ async addEmail(email, code) {
+ const url = accountEmailUrl(config.base_url);
+ console.log(`[AccountApi] Adding email with verification code ${url}`);
+ await fetchOrThrow(url, {
+ method: "PUT",
+ headers: withBearerAuth({}, session.token()),
+ body: JSON.stringify({
+ email,
+ code,
+ }),
+ });
+ }
+
+ async deleteEmail(email) {
+ const url = accountEmailUrl(config.base_url);
+ console.log(`[AccountApi] Deleting email ${url}`);
+ await fetchOrThrow(url, {
+ method: "DELETE",
+ headers: withBearerAuth({}, session.token()),
+ body: JSON.stringify({
+ email,
+ }),
+ });
+ }
+
async sync() {
try {
if (!session.token()) {
diff --git a/web/src/app/errors.js b/web/src/app/errors.js
index 28f49af1..4214ad84 100644
--- a/web/src/app/errors.js
+++ b/web/src/app/errors.js
@@ -47,6 +47,14 @@ export class IncorrectPasswordError extends Error {
}
}
+export class EmailVerificationCodeInvalidError extends Error {
+ static CODE = 40051; // errHTTPBadRequestEmailVerificationCodeInvalid
+
+ constructor() {
+ super("Email verification code invalid or expired");
+ }
+}
+
export const throwAppError = async (response) => {
if (response.status === 401 || response.status === 403) {
console.log(`[Error] HTTP ${response.status}`, response);
@@ -63,6 +71,8 @@ export const throwAppError = async (response) => {
throw new AccountCreateLimitReachedError();
} else if (error.code === IncorrectPasswordError.CODE) {
throw new IncorrectPasswordError();
+ } else if (error.code === EmailVerificationCodeInvalidError.CODE) {
+ throw new EmailVerificationCodeInvalidError();
} else if (error?.error) {
throw new Error(`Error ${error.code}: ${error.error}`);
}
diff --git a/web/src/app/utils.js b/web/src/app/utils.js
index 8e27365b..d6467eb7 100644
--- a/web/src/app/utils.js
+++ b/web/src/app/utils.js
@@ -34,6 +34,8 @@ export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
+export const accountEmailUrl = (baseUrl) => `${baseUrl}/v1/account/email`;
+export const accountEmailVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/email/verify`;
export const validUrl = (url) => url.match(/^https?:\/\/.+/);
diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx
index 508d6de2..29f4872c 100644
--- a/web/src/components/Account.jsx
+++ b/web/src/components/Account.jsx
@@ -53,7 +53,7 @@ import UpgradeDialog from "./UpgradeDialog";
import { AccountContext } from "./App";
import DialogFooter from "./DialogFooter";
import { Paragraph } from "./styles";
-import { IncorrectPasswordError, UnauthorizedError } from "../app/errors";
+import { EmailVerificationCodeInvalidError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
import { ProChip } from "./SubscriptionPopup";
import session from "../app/Session";
@@ -84,6 +84,7 @@ const Basics = () => {
+
@@ -354,6 +355,200 @@ const AccountType = () => {
);
};
+const Emails = () => {
+ const { t } = useTranslation();
+ const { account } = useContext(AccountContext);
+ const [dialogKey, setDialogKey] = useState(0);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [snackOpen, setSnackOpen] = useState(false);
+ const labelId = "prefVerifiedEmails";
+
+ const handleDialogOpen = () => {
+ setDialogKey((prev) => prev + 1);
+ setDialogOpen(true);
+ };
+
+ const handleDialogClose = () => {
+ setDialogOpen(false);
+ };
+
+ const handleCopy = (email) => {
+ copyToClipboard(email);
+ setSnackOpen(true);
+ };
+
+ const handleDelete = async (email) => {
+ try {
+ await accountApi.deleteEmail(email);
+ } catch (e) {
+ console.log(`[Account] Error deleting email`, e);
+ if (e instanceof UnauthorizedError) {
+ await session.resetAndRedirect(routes.login);
+ }
+ }
+ };
+
+ if (!config.enable_email_verify) {
+ return null;
+ }
+
+ if (account?.limits.emails === 0) {
+ return (
+
+ {t("account_basics_emails_title")}
+ {config.enable_payments && }
+ >
+ }
+ description={t("account_basics_emails_description")}
+ >
+ {t("account_usage_emails_none")}
+
+ );
+ }
+
+ return (
+
+
+ {account?.emails?.map((email) => (
+
+ {email}
+
+ }
+ variant="outlined"
+ onClick={() => handleCopy(email)}
+ onDelete={() => handleDelete(email)}
+ />
+ ))}
+ {!account?.emails && {t("account_basics_emails_no_emails_yet")}}
+
+
+
+
+
+
+ setSnackOpen(false)}
+ message={t("account_basics_emails_copied_to_clipboard")}
+ />
+
+
+ );
+};
+
+const AddEmailDialog = (props) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const [error, setError] = useState("");
+ const [email, setEmail] = useState("");
+ const [code, setCode] = useState("");
+ const [sending, setSending] = useState(false);
+ const [verificationCodeSent, setVerificationCodeSent] = useState(false);
+ const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
+
+ const verifyEmail = async () => {
+ try {
+ setSending(true);
+ await accountApi.verifyEmail(email);
+ setVerificationCodeSent(true);
+ } catch (e) {
+ console.log(`[Account] Error sending email verification`, e);
+ if (e instanceof UnauthorizedError) {
+ await session.resetAndRedirect(routes.login);
+ } else {
+ setError(e.message);
+ }
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const checkVerifyEmail = async () => {
+ try {
+ setSending(true);
+ await accountApi.addEmail(email, code);
+ props.onClose();
+ } catch (e) {
+ console.log(`[Account] Error confirming email verification`, e);
+ if (e instanceof UnauthorizedError) {
+ await session.resetAndRedirect(routes.login);
+ } else if (e instanceof EmailVerificationCodeInvalidError) {
+ setError(t("account_basics_emails_dialog_code_invalid"));
+ } else {
+ setError(e.message);
+ }
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const handleDialogSubmit = async () => {
+ if (!verificationCodeSent) {
+ await verifyEmail();
+ } else {
+ await checkVerifyEmail();
+ }
+ };
+
+ const handleCancel = () => {
+ if (verificationCodeSent) {
+ setVerificationCodeSent(false);
+ setCode("");
+ } else {
+ props.onClose();
+ }
+ };
+
+ return (
+
+ );
+};
+
const PhoneNumbers = () => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
@@ -750,7 +945,9 @@ const Stats = () => {
)}
{account.role === Role.USER && account.limits.basis === LimitBasis.IP && (
- {t("account_usage_basis_ip_description")}
+
+ {t("account_usage_basis_ip_description")}
+
)}
);
diff --git a/web/src/components/Navigation.jsx b/web/src/components/Navigation.jsx
index 89381cb3..dad8abe8 100644
--- a/web/src/components/Navigation.jsx
+++ b/web/src/components/Navigation.jsx
@@ -117,7 +117,8 @@ const NavList = (props) => {
const isAdmin = account?.role === Role.ADMIN;
const isPaid = account?.billing?.subscription;
- const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
+ const hasTier = !!account?.tier;
+ const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid && !hasTier;
const showSubscriptionsList = props.subscriptions?.length > 0;
const showNotificationPermissionRequired = useNotificationPermissionListener(() => notifier.notRequested());
const showNotificationPermissionDenied = useNotificationPermissionListener(() => notifier.denied());