From 61dd788dace82b2e32ebbe0fac39b81edb086298 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 29 Mar 2026 22:47:38 -0400 Subject: [PATCH 01/10] WIP: Email verification --- cmd/serve.go | 5 + docs/config.md | 24 +++- docs/releases.md | 7 ++ docs/static/js/config-generator.js | 1 + mail/mail.go | 126 +++++++++++++++++++ server/config.go | 2 + server/errors.go | 4 + server/server.go | 32 ++++- server/server.yml | 7 ++ server/server_account.go | 102 +++++++++++++++ server/server_manager.go | 3 + server/server_middleware.go | 9 ++ server/types.go | 10 ++ user/manager.go | 50 ++++++++ user/manager_postgres.go | 8 ++ user/manager_postgres_schema.go | 45 ++++++- user/manager_sqlite.go | 8 ++ user/manager_sqlite_schema.go | 32 ++++- user/types.go | 7 ++ web/public/static/langs/en.json | 12 ++ web/src/app/AccountApi.js | 39 ++++++ web/src/app/utils.js | 2 + web/src/components/Account.jsx | 193 +++++++++++++++++++++++++++++ 23 files changed, 720 insertions(+), 8 deletions(-) create mode 100644 mail/mail.go diff --git a/cmd/serve.go b/cmd/serve.go index 2af1f389..d20242e2 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-email-verify", Aliases: []string{"smtp_sender_email_verify"}, EnvVars: []string{"NTFY_SMTP_SENDER_EMAIL_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") + smtpSenderEmailVerify := c.Bool("smtp-sender-email-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 smtpSenderEmailVerify && smtpSenderAddr == "" { + return errors.New("if smtp-sender-email-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.SMTPSenderEmailVerify = smtpSenderEmailVerify conf.SMTPServerListen = smtpServerListen conf.SMTPServerDomain = smtpServerDomain conf.SMTPServerAddrPrefix = smtpServerAddrPrefix diff --git a/docs/config.md b/docs/config.md index e7a98774..eb34382a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -353,6 +353,13 @@ This generator helps you configure your self-hosted ntfy instance. It's not full +
+ + +
@@ -1031,7 +1038,21 @@ configured for `ntfy.sh`): smtp-sender-from: "ntfy@ntfy.sh" ``` -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-email-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. + +=== "/etc/ntfy/server.yml (with email verification)" + ``` yaml + smtp-sender-addr: "email-smtp.us-east-2.amazonaws.com:587" + smtp-sender-user: "AKIDEADBEEFAFFE12345" + smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG." + smtp-sender-from: "ntfy@ntfy.sh" + smtp-sender-email-verify: true + ``` + +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 +2221,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-email-verify` | `NTFY_SMTP_SENDER_EMAIL_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/releases.md b/docs/releases.md index a8024923..8271f36b 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -110,6 +110,13 @@ if things are working (or not working). There is a [one-off migration tool](http * Preserve `
` line breaks in HTML-only emails received via SMTP ([#690](https://github.com/binwiederhier/ntfy/issues/690), [#1620](https://github.com/binwiederhier/ntfy/pull/1620), thanks to [@uzkikh](https://github.com/uzkikh) for the fix and to [@teastrainer](https://github.com/teastrainer) for reporting) +## 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) + ## ntfy Android v1.24.0 Released March 5, 2026 diff --git a/docs/static/js/config-generator.js b/docs/static/js/config-generator.js index 7c093ead..7e4c806f 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-email-verify", env: "NTFY_SMTP_SENDER_EMAIL_VERIFY", section: "smtp-out" }, { 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" }, diff --git a/mail/mail.go b/mail/mail.go new file mode 100644 index 00000000..dc26ced4 --- /dev/null +++ b/mail/mail.go @@ -0,0 +1,126 @@ +package mail + +import ( + "crypto/rand" + "fmt" + "math/big" + "mime" + "net" + "net/smtp" + "strings" + "sync" + "time" + + "heckel.io/ntfy/v2/log" +) + +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 + verifyCodes map[string]verifyCode // keyed by email + mu sync.Mutex +} + +type verifyCode struct { + code string + expires time.Time +} + +// NewSender creates a new mail Sender with the given SMTP config +func NewSender(config *Config) *Sender { + return &Sender{ + config: config, + verifyCodes: make(map[string]verifyCode), + } +} + +// Send sends a plain text email via SMTP +func (s *Sender) Send(to, subject, body string) 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) + } + 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 smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, []byte(message)) +} + +// SendVerification generates a 6-digit code, stores it in-memory, and sends a verification email +func (s *Sender) SendVerification(to string) error { + code, err := generateCode() + if err != nil { + return err + } + s.mu.Lock() + s.verifyCodes[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.verifyCodes[email] + if !ok || time.Now().After(vc.expires) || vc.code != code { + return false + } + delete(s.verifyCodes, email) + return true +} + +// ExpireVerificationCodes removes expired entries from the in-memory map +func (s *Sender) ExpireVerificationCodes() { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + for email, vc := range s.verifyCodes { + if now.After(vc.expires) { + delete(s.verifyCodes, email) + } + } +} + +func generateCode() (string, error) { + max := big.NewInt(1000000) // 0-999999 + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + return fmt.Sprintf("%06d", n.Int64()), nil +} diff --git a/server/config.go b/server/config.go index 8497b18e..0bd6bd32 100644 --- a/server/config.go +++ b/server/config.go @@ -135,6 +135,7 @@ type Config struct { SMTPSenderUser string SMTPSenderPass string SMTPSenderFrom string + SMTPSenderEmailVerify bool SMTPServerListen string SMTPServerDomain string SMTPServerAddrPrefix string @@ -239,6 +240,7 @@ func NewConfig() *Config { SMTPSenderUser: "", SMTPSenderPass: "", SMTPSenderFrom: "", + SMTPSenderEmailVerify: false, SMTPServerListen: "", SMTPServerDomain: "", SMTPServerAddrPrefix: "", diff --git a/server/errors.go b/server/errors.go index 77caf239..16acc9cd 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, or no matching verified email addresses found", "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..f265986b 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, + }) } 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, @@ -594,6 +606,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 { @@ -865,9 +883,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 != "" { + } else if email != "" { + if !vrate.EmailAllowed() { + return nil, errHTTPTooManyRequestsLimitEmails.With(t) + } + var httpErr *errHTTP + email, httpErr = s.convertEmailAddress(v.User(), email) + if httpErr != nil { + return nil, httpErr.With(t) + } + } + if call != "" { var httpErr *errHTTP call, httpErr = s.convertPhoneNumber(v.User(), call) if httpErr != nil { diff --git a/server/server.yml b/server/server.yml index 9dc92968..471d5b88 100644 --- a/server/server.yml +++ b/server/server.yml @@ -199,6 +199,13 @@ # smtp-sender-user: # smtp-sender-pass: +# If set to true, only verified email recipients will receive email notifications. +# Anonymous users will not be able to send emails, and authenticated users must verify +# their email addresses first. Users can use "yes"/"true"/"1" as the email value to +# send to their first verified address. +# +# smtp-sender-email-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..93859490 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,99 @@ 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 || (u.IsUser() && u.Tier == nil) { + return errHTTPUnauthorized + } else if u.IsUser() && u.Tier.EmailLimit == 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 + } + // Send verification email + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("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 + } + if !emailAddressRegex.MatchString(req.Email) { + return errHTTPBadRequestEmailAddressInvalid + } + if !s.mailSender.CheckVerification(req.Email, req.Code) { + return errHTTPBadRequestEmailVerificationCodeInvalid + } + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("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-email-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.SMTPSenderEmailVerify { + return email, nil + } + if u == nil { + return "", errHTTPBadRequestAnonymousEmailNotAllowed + } + if s.userManager == nil { + return email, nil + } + emails, err := s.userManager.Emails(u.ID) + if err != nil { + return "", errHTTPInternalError + } + 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_manager.go b/server/server_manager.go index 387ad2b8..5251e1aa 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -15,6 +15,9 @@ func (s *Server) execManager() { s.pruneAttachments() s.pruneMessages() s.pruneAndNotifyWebPushSubscriptions() + if s.mailSender != nil { + s.mailSender.ExpireVerificationCodes() + } // Message count messagesCached, err := s.messageCache.MessagesCount() 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/types.go b/server/types.go index 77a3c33e..c9d4688d 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"` 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..efa9998e 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` + 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..8db75cad 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 = ?` + 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/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/public/static/langs/en.json b/web/public/static/langs/en.json index 19fe2195..077d021c 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -215,6 +215,18 @@ "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": "Verified email recipients", + "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_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}}", 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/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..5b732719 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -84,6 +84,7 @@ const Basics = () => { + @@ -354,6 +355,198 @@ const AccountType = () => { ); }; +const VerifiedEmails = () => { + 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_emails) { + 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 { + 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); From 6aebc5c67749ccdc97a8ca4c8cf7f12b14dd2dd8 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 08:26:56 -0400 Subject: [PATCH 02/10] Refine --- cmd/serve.go | 10 ++--- docs/config.md | 8 ++-- docs/static/js/config-generator.js | 2 +- mail/{mail.go => sender.go} | 68 +++++++++++++++++------------- server/config.go | 4 +- server/server.go | 11 +++-- server/server.yml | 10 ++--- server/server_account.go | 17 ++++---- server/server_manager.go | 3 -- web/public/static/langs/en.json | 1 + web/src/app/errors.js | 10 +++++ web/src/components/Account.jsx | 4 +- 12 files changed, 83 insertions(+), 65 deletions(-) rename mail/{mail.go => sender.go} (72%) diff --git a/cmd/serve.go b/cmd/serve.go index d20242e2..0c0b1139 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -71,7 +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-email-verify", Aliases: []string{"smtp_sender_email_verify"}, EnvVars: []string{"NTFY_SMTP_SENDER_EMAIL_VERIFY"}, Value: false, Usage: "require verified email addresses for sending email notifications"}), + 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-')"}), @@ -185,7 +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") - smtpSenderEmailVerify := c.Bool("smtp-sender-email-verify") + 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") @@ -312,8 +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 smtpSenderEmailVerify && smtpSenderAddr == "" { - return errors.New("if smtp-sender-email-verify is set, smtp-sender-addr 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 == "" { @@ -475,7 +475,7 @@ func execServe(c *cli.Context) error { conf.SMTPSenderUser = smtpSenderUser conf.SMTPSenderPass = smtpSenderPass conf.SMTPSenderFrom = smtpSenderFrom - conf.SMTPSenderEmailVerify = smtpSenderEmailVerify + conf.SMTPSenderVerify = smtpSenderVerify conf.SMTPServerListen = smtpServerListen conf.SMTPServerDomain = smtpServerDomain conf.SMTPServerAddrPrefix = smtpServerAddrPrefix diff --git a/docs/config.md b/docs/config.md index eb34382a..44943165 100644 --- a/docs/config.md +++ b/docs/config.md @@ -355,7 +355,7 @@ This generator helps you configure your self-hosted ntfy instance. It's not full
- @@ -1039,7 +1039,7 @@ configured for `ntfy.sh`): ``` By default, any user (including anonymous users) can send email notifications to any address. To require email -address verification, set `smtp-sender-email-verify` to `true`. When enabled, anonymous users cannot send emails, +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. @@ -1049,7 +1049,7 @@ also use `yes`/`true`/`1` as the `X-Email` value to send to their first verified smtp-sender-user: "AKIDEADBEEFAFFE12345" smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG." smtp-sender-from: "ntfy@ntfy.sh" - smtp-sender-email-verify: true + smtp-sender-verify: true ``` Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst` @@ -2221,7 +2221,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-email-verify` | `NTFY_SMTP_SENDER_EMAIL_VERIFY` | *bool* | `false` | If true, require verified email addresses for email notifications; anonymous email sending is disabled | +| `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/static/js/config-generator.js b/docs/static/js/config-generator.js index 7e4c806f..dc8ea4ed 100644 --- a/docs/static/js/config-generator.js +++ b/docs/static/js/config-generator.js @@ -125,7 +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-email-verify", env: "NTFY_SMTP_SENDER_EMAIL_VERIFY", section: "smtp-out" }, + { key: "smtp-sender-verify", env: "NTFY_SMTP_SENDER_VERIFY", section: "smtp-out" }, { 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" }, diff --git a/mail/mail.go b/mail/sender.go similarity index 72% rename from mail/mail.go rename to mail/sender.go index dc26ced4..9efb2f6c 100644 --- a/mail/mail.go +++ b/mail/sender.go @@ -1,9 +1,7 @@ package mail import ( - "crypto/rand" "fmt" - "math/big" "mime" "net" "net/smtp" @@ -12,6 +10,7 @@ import ( "time" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/util" ) const ( @@ -30,9 +29,10 @@ type Config struct { // Sender sends emails and manages email verification codes type Sender struct { - config *Config - verifyCodes map[string]verifyCode // keyed by email - mu sync.Mutex + config *Config + codes map[string]verifyCode // Verification codes, keyed by email + mu sync.Mutex + closeChan chan struct{} } type verifyCode struct { @@ -42,10 +42,18 @@ type verifyCode struct { // NewSender creates a new mail Sender with the given SMTP config func NewSender(config *Config) *Sender { - return &Sender{ - config: config, - verifyCodes: make(map[string]verifyCode), + 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) } // Send sends a plain text email via SMTP @@ -76,14 +84,11 @@ Content-Type: text/plain; charset="utf-8" return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, []byte(message)) } -// SendVerification generates a 6-digit code, stores it in-memory, and sends a verification email +// SendVerification generates a random code, stores it in-memory, and sends a verification email func (s *Sender) SendVerification(to string) error { - code, err := generateCode() - if err != nil { - return err - } + code := util.RandomString(verifyCodeLength) s.mu.Lock() - s.verifyCodes[to] = verifyCode{ + s.codes[to] = verifyCode{ code: code, expires: time.Now().Add(verifyCodeExpiry), } @@ -96,31 +101,34 @@ func (s *Sender) SendVerification(to string) error { func (s *Sender) CheckVerification(email, code string) bool { s.mu.Lock() defer s.mu.Unlock() - vc, ok := s.verifyCodes[email] + vc, ok := s.codes[email] if !ok || time.Now().After(vc.expires) || vc.code != code { return false } - delete(s.verifyCodes, email) + delete(s.codes, email) return true } -// ExpireVerificationCodes removes expired entries from the in-memory map -func (s *Sender) ExpireVerificationCodes() { - s.mu.Lock() - defer s.mu.Unlock() - now := time.Now() - for email, vc := range s.verifyCodes { - if now.After(vc.expires) { - delete(s.verifyCodes, email) +func (s *Sender) expireLoop() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.expireVerificationCodes() + case <-s.closeChan: + return } } } -func generateCode() (string, error) { - max := big.NewInt(1000000) // 0-999999 - n, err := rand.Int(rand.Reader, max) - if err != nil { - return "", err +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) + } } - return fmt.Sprintf("%06d", n.Int64()), nil } diff --git a/server/config.go b/server/config.go index 0bd6bd32..f472930a 100644 --- a/server/config.go +++ b/server/config.go @@ -135,7 +135,7 @@ type Config struct { SMTPSenderUser string SMTPSenderPass string SMTPSenderFrom string - SMTPSenderEmailVerify bool + SMTPSenderVerify bool SMTPServerListen string SMTPServerDomain string SMTPServerAddrPrefix string @@ -240,7 +240,7 @@ func NewConfig() *Config { SMTPSenderUser: "", SMTPSenderPass: "", SMTPSenderFrom: "", - SMTPSenderEmailVerify: false, + SMTPSenderVerify: false, SMTPServerListen: "", SMTPServerDomain: "", SMTPServerAddrPrefix: "", diff --git a/server/server.go b/server/server.go index f265986b..62213879 100644 --- a/server/server.go +++ b/server/server.go @@ -441,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() } @@ -883,14 +886,14 @@ 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 != "" { - if !vrate.EmailAllowed() { - return nil, errHTTPTooManyRequestsLimitEmails.With(t) - } + } + 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 != "" { diff --git a/server/server.yml b/server/server.yml index 471d5b88..833f1bea 100644 --- a/server/server.yml +++ b/server/server.yml @@ -193,18 +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: - -# If set to true, only verified email recipients will receive email notifications. -# Anonymous users will not be able to send emails, and authenticated users must verify -# their email addresses first. Users can use "yes"/"true"/"1" as the email value to -# send to their first verified address. -# -# smtp-sender-email-verify: false +# 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 93859490..39554348 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -636,6 +636,10 @@ func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request } 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).Debug("Sending email verification") if err := s.mailSender.SendVerification(req.Email); err != nil { @@ -680,24 +684,21 @@ func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request } // convertEmailAddress checks the email address against the user's verified email list. -// If smtp-sender-email-verify is false (default), the email is passed through as-is for +// 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.SMTPSenderEmailVerify { + if !s.config.SMTPSenderVerify { return email, nil - } - if u == nil { + } else if u == nil { return "", errHTTPBadRequestAnonymousEmailNotAllowed - } - if s.userManager == nil { + } else if s.userManager == nil { return email, nil } emails, err := s.userManager.Emails(u.ID) if err != nil { return "", errHTTPInternalError - } - if len(emails) == 0 { + } else if len(emails) == 0 { return "", errHTTPBadRequestEmailAddressNotVerified } if toBool(email) { diff --git a/server/server_manager.go b/server/server_manager.go index 5251e1aa..387ad2b8 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -15,9 +15,6 @@ func (s *Server) execManager() { s.pruneAttachments() s.pruneMessages() s.pruneAndNotifyWebPushSubscriptions() - if s.mailSender != nil { - s.mailSender.ExpireVerificationCodes() - } // Message count messagesCached, err := s.messageCache.MessagesCount() diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 077d021c..984db05c 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -226,6 +226,7 @@ "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, please try again", "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", 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/components/Account.jsx b/web/src/components/Account.jsx index 5b732719..0bca6120 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"; @@ -478,6 +478,8 @@ const AddEmailDialog = (props) => { 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); } From 3e634e0a5a5e5bfc7b6de8d0735661cd8aca1b8a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 08:51:18 -0400 Subject: [PATCH 03/10] Refine --- mail/sender.go | 26 +++++++++++++++++++++++--- server/errors.go | 2 +- server/server.go | 5 +++-- server/server_account.go | 3 +++ server/smtp_sender.go | 22 +++++++--------------- server/types.go | 1 + web/public/static/langs/en.json | 2 +- web/src/components/Account.jsx | 6 +++--- 8 files changed, 42 insertions(+), 25 deletions(-) diff --git a/mail/sender.go b/mail/sender.go index 9efb2f6c..5511cb04 100644 --- a/mail/sender.go +++ b/mail/sender.go @@ -56,8 +56,23 @@ func (s *Sender) Close() { close(s.closeChan) } -// Send sends a plain text email via SMTP -func (s *Sender) Send(to, subject, body string) error { +// 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 @@ -66,6 +81,11 @@ func (s *Sender) Send(to, subject, body string) error { 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}> @@ -81,7 +101,7 @@ Content-Type: text/plain; charset="utf-8" message = strings.ReplaceAll(message, "{subject}", encodedSubject) message = strings.ReplaceAll(message, "{body}", body) log.Tag("mail").Field("email_to", to).Debug("Sending email") - return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, []byte(message)) + return s.SendRaw(to, []byte(message)) } // SendVerification generates a random code, stores it in-memory, and sends a verification email diff --git a/server/errors.go b/server/errors.go index 16acc9cd..aab51df4 100644 --- a/server/errors.go +++ b/server/errors.go @@ -144,7 +144,7 @@ var ( 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, or no matching verified email addresses found", "https://ntfy.sh/docs/publish/#e-mail-notifications", 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} diff --git a/server/server.go b/server/server.go index 62213879..89abe518 100644 --- a/server/server.go +++ b/server/server.go @@ -179,13 +179,13 @@ 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 != "" { @@ -721,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, @@ -1202,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_account.go b/server/server_account.go index 39554348..9acdf450 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -689,6 +689,9 @@ func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request // 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 diff --git a/server/smtp_sender.go b/server/smtp_sender.go index 4e5988ba..30966267 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,23 @@ 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") + ev.Info("Sending email") } - return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message)) + return s.sender.SendRaw(to, []byte(message)) }) } diff --git a/server/types.go b/server/types.go index c9d4688d..1f69d3de 100644 --- a/server/types.go +++ b/server/types.go @@ -312,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/web/public/static/langs/en.json b/web/public/static/langs/en.json index 984db05c..617dce5b 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -215,7 +215,7 @@ "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": "Verified email recipients", + "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", diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index 0bca6120..de76eac3 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -84,7 +84,7 @@ const Basics = () => { - + @@ -355,7 +355,7 @@ const AccountType = () => { ); }; -const VerifiedEmails = () => { +const Emails = () => { const { t } = useTranslation(); const { account } = useContext(AccountContext); const [dialogKey, setDialogKey] = useState(0); @@ -388,7 +388,7 @@ const VerifiedEmails = () => { } }; - if (!config.enable_emails) { + if (!config.enable_email_verify) { return null; } From bdea8c314f83f528de0ab41b587a9ea0961405ee Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 09:00:47 -0400 Subject: [PATCH 04/10] Refine docs and bump deps --- docs/publish.md | 14 ++- docs/releases.md | 27 +++-- go.mod | 2 +- go.sum | 4 +- web/package-lock.json | 252 ++++++++++++++++++++---------------------- 5 files changed, 152 insertions(+), 147 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 00c43b5e..6c887b52 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -3213,12 +3213,16 @@ 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, +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 +Since ntfy does not provide auth (yet), the rate limiting is pretty strict (see [limitations](#limitations)). In the +default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of that, your IP address appears in the e-mail body. This is to prevent abuse. +On ntfy.sh, to verify your email address in the web app before you're allowed to send emails. The daily limit for +free users is **5 emails per visitor per day**. + === "Command line (curl)" ``` curl \ @@ -3658,7 +3662,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 +4875,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 8271f36b..865b10bd 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,12 +6,23 @@ 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 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 + ### ntfy server v2.20.1 Released March 27, 2026 @@ -110,13 +121,6 @@ if things are working (or not working). There is a [one-off migration tool](http * Preserve `
` line breaks in HTML-only emails received via SMTP ([#690](https://github.com/binwiederhier/ntfy/issues/690), [#1620](https://github.com/binwiederhier/ntfy/pull/1620), thanks to [@uzkikh](https://github.com/uzkikh) for the fix and to [@teastrainer](https://github.com/teastrainer) for reporting) -## 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) - ## ntfy Android v1.24.0 Released March 5, 2026 @@ -1839,4 +1843,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/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/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": { From 63ec73a31944c0c16454b9f99d09a954e8fea351 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 12:21:19 -0400 Subject: [PATCH 05/10] Tests --- server/server_test.go | 125 ++++++++++++++++++++++++++++++++++++++++++ user/manager_test.go | 75 +++++++++++++++++++++++++ 2 files changed, 200 insertions(+) diff --git a/server/server_test.go b/server/server_test.go index 384be7dc..dfad1fed 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1567,6 +1567,131 @@ 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_PublishAndExpungeTopicAfter16Hours(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() 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)) From 07b381254905eed83a5a073d59ecb446d8d30996 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 12:37:07 -0400 Subject: [PATCH 06/10] Docs, fix generator --- docs/config.md | 25 ++++++++++++------------- docs/static/js/config-generator.js | 7 ++++++- web/public/static/langs/en.json | 2 +- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/docs/config.md b/docs/config.md index 44943165..61e7eb92 100644 --- a/docs/config.md +++ b/docs/config.md @@ -355,12 +355,13 @@ This generator helps you configure your self-hosted ntfy instance. It's not full
- +
+ +
+ +
@@ -1018,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: @@ -1025,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`): @@ -1036,6 +1043,7 @@ configured for `ntfy.sh`): smtp-sender-user: "AKIDEADBEEFAFFE12345" smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG." smtp-sender-from: "ntfy@ntfy.sh" + smtp-sender-verify: true ``` By default, any user (including anonymous users) can send email notifications to any address. To require email @@ -1043,15 +1051,6 @@ address verification, set `smtp-sender-verify` to `true`. When enabled, anonymou 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. -=== "/etc/ntfy/server.yml (with email verification)" - ``` yaml - smtp-sender-addr: "email-smtp.us-east-2.amazonaws.com:587" - smtp-sender-user: "AKIDEADBEEFAFFE12345" - smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG." - smtp-sender-from: "ntfy@ntfy.sh" - smtp-sender-verify: true - ``` - 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. diff --git a/docs/static/js/config-generator.js b/docs/static/js/config-generator.js index dc8ea4ed..ffada277 100644 --- a/docs/static/js/config-generator.js +++ b/docs/static/js/config-generator.js @@ -125,7 +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" }, + { 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" }, @@ -172,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"), @@ -744,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/web/public/static/langs/en.json b/web/public/static/langs/en.json index 617dce5b..b809a06f 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -226,7 +226,7 @@ "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, please try again", + "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", From 4c6225e311c97f91c16e59c97a0164a039eca530 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 12:38:47 -0400 Subject: [PATCH 07/10] Docs --- docs/publish.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 6c887b52..56995128 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -3220,8 +3220,10 @@ Since ntfy does not provide auth (yet), the rate limiting is pretty strict (see 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. -On ntfy.sh, to verify your email address in the web app before you're allowed to send emails. The daily limit for -free users is **5 emails per visitor per day**. +!!! 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)" ``` From e57ef84f13951b32074588c7784283fd70bb65b7 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 16:06:53 -0400 Subject: [PATCH 08/10] Fix limits for anon users --- server/server_account.go | 16 +++++------ server/server_test.go | 46 +++++++++++++++++++++++++++++++ server/visitor.go | 6 +++- web/public/static/langs/en.json | 1 + web/src/components/Account.jsx | 4 ++- web/src/components/Navigation.jsx | 3 +- 6 files changed, 65 insertions(+), 11 deletions(-) diff --git a/server/server_account.go b/server/server_account.go index 9acdf450..7def814d 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -624,9 +624,11 @@ func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request return errHTTPBadRequestEmailAddressInvalid } // Check user is allowed to add emails - if u == nil || (u.IsUser() && u.Tier == nil) { + if u == nil { return errHTTPUnauthorized - } else if u.IsUser() && u.Tier.EmailLimit == 0 { + } 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 @@ -641,7 +643,7 @@ func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request return errHTTPTooManyRequestsLimitEmails } // Send verification email - logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Sending email verification") + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Sending email verification") if err := s.mailSender.SendVerification(req.Email); err != nil { return err } @@ -653,14 +655,12 @@ func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v req, err := readJSONWithLimit[apiAccountEmailAddRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err - } - if !emailAddressRegex.MatchString(req.Email) { + } else if !emailAddressRegex.MatchString(req.Email) { return errHTTPBadRequestEmailAddressInvalid - } - if !s.mailSender.CheckVerification(req.Email, req.Code) { + } else if !s.mailSender.CheckVerification(req.Email, req.Code) { return errHTTPBadRequestEmailVerificationCodeInvalid } - logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Adding email as verified") + 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 } diff --git a/server/server_test.go b/server/server_test.go index dfad1fed..133517d9 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1692,6 +1692,52 @@ func TestServer_PublishEmailVerify_Disabled_Backwards_Compatible(t *testing.T) { }) } +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/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/web/public/static/langs/en.json b/web/public/static/langs/en.json index b809a06f..2e06cc64 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -251,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/components/Account.jsx b/web/src/components/Account.jsx index de76eac3..29f4872c 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -945,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()); From 51da5e0f77b2210319ce83239ec8d7312c89dc66 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 16:33:19 -0400 Subject: [PATCH 09/10] Review --- docs/publish.md | 4 ++-- docs/releases.md | 7 ++++--- server/server.go | 2 +- server/smtp_sender.go | 3 +-- user/manager_postgres.go | 2 +- user/manager_sqlite.go | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 56995128..5cef10cf 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -3213,10 +3213,10 @@ 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. If the server has [`smtp-sender-verify`](config.md#e-mail-notifications) enabled, +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 +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. diff --git a/docs/releases.md b/docs/releases.md index 865b10bd..93361d0a 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -15,13 +15,14 @@ Please check out the release notes for [upcoming releases](#not-released-yet) be ### ntfy server v2.21.0 Released March 30, 2026 -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. +This release add 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 + address verification before sending email notifications ([#1681](https://github.com/binwiederhier/ntfy/pull/1681)) ### ntfy server v2.20.1 Released March 27, 2026 diff --git a/server/server.go b/server/server.go index 89abe518..78a668bd 100644 --- a/server/server.go +++ b/server/server.go @@ -1105,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) diff --git a/server/smtp_sender.go b/server/smtp_sender.go index 30966267..885f806b 100644 --- a/server/smtp_sender.go +++ b/server/smtp_sender.go @@ -43,9 +43,8 @@ func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error { }) if ev.IsTrace() { ev.Field("email_body", message).Trace("Sending email") - } else if ev.IsDebug() { - ev.Info("Sending email") } + ev.Info("Sending email") return s.sender.SendRaw(to, []byte(message)) }) } diff --git a/user/manager_postgres.go b/user/manager_postgres.go index efa9998e..02cffd84 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -209,7 +209,7 @@ const ( 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` + 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` diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index 8db75cad..0f1a9227 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -208,7 +208,7 @@ const ( sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?` // Email queries - sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ?` + 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 = ?` From 6219784aae11ca2ea81ed5b4db809ab159126ee1 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 16:38:01 -0400 Subject: [PATCH 10/10] Bump --- docs/install.md | 76 ++++++++++++++++++++++++------------------------ docs/releases.md | 2 +- 2 files changed, 39 insertions(+), 39 deletions(-) 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/releases.md b/docs/releases.md index 93361d0a..72bfefed 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -15,7 +15,7 @@ Please check out the release notes for [upcoming releases](#not-released-yet) be ### ntfy server v2.21.0 Released March 30, 2026 -This release add the ability to verify email addresses using the `smtp-sender-verify` flag. This is a change that is +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.