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")}
)}
+
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())}
+ />
+ )}