From 6aebc5c67749ccdc97a8ca4c8cf7f12b14dd2dd8 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 08:26:56 -0400 Subject: [PATCH] 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); }