This commit is contained in:
binwiederhier
2026-06-15 21:47:09 -04:00
parent 9fa8550ef6
commit fc59339f86
10 changed files with 87 additions and 83 deletions
+12 -12
View File
@@ -57,8 +57,8 @@ type Server struct {
unixListener net.Listener
smtpServer *smtp.Server
smtpServerBackend *smtpBackend
smtpSender mailer
mailSender emailVerifier
notificationMailer messageMailer
accountMailer magicLinkMailer
topics map[string]*topic
visitors map[string]*visitor // ip:<ip> or user:<user>
firebaseClient *firebaseClient
@@ -184,17 +184,17 @@ const (
// New instantiates a new Server. It creates the cache and adds a Firebase
// subscriber (if configured).
func New(conf *Config) (*Server, error) {
var mailer mailer
var emailSender emailVerifier // Stays untyped-nil when SMTP is unconfigured, so ensureEmailsEnabled gates correctly
var notificationMailer messageMailer
var accountEmailer magicLinkMailer // Stays untyped-nil when SMTP is unconfigured, so ensureEmailsEnabled gates correctly
if conf.SMTPSenderAddr != "" {
mailSender := mail.NewSender(&mail.Config{
sender := mail.NewSender(&mail.Config{
SMTPAddr: conf.SMTPSenderAddr,
SMTPUser: conf.SMTPSenderUser,
SMTPPass: conf.SMTPSenderPass,
From: conf.SMTPSenderFrom,
})
mailer = &smtpSender{config: conf, sender: mailSender}
emailSender = mailSender
notificationMailer = &notificationSender{config: conf, sender: sender}
accountEmailer = sender
}
var stripe stripeAPI
if payments.Available && conf.StripeSecretKey != "" {
@@ -299,8 +299,8 @@ func New(conf *Config) (*Server, error) {
webPush: wp,
attachment: attachmentStore,
firebaseClient: firebaseClient,
smtpSender: mailer,
mailSender: emailSender,
notificationMailer: notificationMailer,
accountMailer: accountEmailer,
topics: topics,
userManager: userManager,
messages: messages,
@@ -974,7 +974,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
if s.firebaseClient != nil && firebase {
go s.sendToFirebase(v, m)
}
if s.smtpSender != nil && email != "" {
if s.notificationMailer != nil && email != "" {
go s.sendEmail(v, m, email)
}
if s.config.TwilioAccount != "" && call != "" {
@@ -1136,7 +1136,7 @@ func (s *Server) sendToFirebase(v *visitor, m *model.Message) {
func (s *Server) sendEmail(v *visitor, m *model.Message, email string) {
logvm(v, m).Tag(tagEmail).Field("email", email).Info("Sending email to %s", email)
if err := s.smtpSender.Send(v, m, email); err != nil {
if err := s.notificationMailer.Send(v, m, email); err != nil {
logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error())
minc(metricEmailsPublishedFailure)
return
@@ -1236,7 +1236,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo
if email != "" && !emailAddressRegex.MatchString(email) && !toBool(email) {
return false, false, "", "", "", false, "", errHTTPBadRequestEmailAddressInvalid
}
if s.smtpSender == nil && email != "" {
if s.notificationMailer == nil && email != "" {
return false, false, "", "", "", false, "", errHTTPBadRequestEmailDisabled
}
call = readParam(r, "x-call", "call")
+4 -4
View File
@@ -54,7 +54,7 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
// If an email was provided and email sending is configured, start verification (best-effort).
// The address becomes the primary email on verify (the new account has no primary yet); a
// failure to send must not fail signup, so we only log it.
if newAccount.Email != "" && s.mailSender != nil {
if newAccount.Email != "" && s.accountMailer != nil {
if u, err := s.userManager.User(newAccount.Username); err != nil {
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to load new user for email verification")
} else if err := s.enqueueEmailVerification(u.ID, newAccount.Email); err != nil {
@@ -175,7 +175,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
response.PhoneNumbers = phoneNumbers
}
}
if s.mailSender != nil {
if s.accountMailer != nil {
emails, err := s.userManager.Emails(u.ID)
if err != nil {
return err
@@ -788,7 +788,7 @@ func (s *Server) enqueueEmailVerification(userID, email string) error {
return err
}
link := s.config.BaseURL + webAppEmailVerifyPathPrefix + token
return s.mailSender.SendEmailVerification(email, link)
return s.accountMailer.SendEmailVerification(email, link)
}
// handleAccountPasswordResetRequest starts a password reset (POST /v1/account/password/reset/request,
@@ -814,7 +814,7 @@ func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *htt
} else {
link := s.config.BaseURL + webAppPasswordResetPathPrefix + token
logvr(v, r).Tag(tagAccount).Field("user_id", userID).Info("Sending password reset link")
if err := s.mailSender.SendPasswordReset(email, link); err != nil {
if err := s.accountMailer.SendPasswordReset(email, link); err != nil {
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send password reset email")
}
}
+6 -6
View File
@@ -11,7 +11,7 @@ import (
"heckel.io/ntfy/v2/util"
)
// captureMailer is a fake emailVerifier that records the magic links it is asked to send, so
// captureMailer is a fake magicLinkMailer that records the magic links it is asked to send, so
// tests can "click" them without a real SMTP server.
type captureMailer struct {
verifyLinks map[string]string // email -> verification link
@@ -41,7 +41,7 @@ func newEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMail
conf.BaseURL = "https://ntfy.example.com"
s := newTestServer(t, conf)
mailer := newCaptureMailer()
s.mailSender = mailer
s.accountMailer = mailer
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
auth := map[string]string{"Authorization": util.BasicAuth("ben", "ben")}
return s, mailer, auth
@@ -283,7 +283,7 @@ func TestAccount_Signup_WithEmail_SendsVerification(t *testing.T) {
conf.BaseURL = "https://ntfy.example.com"
s := newTestServer(t, conf)
mailer := newCaptureMailer()
s.mailSender = mailer
s.accountMailer = mailer
defer s.closeDatabases()
// Sign up with an optional email -> account created and a verification link sent
@@ -310,7 +310,7 @@ func TestAccount_Signup_WithoutEmail_NoSend(t *testing.T) {
conf.BaseURL = "https://ntfy.example.com"
s := newTestServer(t, conf)
mailer := newCaptureMailer()
s.mailSender = mailer
s.accountMailer = mailer
defer s.closeDatabases()
// No email -> account created, nothing sent
@@ -335,7 +335,7 @@ func TestAccount_Email_ProvisionedNoPrimary(t *testing.T) {
conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}}
s := newTestServer(t, conf)
mailer := newCaptureMailer()
s.mailSender = mailer
s.accountMailer = mailer
defer s.closeDatabases()
auth := map[string]string{"Authorization": util.BasicAuth("prov", "provpass")}
@@ -364,7 +364,7 @@ func TestAccount_PasswordReset_ProvisionedUserNoSend(t *testing.T) {
}
s := newTestServer(t, conf)
mailer := newCaptureMailer()
s.mailSender = mailer
s.accountMailer = mailer
defer s.closeDatabases()
// Give the provisioned user a verified primary email anyway
+1 -1
View File
@@ -151,7 +151,7 @@ func TestAccount_Get_Anonymous(t *testing.T) {
conf.VisitorAttachmentTotalSizeLimit = 5123
conf.AttachmentFileSizeLimit = 512
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
s.notificationMailer = &testMailer{}
defer s.closeDatabases()
rr := request(t, s, "GET", "/v1/account", "", nil)
+2 -2
View File
@@ -54,8 +54,8 @@ func (s *Server) execManager() {
receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts()
}
var sentMailTotal, sentMailSuccess, sentMailFailure int64
if s.smtpSender != nil {
sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
if s.notificationMailer != nil {
sentMailTotal, sentMailSuccess, sentMailFailure = s.notificationMailer.Counts()
}
// Users
+1 -1
View File
@@ -105,7 +105,7 @@ func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc {
func (s *Server) ensureEmailsEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.mailSender == nil || s.userManager == nil {
if s.accountMailer == nil || s.userManager == nil {
return errHTTPNotFound
}
return next(w, r, v)
+1 -1
View File
@@ -252,7 +252,7 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
// collision (or any other skip), the generic "no recovery email set" warning on the account page
// nudges the user to add one. This is best-effort: failures are logged, never surfaced.
func (s *Server) maybeEnqueueBillingEmailVerification(r *http.Request, v *visitor, userID, billingEmail string) {
if s.mailSender == nil || s.config.BaseURL == "" || billingEmail == "" || !emailAddressRegex.MatchString(billingEmail) {
if s.accountMailer == nil || s.config.BaseURL == "" || billingEmail == "" || !emailAddressRegex.MatchString(billingEmail) {
return
}
emails, err := s.userManager.Emails(userID)
+1 -1
View File
@@ -45,7 +45,7 @@ func newCheckoutEmailTestServer(t *testing.T, databaseURL string) (*Server, *cap
c.SMTPSenderFrom = "noreply@example.com"
s := newTestServer(t, c)
mailer := newCaptureMailer()
s.mailSender = mailer
s.accountMailer = mailer
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123", Code: "starter", StripeMonthlyPriceID: "price_1234", MessageLimit: 100, MessageExpiryDuration: time.Hour,
}))
+13 -13
View File
@@ -740,7 +740,7 @@ func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) {
func TestServer_PublishInvalidTopic(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = &testMailer{}
s.notificationMailer = &testMailer{}
response := request(t, s, "PUT", "/docs", "fail", nil)
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
})
@@ -1231,7 +1231,7 @@ func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) {
c := newTestConfigWithAuthFile(t, databaseURL)
s := newTestServer(t, c)
s.smtpSender = &testMailer{}
s.notificationMailer = &testMailer{}
// Publish some messages, and check stats
for i := 0; i < 3; i++ {
@@ -1461,7 +1461,7 @@ func TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) {
func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = &testMailer{}
s.notificationMailer = &testMailer{}
for i := 0; i < 16; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
"E-Mail": "test@example.com",
@@ -1481,7 +1481,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
c := newTestConfig(t, databaseURL)
c.VisitorEmailLimitReplenish = 500 * time.Millisecond
s := newTestServer(t, c)
s.smtpSender = &testMailer{}
s.notificationMailer = &testMailer{}
for i := 0; i < 16; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
"E-Mail": "test@example.com",
@@ -1509,7 +1509,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = &testMailer{}
s.notificationMailer = &testMailer{}
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
"E-Mail": "test@example.com",
"Delay": "20 min",
@@ -1546,7 +1546,7 @@ func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {
func TestServer_PublishEmailAddressInvalid(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = &testMailer{}
s.notificationMailer = &testMailer{}
addresses := []string{
"test@example.com, other@example.com",
"invalidaddress",
@@ -1572,7 +1572,7 @@ func TestServer_PublishEmailVerify_VerifiedAddress(t *testing.T) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
s.notificationMailer = &testMailer{}
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
@@ -1602,7 +1602,7 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
s.notificationMailer = &testMailer{}
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
@@ -1631,7 +1631,7 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) {
func TestServer_PublishEmailVerify_BoolValue_NoVerify(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = &testMailer{}
s.notificationMailer = &testMailer{}
// "yes" without smtp-sender-verify should fail with invalid address
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
@@ -1647,7 +1647,7 @@ func TestServer_PublishEmailVerify_Anonymous(t *testing.T) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
s.notificationMailer = &testMailer{}
defer s.closeDatabases()
// Anonymous user should be rejected
@@ -1664,7 +1664,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
s.notificationMailer = &testMailer{}
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
@@ -1682,7 +1682,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) {
func TestServer_PublishEmailVerify_Disabled_Backwards_Compatible(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = &testMailer{}
s.notificationMailer = &testMailer{}
// Without smtp-sender-verify, any email address should work (backwards compatible)
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
@@ -2139,7 +2139,7 @@ func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
t.Parallel()
mailer := &testMailer{}
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = mailer
s.notificationMailer = mailer
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
response := request(t, s, "PUT", "/", body, nil)
require.Equal(t, 200, response.Code)
+11 -7
View File
@@ -15,19 +15,23 @@ import (
"heckel.io/ntfy/v2/util"
)
type mailer interface {
// messageMailer sends notification emails (the email-on-publish feature). It formats a ntfy
// message into an email. Implemented by *notificationSender; tests inject testMailer.
type messageMailer interface {
Send(v *visitor, m *model.Message, to string) error
Counts() (total int64, success int64, failure int64)
}
// emailVerifier sends the magic-link emails for email verification and password reset.
// magicLinkMailer sends the magic-link emails for email verification and password reset.
// *mail.Sender implements it; tests inject a fake to capture the generated links.
type emailVerifier interface {
type magicLinkMailer interface {
SendEmailVerification(to, link string) error
SendPasswordReset(to, link string) error
}
type smtpSender struct {
// notificationSender adapts a *mail.Sender for notification emails: it formats a model.Message
// into an email and tracks success/failure counts.
type notificationSender struct {
config *Config
sender *mail.Sender
success int64
@@ -35,7 +39,7 @@ type smtpSender struct {
mu sync.Mutex
}
func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error {
func (s *notificationSender) Send(v *visitor, m *model.Message, to string) error {
return s.withCount(v, m, func() error {
message, err := formatMail(s.config.BaseURL, v.ip.String(), s.sender.From(), to, m)
if err != nil {
@@ -56,13 +60,13 @@ func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error {
})
}
func (s *smtpSender) Counts() (total int64, success int64, failure int64) {
func (s *notificationSender) Counts() (total int64, success int64, failure int64) {
s.mu.Lock()
defer s.mu.Unlock()
return s.success + s.failure, s.success, s.failure
}
func (s *smtpSender) withCount(v *visitor, m *model.Message, fn func() error) error {
func (s *notificationSender) withCount(v *visitor, m *model.Message, fn func() error) error {
err := fn()
s.mu.Lock()
defer s.mu.Unlock()