diff --git a/server/server.go b/server/server.go index 4cbc7984..ade5aebe 100644 --- a/server/server.go +++ b/server/server.go @@ -48,31 +48,31 @@ import ( // Server is the main server, providing the UI and API for ntfy type Server struct { - config *Config - db *db.DB // Shared PostgreSQL connection pool (with optional replicas), nil when using SQLite - httpServer *http.Server - httpsServer *http.Server - httpMetricsServer *http.Server - httpProfileServer *http.Server - unixListener net.Listener - smtpServer *smtp.Server - smtpServerBackend *smtpBackend - smtpSender mailer - mailSender emailVerifier - topics map[string]*topic - visitors map[string]*visitor // ip: or user: - firebaseClient *firebaseClient - messages int64 // Total number of messages (persisted if messageCache enabled) - messagesHistory []int64 // Last n values of the messages counter, used to determine rate - userManager *user.Manager // Might be nil! - messageCache *message.Cache // Database that stores the messages - webPush *webpush.Store // Database that stores web push subscriptions - attachment *attachment.Store // Attachment store (file system or S3) - stripe stripeAPI // Stripe API, can be replaced with a mock - priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!) - metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set - closeChan chan bool - mu sync.RWMutex + config *Config + db *db.DB // Shared PostgreSQL connection pool (with optional replicas), nil when using SQLite + httpServer *http.Server + httpsServer *http.Server + httpMetricsServer *http.Server + httpProfileServer *http.Server + unixListener net.Listener + smtpServer *smtp.Server + smtpServerBackend *smtpBackend + notificationMailer messageMailer + accountMailer magicLinkMailer + topics map[string]*topic + visitors map[string]*visitor // ip: or user: + firebaseClient *firebaseClient + messages int64 // Total number of messages (persisted if messageCache enabled) + messagesHistory []int64 // Last n values of the messages counter, used to determine rate + userManager *user.Manager // Might be nil! + messageCache *message.Cache // Database that stores the messages + webPush *webpush.Store // Database that stores web push subscriptions + attachment *attachment.Store // Attachment store (file system or S3) + stripe stripeAPI // Stripe API, can be replaced with a mock + priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!) + metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set + closeChan chan bool + mu sync.RWMutex } // handleFunc extends the normal http.HandlerFunc to be able to easily return errors @@ -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 = ¬ificationSender{config: conf, sender: sender} + accountEmailer = sender } var stripe stripeAPI if payments.Available && conf.StripeSecretKey != "" { @@ -293,20 +293,20 @@ func New(conf *Config) (*Server, error) { firebaseClient = newFirebaseClient(sender, auther) } s := &Server{ - config: conf, - db: pool, - messageCache: messageCache, - webPush: wp, - attachment: attachmentStore, - firebaseClient: firebaseClient, - smtpSender: mailer, - mailSender: emailSender, - topics: topics, - userManager: userManager, - messages: messages, - messagesHistory: []int64{messages}, - visitors: make(map[string]*visitor), - stripe: stripe, + config: conf, + db: pool, + messageCache: messageCache, + webPush: wp, + attachment: attachmentStore, + firebaseClient: firebaseClient, + notificationMailer: notificationMailer, + accountMailer: accountEmailer, + topics: topics, + userManager: userManager, + messages: messages, + messagesHistory: []int64{messages}, + visitors: make(map[string]*visitor), + stripe: stripe, } s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration) return s, nil @@ -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") diff --git a/server/server_account.go b/server/server_account.go index 621fe63b..79c43866 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -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") } } diff --git a/server/server_account_email_test.go b/server/server_account_email_test.go index eb66ba2c..f2ca2d3a 100644 --- a/server/server_account_email_test.go +++ b/server/server_account_email_test.go @@ -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 diff --git a/server/server_account_test.go b/server/server_account_test.go index f2f36168..b74aac0d 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -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) diff --git a/server/server_manager.go b/server/server_manager.go index 387ad2b8..e2fb66e9 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -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 diff --git a/server/server_middleware.go b/server/server_middleware.go index b8e650c5..7f76ae4c 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -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) diff --git a/server/server_payments.go b/server/server_payments.go index 4b19a091..56a0026b 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -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) diff --git a/server/server_payments_email_test.go b/server/server_payments_email_test.go index 24fa7bb2..ff7727da 100644 --- a/server/server_payments_email_test.go +++ b/server/server_payments_email_test.go @@ -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, })) diff --git a/server/server_test.go b/server/server_test.go index 1d19815b..04a4a8da 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -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) diff --git a/server/smtp_sender.go b/server/smtp_sender.go index 199cecd9..9ef7c000 100644 --- a/server/smtp_sender.go +++ b/server/smtp_sender.go @@ -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()