Change "Email: yes" behavior to make more sense

This commit is contained in:
binwiederhier
2026-06-22 10:13:27 -04:00
parent 5808c4d4c0
commit d8666b66ec
8 changed files with 76 additions and 33 deletions
+6 -4
View File
@@ -1047,10 +1047,12 @@ configured for `ntfy.sh`):
``` ```
By default, any user (including anonymous users) can send email notifications to any address. To require email By default, any user (including anonymous users) can send email notifications to any address. To require email
address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails, address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails, and
and authenticated users can only send to email addresses they have verified in their account settings. Users can authenticated users can only send to *literal* email addresses they have verified in their account settings.
also use `yes`/`true`/`1` as the `X-Email` value to send to their primary verified address (falling back to their
first verified address if no primary is designated). Regardless of this setting, a logged-in user can pass `yes`/`true`/`1` as the `X-Email` value to send to their primary
verified address (falling back to their first verified address if no primary is designated). `smtp-sender-verify` only
governs whether arbitrary literal addresses are allowed.
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst` 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. and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse.
+7 -3
View File
@@ -3251,9 +3251,13 @@ 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. 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`). 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 (ntfy.sh has this enabled), Only one e-mail address is supported.
you can also pass `yes`, `true`, or `1` to send to your **primary email address** (the one marked primary in the web app's
[Account section](https://ntfy.sh/account)). If you haven't designated a primary address, it falls back to your first verified address. If you are logged in and have a verified email address on your account, you can pass `yes`, `true`, or `1` instead of an
address to send to your **primary email address** (the one marked primary in the web app's
[Account section](https://ntfy.sh/account)); if you haven't designated a primary, it falls back to your first verified
address. This works regardless of the [`smtp-sender-verify`](config.md#e-mail-notifications) setting -- that setting only
controls whether *literal* addresses must already be verified on your account.
ntfy allows anonymous email sending (if enabled), so 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 default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of
+2 -1
View File
@@ -1957,13 +1957,14 @@ email. All of this rides on the existing SMTP configuration -- no new config fla
**Features:** **Features:**
* Add password reset via emailed magic link, with a "Forgot password?" link on the login page and a `ntfy user password-reset` CLI command for admins * Add password reset via emailed magic link, with a "Forgot password?" link on the login page and a `ntfy user reset-pass` CLI command for admins
* Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" (recovery) email with verified/unverified state in the account UI * Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" (recovery) email with verified/unverified state in the account UI
* Auto-send a verification link to the billing email after a Stripe checkout, so paying users can set up password recovery * Auto-send a verification link to the billing email after a Stripe checkout, so paying users can set up password recovery
**Bug fixes + maintenance:** **Bug fixes + maintenance:**
* Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG * Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG
* `X-Email: yes` (also `true`/`1`) now sends to your primary verified email regardless of the `smtp-sender-verify` setting (previously it was rejected unless verification was enabled); it requires being logged in with a verified address
### ntfy Android v1.25.x (UNRELEASED) ### ntfy Android v1.25.x (UNRELEASED)
-1
View File
@@ -746,7 +746,6 @@ func (s *Server) configResponse() *apiConfigResponse {
EnablePayments: s.config.StripeSecretKey != "", EnablePayments: s.config.StripeSecretKey != "",
EnableCalls: s.config.TwilioAccount != "", EnableCalls: s.config.TwilioAccount != "",
EnableEmails: s.config.SMTPSenderFrom != "", EnableEmails: s.config.SMTPSenderFrom != "",
EnableEmailVerify: s.config.SMTPSenderVerify,
EnableResetPassword: s.config.SMTPSenderFrom != "" && s.config.BaseURL != "", // Reset links need SMTP + an absolute base-url EnableResetPassword: s.config.SMTPSenderFrom != "" && s.config.BaseURL != "", // Reset links need SMTP + an absolute base-url
EnableReservations: s.config.EnableReservations, EnableReservations: s.config.EnableReservations,
EnableWebPush: s.config.WebPushPublicKey != "", EnableWebPush: s.config.WebPushPublicKey != "",
+31 -19
View File
@@ -863,16 +863,39 @@ func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Reque
return s.writeJSON(w, newSuccessResponse()) return s.writeJSON(w, newSuccessResponse())
} }
// convertEmailAddress checks the email address against the user's verified email list. // convertEmailAddress resolves the X-Email value to the address ntfy should send to.
// 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 // "yes"/"true"/"1" resolves to the user's primary verified address -- or, if no primary is
// in their verified list. "yes"/"true"/"1" resolves to the user's primary email (falling // designated (e.g. a provisioned user), the first verified address (alphabetically). This is
// back to the first verified email if no primary is designated). // independent of smtp-sender-verify: it only requires an authenticated user with a verified
// address, since it means "send to my own email".
//
// A literal address is sent as-is when smtp-sender-verify is false (the default, backwards
// compatible); when true, the address must be one the user has verified.
func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) { func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) {
if !s.config.SMTPSenderVerify { if toBool(email) {
if toBool(email) { if u == nil {
return "", errHTTPBadRequestEmailAddressInvalid return "", errHTTPBadRequestAnonymousEmailNotAllowed
} else if s.userManager == nil {
return "", errHTTPBadRequestEmailAddressNotVerified
} }
primary, err := s.userManager.PrimaryEmail(u.ID)
if err != nil {
return "", errHTTPInternalError
} else if primary != "" {
return primary, nil
}
// No primary designated -> fall back to the first verified address, if any
emails, err := s.userManager.Emails(u.ID)
if err != nil {
return "", errHTTPInternalError
} else if len(emails) > 0 {
return emails[0], nil
}
return "", errHTTPBadRequestEmailAddressNotVerified
}
// A literal address
if !s.config.SMTPSenderVerify {
return email, nil return email, nil
} else if u == nil { } else if u == nil {
return "", errHTTPBadRequestAnonymousEmailNotAllowed return "", errHTTPBadRequestAnonymousEmailNotAllowed
@@ -882,17 +905,6 @@ func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHT
emails, err := s.userManager.Emails(u.ID) emails, err := s.userManager.Emails(u.ID)
if err != nil { if err != nil {
return "", errHTTPInternalError return "", errHTTPInternalError
} else if len(emails) == 0 {
return "", errHTTPBadRequestEmailAddressNotVerified
}
if toBool(email) {
primary, err := s.userManager.PrimaryEmail(u.ID)
if err != nil {
return "", errHTTPInternalError
} else if primary != "" {
return primary, nil
}
return emails[0], nil // No primary designated (e.g. provisioned user); fall back to first verified
} else if util.Contains(emails, email) { } else if util.Contains(emails, email) {
return email, nil return email, nil
} }
+29 -3
View File
@@ -1688,17 +1688,43 @@ func TestServer_PublishEmailVerify_BoolValueUsesPrimary(t *testing.T) {
}) })
} }
func TestServer_PublishEmailVerify_BoolValue_NoVerify(t *testing.T) { func TestServer_PublishEmailVerify_BoolValueNoVerifyUsesPrimary(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
// smtp-sender-verify intentionally left false (the default)
s := newTestServer(t, conf)
mailer := &testMailer{}
s.mailer = mailer
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, "aaa@example.com"))
require.Nil(t, s.userManager.AddEmail(u.ID, "zzz@example.com"))
require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "zzz@example.com"))
// Even with smtp-sender-verify off, "yes" resolves to the user's primary verified address
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "yes",
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
require.Equal(t, "zzz@example.com", mailer.LastTo())
})
}
func TestServer_PublishEmailVerify_BoolValueAnonymousRejected(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) { forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL)) s := newTestServer(t, newTestConfig(t, databaseURL))
s.mailer = &testMailer{} s.mailer = &testMailer{}
// "yes" without smtp-sender-verify should fail with invalid address // "yes" requires an authenticated user (it means "my primary"); anonymous is rejected
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{ response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "yes", "Email": "yes",
}) })
require.Equal(t, 400, response.Code) require.Equal(t, 400, response.Code)
require.Equal(t, 40050, toHTTPError(t, response.Body.String()).Code) require.Equal(t, 40053, toHTTPError(t, response.Body.String()).Code)
}) })
} }
-1
View File
@@ -338,7 +338,6 @@ type apiConfigResponse struct {
EnablePayments bool `json:"enable_payments"` EnablePayments bool `json:"enable_payments"`
EnableCalls bool `json:"enable_calls"` EnableCalls bool `json:"enable_calls"`
EnableEmails bool `json:"enable_emails"` EnableEmails bool `json:"enable_emails"`
EnableEmailVerify bool `json:"enable_email_verify"`
EnableResetPassword bool `json:"enable_reset_password"` EnableResetPassword bool `json:"enable_reset_password"`
EnableReservations bool `json:"enable_reservations"` EnableReservations bool `json:"enable_reservations"`
EnableWebPush bool `json:"enable_web_push"` EnableWebPush bool `json:"enable_web_push"`
+1 -1
View File
@@ -428,7 +428,7 @@ const Emails = () => {
setSnack(t("account_basics_emails_resent")); setSnack(t("account_basics_emails_resent"));
}); });
if (!config.enable_email_verify) { if (!config.enable_emails) {
return null; return null;
} }