Send "X-Email: yes" to primary

This commit is contained in:
binwiederhier
2026-06-20 14:15:12 -04:00
parent 2ee8717e0c
commit bb2ca0facf
4 changed files with 51 additions and 7 deletions
+2 -1
View File
@@ -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.
+3 -2
View File
@@ -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) |
+9 -2
View File
@@ -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
}
+37 -2
View File
@@ -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))