diff --git a/docs/config.md b/docs/config.md index f51ad537..0f72557b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 -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 primary verified address (falling back to their -first verified address if no primary is designated). +address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails, and +authenticated users can only send to *literal* email addresses they have verified in their account settings. + +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` and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse. diff --git a/docs/publish.md b/docs/publish.md index 95354362..46f2d80a 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -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. 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), -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. +Only one e-mail address is supported. + +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 default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of diff --git a/docs/releases.md b/docs/releases.md index d2de3d07..d0551d6b 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1957,13 +1957,14 @@ email. All of this rides on the existing SMTP configuration -- no new config fla **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 * Auto-send a verification link to the billing email after a Stripe checkout, so paying users can set up password recovery **Bug fixes + maintenance:** * 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) diff --git a/server/server.go b/server/server.go index d79d7ad0..fb5d5d37 100644 --- a/server/server.go +++ b/server/server.go @@ -746,7 +746,6 @@ func (s *Server) configResponse() *apiConfigResponse { EnablePayments: s.config.StripeSecretKey != "", EnableCalls: s.config.TwilioAccount != "", EnableEmails: s.config.SMTPSenderFrom != "", - EnableEmailVerify: s.config.SMTPSenderVerify, EnableResetPassword: s.config.SMTPSenderFrom != "" && s.config.BaseURL != "", // Reset links need SMTP + an absolute base-url EnableReservations: s.config.EnableReservations, EnableWebPush: s.config.WebPushPublicKey != "", diff --git a/server/server_account.go b/server/server_account.go index 5951d72f..ac79b05a 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -863,16 +863,39 @@ func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Reque return s.writeJSON(w, newSuccessResponse()) } -// convertEmailAddress checks the email address against the user's verified email list. -// If smtp-sender-verify is false (default), the email is passed through as-is for -// backwards compatibility. If true, the user must be authenticated and the email must be -// in their verified list. "yes"/"true"/"1" resolves to the user's primary email (falling -// back to the first verified email if no primary is designated). +// convertEmailAddress resolves the X-Email value to the address ntfy should send to. +// +// "yes"/"true"/"1" resolves to the user's primary verified address -- or, if no primary is +// designated (e.g. a provisioned user), the first verified address (alphabetically). This is +// 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) { - if !s.config.SMTPSenderVerify { - if toBool(email) { - return "", errHTTPBadRequestEmailAddressInvalid + if toBool(email) { + if u == nil { + 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 } else if u == nil { return "", errHTTPBadRequestAnonymousEmailNotAllowed @@ -882,17 +905,6 @@ func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHT emails, err := s.userManager.Emails(u.ID) if err != nil { 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) { return email, nil } diff --git a/server/server_test.go b/server/server_test.go index a6106a93..eaa7f350 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -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) { s := newTestServer(t, newTestConfig(t, databaseURL)) 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{ "Email": "yes", }) 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) }) } diff --git a/server/types.go b/server/types.go index 4b0c738d..5b254c4d 100644 --- a/server/types.go +++ b/server/types.go @@ -338,7 +338,6 @@ type apiConfigResponse struct { EnablePayments bool `json:"enable_payments"` EnableCalls bool `json:"enable_calls"` EnableEmails bool `json:"enable_emails"` - EnableEmailVerify bool `json:"enable_email_verify"` EnableResetPassword bool `json:"enable_reset_password"` EnableReservations bool `json:"enable_reservations"` EnableWebPush bool `json:"enable_web_push"` diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index f37089e0..f225cc81 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -428,7 +428,7 @@ const Emails = () => { setSnack(t("account_basics_emails_resent")); }); - if (!config.enable_email_verify) { + if (!config.enable_emails) { return null; }