From 1215e99098e50b09bbc2c116f2a8884e5d67d2f6 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 12 Jun 2026 23:13:01 -0400 Subject: [PATCH] Provide email during signup --- server/server_account.go | 13 ++++++++ server/server_account_email_test.go | 50 +++++++++++++++++++++++++++++ server/types.go | 1 + web/public/static/langs/en.json | 8 +++-- web/src/app/AccountApi.js | 3 +- web/src/components/Account.jsx | 15 +++++++-- web/src/components/Signup.jsx | 15 ++++++++- 7 files changed, 98 insertions(+), 7 deletions(-) diff --git a/server/server_account.go b/server/server_account.go index 214a71ba..644a9283 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -37,6 +37,9 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * if err != nil { return err } + if newAccount.Email != "" && !emailAddressRegex.MatchString(newAccount.Email) { + return errHTTPBadRequestEmailAddressInvalid + } if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil { return errHTTPConflictUserExists } @@ -48,6 +51,16 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * return err } v.AccountCreated() + // 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 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 { + logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send signup email verification") + } + } return s.writeJSON(w, newSuccessResponse()) } diff --git a/server/server_account_email_test.go b/server/server_account_email_test.go index 38ed8d84..1b216c12 100644 --- a/server/server_account_email_test.go +++ b/server/server_account_email_test.go @@ -276,6 +276,56 @@ func TestAccount_PasswordReset_NoPrimaryEmailNoSend(t *testing.T) { }) } +func TestAccount_Signup_WithEmail_SendsVerification(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.EnableSignup = true + conf.SMTPSenderAddr = "localhost:25" + conf.SMTPSenderFrom = "noreply@example.com" + conf.BaseURL = "https://ntfy.example.com" + s := newTestServer(t, conf) + mailer := newCaptureMailer() + s.mailSender = mailer + defer s.closeDatabases() + + // Sign up with an optional email -> account created and a verification link sent + rr := request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass","email":"emma@example.com"}`, nil) + require.Equal(t, 200, rr.Code) + link := mailer.verifyLinks["emma@example.com"] + require.NotEmpty(t, link) + + // Verifying the link makes it the (first) primary email + token := tokenFromLink(t, link, "https://ntfy.example.com/account/email/verify/") + require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code) + account := getAccount(t, s, map[string]string{"Authorization": util.BasicAuth("emma", "emmapass")}) + require.Equal(t, []string{"emma@example.com"}, verifiedAddrs(account)) + require.Equal(t, "emma@example.com", primaryAddr(account)) + }) +} + +func TestAccount_Signup_WithoutEmail_NoSend(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.EnableSignup = true + conf.SMTPSenderAddr = "localhost:25" + conf.SMTPSenderFrom = "noreply@example.com" + conf.BaseURL = "https://ntfy.example.com" + s := newTestServer(t, conf) + mailer := newCaptureMailer() + s.mailSender = mailer + defer s.closeDatabases() + + // No email -> account created, nothing sent + require.Equal(t, 200, request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass"}`, nil).Code) + require.Empty(t, mailer.verifyLinks) + + // Invalid email -> rejected + rr := request(t, s, "POST", "/v1/account", `{"username":"otto","password":"ottopass","email":"not-an-email"}`, nil) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40050, toHTTPError(t, rr.Body.String()).Code) + }) +} + func TestAccount_Email_ProvisionedNoPrimary(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { hash, err := user.HashPassword("provpass") diff --git a/server/types.go b/server/types.go index 08687fbb..4b0c738d 100644 --- a/server/types.go +++ b/server/types.go @@ -185,6 +185,7 @@ type apiAccessResetRequest struct { type apiAccountCreateRequest struct { Username string `json:"username"` Password string `json:"password"` + Email string `json:"email"` // Optional; if set (and SMTP configured), a verification link is sent } type apiAccountPasswordChangeRequest struct { diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 278d6466..6b207f2d 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -16,6 +16,7 @@ "version_update_available_description": "The ntfy server has been updated. Please refresh the page.", "signup_title": "Create a ntfy account", "signup_form_username": "Username", + "signup_form_email": "Email (optional, for account recovery)", "signup_form_password": "Password", "signup_form_confirm_password": "Confirm password", "signup_form_button_submit": "Sign up", @@ -246,13 +247,16 @@ "account_basics_emails_copied_to_clipboard": "Email address copied to clipboard", "account_basics_emails_primary_badge": "Primary", "account_basics_emails_chip_actions": "Click for actions", + "account_basics_emails_chip_actions_primary": "Primary address, can be used for account recovery and notifications. Click for actions.", + "account_basics_emails_chip_actions_verified": "Can be used for notifications. Click for actions.", + "account_basics_emails_chip_actions_unverified": "Unverified address, check your inbox to verify. Click for actions.", "account_basics_emails_unverified": "unverified", "account_basics_emails_set_primary": "Set as primary email", - "account_basics_emails_delete": "Remove", + "account_basics_emails_delete": "Remove address", "account_basics_emails_cancel": "Cancel", "account_basics_emails_resend": "Resend verification email", "account_basics_emails_resent": "Verification email sent, check your inbox", - "account_basics_emails_primary_elsewhere": "This email is the primary email on another account", + "account_basics_emails_primary_elsewhere": "This email address is used as the primary address on another account", "account_basics_emails_no_recovery_warning": "Add at least one email address to ensure you can recover your account if you lose your password.", "account_basics_emails_no_primary_warning": "Add a primary email address to ensure you can recover your account if you lose your password.", "account_basics_emails_provisioned_info": "Provisioned users cannot add a primary email address, but you can still add an email address for notifications.", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 823eba53..10facafd 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -69,11 +69,12 @@ class AccountApi { }); } - async create(username, password) { + async create(username, password, email) { const url = accountUrl(config.base_url); const body = JSON.stringify({ username, password, + email: email || "", }); console.log(`[AccountApi] Creating user account ${url}`); await fetchOrThrow(url, { diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index 0024c70f..0134224a 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -448,7 +448,8 @@ const Emails = () => { } const emails = account?.emails ?? []; - const verifiedEmails = emails.filter((e) => !e.pending); + // Verified addresses, primary always first + const verifiedEmails = emails.filter((e) => !e.pending).sort((a, b) => (b.primary ? 1 : 0) - (a.primary ? 1 : 0)); const pendingEmails = emails.filter((e) => e.pending); const primaryEmail = verifiedEmails.find((e) => e.primary)?.address ?? ""; // Recovery nudges (skipped for provisioned users -- they can't reset, and the Add-email dialog @@ -467,7 +468,9 @@ const Emails = () => { key={email.address} icon={email.primary ? : undefined} label={ - + {email.address} } @@ -481,7 +484,7 @@ const Emails = () => { + {email.address} ({t("account_basics_emails_unverified")}) @@ -531,6 +534,12 @@ const Emails = () => { {t("account_basics_emails_resend")} )} + runMenuAction(handleDelete)}> + + + + {t("account_basics_emails_delete")} + diff --git a/web/src/components/Signup.jsx b/web/src/components/Signup.jsx index 7da54c49..cd3a3d87 100644 --- a/web/src/components/Signup.jsx +++ b/web/src/components/Signup.jsx @@ -15,6 +15,7 @@ const Signup = () => { const { t } = useTranslation(); const [error, setError] = useState(""); const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const [showPassword, setShowPassword] = useState(false); @@ -24,7 +25,7 @@ const Signup = () => { event.preventDefault(); const user = { username, password }; try { - await accountApi.create(user.username, user.password); + await accountApi.create(user.username, user.password, email); const token = await accountApi.login(user); console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`); await session.store(user.username, token); @@ -64,6 +65,18 @@ const Signup = () => { onChange={(ev) => setUsername(ev.target.value.trim())} autoFocus /> + {config.enable_emails && ( + setEmail(ev.target.value.trim())} + /> + )}