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 ( + + {t("account_basics_emails_dialog_title")} + + {t("account_basics_emails_dialog_description")} + {!verificationCodeSent && ( + setEmail(ev.target.value)} + fullWidth + variant="standard" + /> + )} + {verificationCodeSent && ( + setCode(ev.target.value)} + fullWidth + inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} + variant="standard" + /> + )} + + + + + + + ); +}; + 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());