diff --git a/server/server_account.go b/server/server_account.go index 9acdf450..7def814d 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -624,9 +624,11 @@ func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request return errHTTPBadRequestEmailAddressInvalid } // Check user is allowed to add emails - if u == nil || (u.IsUser() && u.Tier == nil) { + if u == nil { return errHTTPUnauthorized - } else if u.IsUser() && u.Tier.EmailLimit == 0 { + } else if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 { + return errHTTPUnauthorized + } else if u.IsUser() && u.Tier == nil && s.config.VisitorEmailLimitBurst == 0 { return errHTTPUnauthorized } // Check if email already exists @@ -641,7 +643,7 @@ func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request return errHTTPTooManyRequestsLimitEmails } // Send verification email - logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Sending email verification") + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Sending email verification") if err := s.mailSender.SendVerification(req.Email); err != nil { return err } @@ -653,14 +655,12 @@ func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v req, err := readJSONWithLimit[apiAccountEmailAddRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err - } - if !emailAddressRegex.MatchString(req.Email) { + } else if !emailAddressRegex.MatchString(req.Email) { return errHTTPBadRequestEmailAddressInvalid - } - if !s.mailSender.CheckVerification(req.Email, req.Code) { + } else if !s.mailSender.CheckVerification(req.Email, req.Code) { return errHTTPBadRequestEmailVerificationCodeInvalid } - logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Adding email as verified") + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Adding email as verified") if err := s.userManager.AddEmail(u.ID, req.Email); err != nil { return err } diff --git a/server/server_test.go b/server/server_test.go index dfad1fed..133517d9 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1692,6 +1692,52 @@ func TestServer_PublishEmailVerify_Disabled_Backwards_Compatible(t *testing.T) { }) } +func TestServer_AccountEmailVerify_UserWithoutTier(t *testing.T) { + // This test verifies that an authenticated user WITHOUT a tier can verify emails + // when the default visitor email limit allows it. + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.SMTPSenderVerify = true + conf.SMTPSenderAddr = "localhost:25" // Dummy SMTP server (will fail to send, but that's ok) + conf.SMTPSenderFrom = "noreply@example.com" + s := newTestServer(t, conf) + defer s.closeDatabases() + + // Create a user without a tier + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + + // Verify email request should NOT return 401 + response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + // The request will fail (SMTP not available), but it must NOT be a 401 + require.NotEqual(t, 401, response.Code) + }) +} + +func TestServer_AccountEmailVerify_UserWithoutTier_EmailLimitZero(t *testing.T) { + // This test verifies that a tier-less user is rejected when the server's + // visitor email limit is zero (email sending disabled). + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.SMTPSenderVerify = true + conf.SMTPSenderAddr = "localhost:25" + conf.SMTPSenderFrom = "noreply@example.com" + conf.VisitorEmailLimitBurst = 0 + s := newTestServer(t, conf) + defer s.closeDatabases() + + // Create a user without a tier + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + + // Should be rejected with 401 since email sending is disabled + response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{ + "Authorization": util.BasicAuth("ben", "ben"), + }) + require.Equal(t, 401, response.Code) + }) +} + func TestServer_PublishAndExpungeTopicAfter16Hours(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() diff --git a/server/visitor.go b/server/visitor.go index 6d8fe6d1..d5e774d7 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -440,13 +440,17 @@ func configBasedVisitorLimits(conf *Config) *visitorLimits { if conf.VisitorMessageDailyLimit > 0 { messagesLimit = int64(conf.VisitorMessageDailyLimit) } + var emailLimit int64 + if conf.VisitorEmailLimitBurst > 0 { + emailLimit = replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish) // Approximation! + } return &visitorLimits{ Basis: visitorLimitBasisIP, RequestLimitBurst: conf.VisitorRequestLimitBurst, RequestLimitReplenish: rate.Every(conf.VisitorRequestLimitReplenish), MessageLimit: messagesLimit, MessageExpiryDuration: conf.CacheDuration, - EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation! + EmailLimit: emailLimit, EmailLimitBurst: conf.VisitorEmailLimitBurst, EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish), CallLimit: visitorDefaultCallsLimit, diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index b809a06f..2e06cc64 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -251,6 +251,7 @@ "account_usage_messages_title": "Published messages", "account_usage_emails_title": "Emails sent", "account_usage_calls_title": "Phone calls made", + "account_usage_emails_none": "No email notifications can be sent with this account", "account_usage_calls_none": "No phone calls can be made with this account", "account_usage_reservations_title": "Reserved topics", "account_usage_reservations_none": "No reserved topics for this account", diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index de76eac3..29f4872c 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -945,7 +945,9 @@ const Stats = () => { )} {account.role === Role.USER && account.limits.basis === LimitBasis.IP && ( - {t("account_usage_basis_ip_description")} + + {t("account_usage_basis_ip_description")} + )} ); diff --git a/web/src/components/Navigation.jsx b/web/src/components/Navigation.jsx index 89381cb3..dad8abe8 100644 --- a/web/src/components/Navigation.jsx +++ b/web/src/components/Navigation.jsx @@ -117,7 +117,8 @@ const NavList = (props) => { const isAdmin = account?.role === Role.ADMIN; const isPaid = account?.billing?.subscription; - const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid; + const hasTier = !!account?.tier; + const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid && !hasTier; const showSubscriptionsList = props.subscriptions?.length > 0; const showNotificationPermissionRequired = useNotificationPermissionListener(() => notifier.notRequested()); const showNotificationPermissionDenied = useNotificationPermissionListener(() => notifier.denied());