From bb2ca0facf0c3d55721f32702aec9d00c975b505 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 20 Jun 2026 14:15:12 -0400 Subject: [PATCH] Send "X-Email: yes" to primary --- docs/config.md | 3 ++- docs/publish.md | 5 +++-- server/server_account.go | 11 +++++++++-- server/server_test.go | 39 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/docs/config.md b/docs/config.md index bc241a4f..f51ad537 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1049,7 +1049,8 @@ 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 first verified address. +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). 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 0060cdcc..95354362 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -3252,7 +3252,8 @@ you'd like to persist longer, or to blast-notify yourself on all possible channe 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 first verified email address. +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. 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 @@ -3702,7 +3703,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 or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use first verified address | +| `email` | - | *e-mail address or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use your primary 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) | diff --git a/server/server_account.go b/server/server_account.go index c1556cf1..7598a91f 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -866,7 +866,8 @@ func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Reque // 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 first verified email. +// 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). func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) { if !s.config.SMTPSenderVerify { if toBool(email) { @@ -885,7 +886,13 @@ func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHT return "", errHTTPBadRequestEmailAddressNotVerified } if toBool(email) { - return emails[0], nil + 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 eec41e03..a6106a93 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1336,14 +1336,16 @@ func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) { } type testMailer struct { - count int - mu sync.Mutex + count int + lastTo string + mu sync.Mutex } func (t *testMailer) SendNotification(to string, m *model.Message, senderIP string) error { t.mu.Lock() defer t.mu.Unlock() t.count++ + t.lastTo = to return nil } @@ -1357,6 +1359,12 @@ func (t *testMailer) Count() int { return t.count } +func (t *testMailer) LastTo() string { + t.mu.Lock() + defer t.mu.Unlock() + return t.lastTo +} + func (t *testMailer) SendEmailVerification(to, link string) error { return nil } func (t *testMailer) SendPasswordReset(to, link string) error { return nil } @@ -1653,6 +1661,33 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) { }) } +func TestServer_PublishEmailVerify_BoolValueUsesPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.SMTPSenderVerify = true + 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) + // Two verified emails; the primary is NOT the alphabetically-first one + 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")) + + // "yes" must resolve to the primary email, not emails[0] (alphabetically first) + 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_BoolValue_NoVerify(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL))