This commit is contained in:
binwiederhier 2026-03-30 08:26:56 -04:00
parent 61dd788dac
commit 6aebc5c677
12 changed files with 83 additions and 65 deletions

View file

@ -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

View file

@ -355,7 +355,7 @@ This generator helps you configure your self-hosted ntfy instance. It's not full
</div>
<div class="cg-field cg-inline-field">
<label>Require email verification</label>
<select data-key="smtp-sender-email-verify">
<select data-key="smtp-sender-verify">
<option value="">No (default)</option>
<option value="true">Yes</option>
</select>
@ -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-` |

View file

@ -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" },

View file

@ -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 (
@ -31,8 +30,9 @@ type Config struct {
// Sender sends emails and manages email verification codes
type Sender struct {
config *Config
verifyCodes map[string]verifyCode // keyed by email
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{
s := &Sender{
config: config,
verifyCodes: make(map[string]verifyCode),
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
}

View file

@ -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: "",

View file

@ -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 != "" {

View file

@ -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.

View file

@ -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) {

View file

@ -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()

View file

@ -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",

View file

@ -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}`);
}

View file

@ -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);
}