浏览代码

user: add additional emails

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 1 年之前
父节点
当前提交
eba4c93efd

+ 1 - 1
go.mod

@@ -52,7 +52,7 @@ require (
 	github.com/rs/cors v1.11.1
 	github.com/rs/cors v1.11.1
 	github.com/rs/xid v1.6.0
 	github.com/rs/xid v1.6.0
 	github.com/rs/zerolog v1.33.0
 	github.com/rs/zerolog v1.33.0
-	github.com/sftpgo/sdk v0.1.9-0.20241002160417-3a2e25af00c1
+	github.com/sftpgo/sdk v0.1.9-0.20241011171103-64fc18a344f9
 	github.com/shirou/gopsutil/v3 v3.24.5
 	github.com/shirou/gopsutil/v3 v3.24.5
 	github.com/spf13/afero v1.11.0
 	github.com/spf13/afero v1.11.0
 	github.com/spf13/cobra v1.8.1
 	github.com/spf13/cobra v1.8.1

+ 2 - 2
go.sum

@@ -371,8 +371,8 @@ github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJ
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
-github.com/sftpgo/sdk v0.1.9-0.20241002160417-3a2e25af00c1 h1:UR1rI03lk+rLbt/FmUszQoY+hE3XxVCEGSumjbMZx/I=
-github.com/sftpgo/sdk v0.1.9-0.20241002160417-3a2e25af00c1/go.mod h1:Isl0IEzS/Muvh8Fr4X+NWFsOS/fZQHRD4oPQpoY7C4g=
+github.com/sftpgo/sdk v0.1.9-0.20241011171103-64fc18a344f9 h1:wlXBnaNfJJJRZjHO2AerSS5gp0ckkYUgBzSXivUo0Wo=
+github.com/sftpgo/sdk v0.1.9-0.20241011171103-64fc18a344f9/go.mod h1:ehimvlTP+XTEiE3t1CPwWx9n7+6A6OGvMGlZ7ouvKFk=
 github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
 github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
 github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
 github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
 github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
 github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=

+ 4 - 1
internal/common/eventmanager.go

@@ -2459,7 +2459,7 @@ func executePwdExpirationCheckForUser(user *dataprovider.User, config dataprovid
 	}
 	}
 	subject := "SFTPGo password expiration notification"
 	subject := "SFTPGo password expiration notification"
 	startTime := time.Now()
 	startTime := time.Now()
-	if err := smtp.SendEmail([]string{user.Email}, nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
+	if err := smtp.SendEmail(user.GetEmailAddresses(), nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
 		eventManagerLog(logger.LevelError, "unable to notify password expiration for user %s: %v, elapsed: %s",
 		eventManagerLog(logger.LevelError, "unable to notify password expiration for user %s: %v, elapsed: %s",
 			user.Username, err, time.Since(startTime))
 			user.Username, err, time.Since(startTime))
 		return err
 		return err
@@ -2554,6 +2554,9 @@ func preserveUserProfile(user, newUser *dataprovider.User) {
 		if user.Email != "" {
 		if user.Email != "" {
 			newUser.Email = user.Email
 			newUser.Email = user.Email
 		}
 		}
+		if len(user.Filters.AdditionalEmails) > 0 {
+			newUser.Filters.AdditionalEmails = user.Filters.AdditionalEmails
+		}
 	}
 	}
 	if newUser.CanChangeAPIKeyAuth() {
 	if newUser.CanChangeAPIKeyAuth() {
 		newUser.Filters.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth
 		newUser.Filters.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth

+ 2 - 0
internal/common/eventmanager_test.go

@@ -1418,6 +1418,7 @@ func TestIDPAccountCheckRule(t *testing.T) {
 	// Update the profile attribute and make sure they are preserved
 	// Update the profile attribute and make sure they are preserved
 	user.Password = "secret"
 	user.Password = "secret"
 	user.Email = "[email protected]"
 	user.Email = "[email protected]"
+	user.Filters.AdditionalEmails = []string{"[email protected]"}
 	user.Description = "some desc"
 	user.Description = "some desc"
 	user.Filters.TLSCerts = []string{serverCert}
 	user.Filters.TLSCerts = []string{serverCert}
 	user.PublicKeys = []string{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC03jj0D+djk7pxIf/0OhrxrchJTRZklofJ1NoIu4752Sq02mdXmarMVsqJ1cAjV5LBVy3D1F5U6XW4rppkXeVtd04Pxb09ehtH0pRRPaoHHlALiJt8CoMpbKYMA8b3KXPPriGxgGomvtU2T2RMURSwOZbMtpsugfjYSWenyYX+VORYhylWnSXL961LTyC21ehd6d6QnW9G7E5hYMITMY9TuQZz3bROYzXiTsgN0+g6Hn7exFQp50p45StUMfV/SftCMdCxlxuyGny2CrN/vfjO7xxOo2uv7q1qm10Q46KPWJQv+pgZ/OfL+EDjy07n5QVSKHlbx+2nT4Q0EgOSQaCTYwn3YjtABfIxWwgAFdyj6YlPulCL22qU4MYhDcA6PSBwDdf8hvxBfvsiHdM+JcSHvv8/VeJhk6CmnZxGY0fxBupov27z3yEO8nAg8k+6PaUiW1MSUfuGMF/ktB8LOstXsEPXSszuyXiOv4DaryOXUiSn7bmRqKcEFlJusO6aZP0= nicola@p1"}
 	user.PublicKeys = []string{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC03jj0D+djk7pxIf/0OhrxrchJTRZklofJ1NoIu4752Sq02mdXmarMVsqJ1cAjV5LBVy3D1F5U6XW4rppkXeVtd04Pxb09ehtH0pRRPaoHHlALiJt8CoMpbKYMA8b3KXPPriGxgGomvtU2T2RMURSwOZbMtpsugfjYSWenyYX+VORYhylWnSXL961LTyC21ehd6d6QnW9G7E5hYMITMY9TuQZz3bROYzXiTsgN0+g6Hn7exFQp50p45StUMfV/SftCMdCxlxuyGny2CrN/vfjO7xxOo2uv7q1qm10Q46KPWJQv+pgZ/OfL+EDjy07n5QVSKHlbx+2nT4Q0EgOSQaCTYwn3YjtABfIxWwgAFdyj6YlPulCL22qU4MYhDcA6PSBwDdf8hvxBfvsiHdM+JcSHvv8/VeJhk6CmnZxGY0fxBupov27z3yEO8nAg8k+6PaUiW1MSUfuGMF/ktB8LOstXsEPXSszuyXiOv4DaryOXUiSn7bmRqKcEFlJusO6aZP0= nicola@p1"}
@@ -1432,6 +1433,7 @@ func TestIDPAccountCheckRule(t *testing.T) {
 	assert.Len(t, user.PublicKeys, 1)
 	assert.Len(t, user.PublicKeys, 1)
 	assert.Len(t, user.Filters.TLSCerts, 1)
 	assert.Len(t, user.Filters.TLSCerts, 1)
 	assert.NotEmpty(t, user.Email)
 	assert.NotEmpty(t, user.Email)
+	assert.Len(t, user.Filters.AdditionalEmails, 1)
 	assert.NotEmpty(t, user.Description)
 	assert.NotEmpty(t, user.Description)
 
 
 	err = dataprovider.DeleteUser(username, "", "", "")
 	err = dataprovider.DeleteUser(username, "", "", "")

+ 3 - 1
internal/common/protocol_test.go

@@ -7709,6 +7709,7 @@ func TestEventRulePasswordExpiration(t *testing.T) {
 	_, _, err = httpdtest.UpdateEventRule(rule1, http.StatusOK)
 	_, _, err = httpdtest.UpdateEventRule(rule1, http.StatusOK)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	user.Email = "[email protected]"
 	user.Email = "[email protected]"
+	user.Filters.AdditionalEmails = []string{"[email protected]"}
 	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
 	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	conn, client, err = getSftpClient(user)
 	conn, client, err = getSftpClient(user)
@@ -7724,8 +7725,9 @@ func TestEventRulePasswordExpiration(t *testing.T) {
 			return lastReceivedEmail.get().From != ""
 			return lastReceivedEmail.get().From != ""
 		}, 1500*time.Millisecond, 100*time.Millisecond)
 		}, 1500*time.Millisecond, 100*time.Millisecond)
 		email := lastReceivedEmail.get()
 		email := lastReceivedEmail.get()
-		assert.Len(t, email.To, 1)
+		assert.Len(t, email.To, 2)
 		assert.Contains(t, email.To, user.Email)
 		assert.Contains(t, email.To, user.Email)
+		assert.Contains(t, email.To, user.Filters.AdditionalEmails[0])
 		assert.Contains(t, email.Data, "your SFTPGo password expires in 5 days")
 		assert.Contains(t, email.Data, "your SFTPGo password expires in 5 days")
 		err = client.RemoveDirectory(dirName)
 		err = client.RemoveDirectory(dirName)
 		assert.NoError(t, err)
 		assert.NoError(t, err)

+ 20 - 5
internal/dataprovider/dataprovider.go

@@ -3230,6 +3230,24 @@ func validateCombinedUserFilters(user *User) error {
 	return nil
 	return nil
 }
 }
 
 
+func validateEmails(user *User) error {
+	if user.Email != "" && !util.IsEmailValid(user.Email) {
+		return util.NewI18nError(
+			util.NewValidationError(fmt.Sprintf("email %q is not valid", user.Email)),
+			util.I18nErrorInvalidEmail,
+		)
+	}
+	for _, email := range user.Filters.AdditionalEmails {
+		if !util.IsEmailValid(email) {
+			return util.NewI18nError(
+				util.NewValidationError(fmt.Sprintf("email %q is not valid", email)),
+				util.I18nErrorInvalidEmail,
+			)
+		}
+	}
+	return nil
+}
+
 func validateBaseParams(user *User) error {
 func validateBaseParams(user *User) error {
 	if user.Username == "" {
 	if user.Username == "" {
 		return util.NewI18nError(util.NewValidationError("username is mandatory"), util.I18nErrorUsernameRequired)
 		return util.NewI18nError(util.NewValidationError("username is mandatory"), util.I18nErrorUsernameRequired)
@@ -3237,11 +3255,8 @@ func validateBaseParams(user *User) error {
 	if err := checkReservedUsernames(user.Username); err != nil {
 	if err := checkReservedUsernames(user.Username); err != nil {
 		return util.NewI18nError(err, util.I18nErrorReservedUsername)
 		return util.NewI18nError(err, util.I18nErrorReservedUsername)
 	}
 	}
-	if user.Email != "" && !util.IsEmailValid(user.Email) {
-		return util.NewI18nError(
-			util.NewValidationError(fmt.Sprintf("email %q is not valid", user.Email)),
-			util.I18nErrorInvalidEmail,
-		)
+	if err := validateEmails(user); err != nil {
+		return err
 	}
 	}
 	if config.NamingRules&1 == 0 && !usernameRegex.MatchString(user.Username) {
 	if config.NamingRules&1 == 0 && !usernameRegex.MatchString(user.Username) {
 		return util.NewI18nError(
 		return util.NewI18nError(

+ 13 - 0
internal/dataprovider/user.go

@@ -124,6 +124,8 @@ type UserFilters struct {
 	sdk.BaseUserFilters
 	sdk.BaseUserFilters
 	// User must change password from WebClient/REST API at next login.
 	// User must change password from WebClient/REST API at next login.
 	RequirePasswordChange bool `json:"require_password_change,omitempty"`
 	RequirePasswordChange bool `json:"require_password_change,omitempty"`
+	// AdditionalEmails defines additional email addresses
+	AdditionalEmails []string `json:"additional_emails,omitempty"`
 	// Time-based one time passwords configuration
 	// Time-based one time passwords configuration
 	TOTPConfig UserTOTPConfig `json:"totp_config,omitempty"`
 	TOTPConfig UserTOTPConfig `json:"totp_config,omitempty"`
 	// Recovery codes to use if the user loses access to their second factor auth device.
 	// Recovery codes to use if the user loses access to their second factor auth device.
@@ -404,6 +406,15 @@ func (u *User) CheckMaxShareExpiration(expiresAt time.Time) error {
 	return nil
 	return nil
 }
 }
 
 
+// GetEmailAddresses returns all the email addresses.
+func (u *User) GetEmailAddresses() []string {
+	var res []string
+	if u.Email != "" {
+		res = append(res, u.Email)
+	}
+	return slices.Concat(res, u.Filters.AdditionalEmails)
+}
+
 // GetSubDirPermissions returns permissions for sub directories
 // GetSubDirPermissions returns permissions for sub directories
 func (u *User) GetSubDirPermissions() []sdk.DirectoryPermissions {
 func (u *User) GetSubDirPermissions() []sdk.DirectoryPermissions {
 	var result []sdk.DirectoryPermissions
 	var result []sdk.DirectoryPermissions
@@ -1784,6 +1795,8 @@ func (u *User) getACopy() User {
 	filters.TOTPConfig.Secret = u.Filters.TOTPConfig.Secret.Clone()
 	filters.TOTPConfig.Secret = u.Filters.TOTPConfig.Secret.Clone()
 	filters.TOTPConfig.Protocols = make([]string, len(u.Filters.TOTPConfig.Protocols))
 	filters.TOTPConfig.Protocols = make([]string, len(u.Filters.TOTPConfig.Protocols))
 	copy(filters.TOTPConfig.Protocols, u.Filters.TOTPConfig.Protocols)
 	copy(filters.TOTPConfig.Protocols, u.Filters.TOTPConfig.Protocols)
+	filters.AdditionalEmails = make([]string, len(u.Filters.AdditionalEmails))
+	copy(filters.AdditionalEmails, u.Filters.AdditionalEmails)
 	filters.RecoveryCodes = make([]RecoveryCode, 0, len(u.Filters.RecoveryCodes))
 	filters.RecoveryCodes = make([]RecoveryCode, 0, len(u.Filters.RecoveryCodes))
 	for _, code := range u.Filters.RecoveryCodes {
 	for _, code := range u.Filters.RecoveryCodes {
 		if code.Secret == nil {
 		if code.Secret == nil {

+ 4 - 2
internal/httpd/api_http_user.go

@@ -469,8 +469,9 @@ func getUserProfile(w http.ResponseWriter, r *http.Request) {
 			Description:     user.Description,
 			Description:     user.Description,
 			AllowAPIKeyAuth: user.Filters.AllowAPIKeyAuth,
 			AllowAPIKeyAuth: user.Filters.AllowAPIKeyAuth,
 		},
 		},
-		PublicKeys: user.PublicKeys,
-		TLSCerts:   user.Filters.TLSCerts,
+		AdditionalEmails: user.Filters.AdditionalEmails,
+		PublicKeys:       user.PublicKeys,
+		TLSCerts:         user.Filters.TLSCerts,
 	}
 	}
 	render.JSON(w, r, resp)
 	render.JSON(w, r, resp)
 }
 }
@@ -508,6 +509,7 @@ func updateUserProfile(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 	if userMerged.CanChangeInfo() {
 	if userMerged.CanChangeInfo() {
 		user.Email = req.Email
 		user.Email = req.Email
+		user.Filters.AdditionalEmails = req.AdditionalEmails
 		user.Description = req.Description
 		user.Description = req.Description
 	}
 	}
 	if err := dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, util.GetIPFromRemoteAddress(r.RemoteAddr), user.Role); err != nil {
 	if err := dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, util.GetIPFromRemoteAddress(r.RemoteAddr), user.Role); err != nil {

+ 13 - 9
internal/httpd/api_utils.go

@@ -72,8 +72,9 @@ type adminProfile struct {
 
 
 type userProfile struct {
 type userProfile struct {
 	baseProfile
 	baseProfile
-	PublicKeys []string `json:"public_keys,omitempty"`
-	TLSCerts   []string `json:"tls_certs,omitempty"`
+	AdditionalEmails []string `json:"additional_emails,omitempty"`
+	PublicKeys       []string `json:"public_keys,omitempty"`
+	TLSCerts         []string `json:"tls_certs,omitempty"`
 }
 }
 
 
 func sendAPIResponse(w http.ResponseWriter, r *http.Request, err error, message string, code int) {
 func sendAPIResponse(w http.ResponseWriter, r *http.Request, err error, message string, code int) {
@@ -786,7 +787,8 @@ func getActiveUser(username string, r *http.Request) (dataprovider.User, error)
 }
 }
 
 
 func handleForgotPassword(r *http.Request, username string, isAdmin bool) error {
 func handleForgotPassword(r *http.Request, username string, isAdmin bool) error {
-	var email, subject string
+	var emails []string
+	var subject string
 	var err error
 	var err error
 	var admin dataprovider.Admin
 	var admin dataprovider.Admin
 	var user dataprovider.User
 	var user dataprovider.User
@@ -796,11 +798,13 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
 	}
 	}
 	if isAdmin {
 	if isAdmin {
 		admin, err = getActiveAdmin(username, util.GetIPFromRemoteAddress(r.RemoteAddr))
 		admin, err = getActiveAdmin(username, util.GetIPFromRemoteAddress(r.RemoteAddr))
-		email = admin.Email
+		if admin.Email != "" {
+			emails = []string{admin.Email}
+		}
 		subject = fmt.Sprintf("Email Verification Code for admin %q", username)
 		subject = fmt.Sprintf("Email Verification Code for admin %q", username)
 	} else {
 	} else {
 		user, err = getActiveUser(username, r)
 		user, err = getActiveUser(username, r)
-		email = user.Email
+		emails = user.GetEmailAddresses()
 		subject = fmt.Sprintf("Email Verification Code for user %q", username)
 		subject = fmt.Sprintf("Email Verification Code for user %q", username)
 		if err == nil {
 		if err == nil {
 			if !isUserAllowedToResetPassword(r, &user) {
 			if !isUserAllowedToResetPassword(r, &user) {
@@ -821,7 +825,7 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
 		}
 		}
 		return util.NewI18nError(util.NewGenericError("Error retrieving your account, please try again later"), util.I18nErrorGetUser)
 		return util.NewI18nError(util.NewGenericError("Error retrieving your account, please try again later"), util.I18nErrorGetUser)
 	}
 	}
-	if email == "" {
+	if len(emails) == 0 {
 		return util.NewI18nError(
 		return util.NewI18nError(
 			util.NewValidationError("Your account does not have an email address, it is not possible to reset your password by sending an email verification code"),
 			util.NewValidationError("Your account does not have an email address, it is not possible to reset your password by sending an email verification code"),
 			util.I18nErrorPwdResetNoEmail,
 			util.I18nErrorPwdResetNoEmail,
@@ -836,7 +840,7 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
 		return util.NewGenericError("Unable to render password reset template")
 		return util.NewGenericError("Unable to render password reset template")
 	}
 	}
 	startTime := time.Now()
 	startTime := time.Now()
-	if err := smtp.SendEmail([]string{email}, nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
+	if err := smtp.SendEmail(emails, nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
 		logger.Warn(logSender, middleware.GetReqID(r.Context()), "unable to send password reset code via email: %v, elapsed: %v",
 		logger.Warn(logSender, middleware.GetReqID(r.Context()), "unable to send password reset code via email: %v, elapsed: %v",
 			err, time.Since(startTime))
 			err, time.Since(startTime))
 		return util.NewI18nError(
 		return util.NewI18nError(
@@ -844,8 +848,8 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
 			util.I18nErrorPwdResetSendEmail,
 			util.I18nErrorPwdResetSendEmail,
 		)
 		)
 	}
 	}
-	logger.Debug(logSender, middleware.GetReqID(r.Context()), "reset code sent via email to %q, email: %q, is admin? %v, elapsed: %v",
-		username, email, isAdmin, time.Since(startTime))
+	logger.Debug(logSender, middleware.GetReqID(r.Context()), "reset code sent via email to %q, emails: %+v, is admin? %v, elapsed: %v",
+		username, emails, isAdmin, time.Since(startTime))
 	return resetCodesMgr.Add(c)
 	return resetCodesMgr.Add(c)
 }
 }
 
 

+ 29 - 0
internal/httpd/httpd_test.go

@@ -623,6 +623,7 @@ func TestInitialization(t *testing.T) {
 func TestBasicUserHandling(t *testing.T) {
 func TestBasicUserHandling(t *testing.T) {
 	u := getTestUser()
 	u := getTestUser()
 	u.Email = "[email protected]"
 	u.Email = "[email protected]"
+	u.Filters.AdditionalEmails = []string{"[email protected]", "[email protected]"}
 	user, resp, err := httpdtest.AddUser(u, http.StatusCreated)
 	user, resp, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err, string(resp))
 	assert.NoError(t, err, string(resp))
 	_, resp, err = httpdtest.AddUser(u, http.StatusConflict)
 	_, resp, err = httpdtest.AddUser(u, http.StatusConflict)
@@ -663,6 +664,12 @@ func TestBasicUserHandling(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.Contains(t, string(body), "Validation error: email")
 	assert.Contains(t, string(body), "Validation error: email")
 
 
+	user.Email = ""
+	user.Filters.AdditionalEmails = []string{"invalid@email"}
+	_, body, err = httpdtest.UpdateUser(user, http.StatusBadRequest, "")
+	assert.NoError(t, err)
+	assert.Contains(t, string(body), "Validation error: email")
+
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
@@ -11298,6 +11305,7 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 
 
 	email := "[email protected]"
 	email := "[email protected]"
+	additionalEmails := []string{"[email protected]"}
 	description := "user API description"
 	description := "user API description"
 	profileReq := make(map[string]any)
 	profileReq := make(map[string]any)
 	profileReq["allow_api_key_auth"] = true
 	profileReq["allow_api_key_auth"] = true
@@ -11305,6 +11313,7 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
 	profileReq["description"] = description
 	profileReq["description"] = description
 	profileReq["public_keys"] = []string{testPubKey, testPubKey1}
 	profileReq["public_keys"] = []string{testPubKey, testPubKey1}
 	profileReq["tls_certs"] = []string{httpsCert}
 	profileReq["tls_certs"] = []string{httpsCert}
+	profileReq["additional_emails"] = additionalEmails
 	asJSON, err := json.Marshal(profileReq)
 	asJSON, err := json.Marshal(profileReq)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
 	req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
@@ -11322,6 +11331,7 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
 	err = json.Unmarshal(rr.Body.Bytes(), &profileReq)
 	err = json.Unmarshal(rr.Body.Bytes(), &profileReq)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.Equal(t, email, profileReq["email"].(string))
 	assert.Equal(t, email, profileReq["email"].(string))
+	assert.Len(t, profileReq["additional_emails"].([]interface{}), 1)
 	assert.Equal(t, description, profileReq["description"].(string))
 	assert.Equal(t, description, profileReq["description"].(string))
 	assert.True(t, profileReq["allow_api_key_auth"].(bool))
 	assert.True(t, profileReq["allow_api_key_auth"].(bool))
 	val, ok := profileReq["public_keys"].([]any)
 	val, ok := profileReq["public_keys"].([]any)
@@ -11343,6 +11353,17 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	assert.Contains(t, rr.Body.String(), "Validation error: email")
 	assert.Contains(t, rr.Body.String(), "Validation error: email")
+	// set an invalid additional email
+	profileReq = make(map[string]any)
+	profileReq["additional_emails"] = []string{"not an email"}
+	asJSON, err = json.Marshal(profileReq)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "Validation error: email")
 	// set an invalid public key
 	// set an invalid public key
 	profileReq = make(map[string]any)
 	profileReq = make(map[string]any)
 	profileReq["public_keys"] = []string{"not a public key"}
 	profileReq["public_keys"] = []string{"not a public key"}
@@ -19859,6 +19880,7 @@ func TestWebUserProfile(t *testing.T) {
 	form.Set("public_keys[0][public_key]", testPubKey)
 	form.Set("public_keys[0][public_key]", testPubKey)
 	form.Set("public_keys[1][public_key]", testPubKey1)
 	form.Set("public_keys[1][public_key]", testPubKey1)
 	form.Set("tls_certs[0][tls_cert]", httpsCert)
 	form.Set("tls_certs[0][tls_cert]", httpsCert)
+	form.Set("additional_emails[0][additional_email]", "[email protected]")
 	// no csrf token
 	// no csrf token
 	req, err := http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
 	req, err := http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -19885,6 +19907,9 @@ func TestWebUserProfile(t *testing.T) {
 	assert.Len(t, user.Filters.TLSCerts, 1)
 	assert.Len(t, user.Filters.TLSCerts, 1)
 	assert.Equal(t, email, user.Email)
 	assert.Equal(t, email, user.Email)
 	assert.Equal(t, description, user.Description)
 	assert.Equal(t, description, user.Description)
+	if assert.Len(t, user.Filters.AdditionalEmails, 1) {
+		assert.Equal(t, "[email protected]", user.Filters.AdditionalEmails[0])
+	}
 
 
 	// set an invalid email
 	// set an invalid email
 	form.Set("email", "not an email")
 	form.Set("email", "not an email")
@@ -21268,6 +21293,7 @@ func TestWebUserAddMock(t *testing.T) {
 	user.AdditionalInfo = "info"
 	user.AdditionalInfo = "info"
 	user.Description = "user dsc"
 	user.Description = "user dsc"
 	user.Email = "[email protected]"
 	user.Email = "[email protected]"
+	user.Filters.AdditionalEmails = []string{"[email protected]", "[email protected]"}
 	mappedDir := filepath.Join(os.TempDir(), "mapped")
 	mappedDir := filepath.Join(os.TempDir(), "mapped")
 	folderName := filepath.Base(mappedDir)
 	folderName := filepath.Base(mappedDir)
 	f := vfs.BaseVirtualFolder{
 	f := vfs.BaseVirtualFolder{
@@ -21285,6 +21311,8 @@ func TestWebUserAddMock(t *testing.T) {
 	form.Set(csrfFormToken, csrfToken)
 	form.Set(csrfFormToken, csrfToken)
 	form.Set("username", user.Username)
 	form.Set("username", user.Username)
 	form.Set("email", user.Email)
 	form.Set("email", user.Email)
+	form.Set("additional_emails[0][additional_email]", user.Filters.AdditionalEmails[0])
+	form.Set("additional_emails[1][additional_email]", user.Filters.AdditionalEmails[1])
 	form.Set("home_dir", user.HomeDir)
 	form.Set("home_dir", user.HomeDir)
 	form.Set("osfs_read_buffer_size", "2")
 	form.Set("osfs_read_buffer_size", "2")
 	form.Set("osfs_write_buffer_size", "3")
 	form.Set("osfs_write_buffer_size", "3")
@@ -21611,6 +21639,7 @@ func TestWebUserAddMock(t *testing.T) {
 	assert.True(t, newUser.Filters.DisableFsChecks)
 	assert.True(t, newUser.Filters.DisableFsChecks)
 	assert.False(t, newUser.Filters.AllowAPIKeyAuth)
 	assert.False(t, newUser.Filters.AllowAPIKeyAuth)
 	assert.Equal(t, user.Email, newUser.Email)
 	assert.Equal(t, user.Email, newUser.Email)
+	assert.Equal(t, len(user.Filters.AdditionalEmails), len(newUser.Filters.AdditionalEmails))
 	assert.Equal(t, "/start/dir", newUser.Filters.StartDirectory)
 	assert.Equal(t, "/start/dir", newUser.Filters.StartDirectory)
 	assert.Equal(t, 0, newUser.Filters.FTPSecurity)
 	assert.Equal(t, 0, newUser.Filters.FTPSecurity)
 	assert.Equal(t, 10, newUser.Filters.DefaultSharesExpiration)
 	assert.Equal(t, 10, newUser.Filters.DefaultSharesExpiration)

+ 9 - 0
internal/httpd/webadmin.go

@@ -1981,6 +1981,13 @@ func updateRepeaterFormFields(r *http.Request) {
 			}
 			}
 			continue
 			continue
 		}
 		}
+		if hasPrefixAndSuffix(k, "additional_emails[", "][additional_email]") {
+			email := strings.TrimSpace(r.Form.Get(k))
+			if email != "" {
+				r.Form.Add("additional_emails", email)
+			}
+			continue
+		}
 		if hasPrefixAndSuffix(k, "virtual_folders[", "][vfolder_path]") {
 		if hasPrefixAndSuffix(k, "virtual_folders[", "][vfolder_path]") {
 			base, _ := strings.CutSuffix(k, "[vfolder_path]")
 			base, _ := strings.CutSuffix(k, "[vfolder_path]")
 			r.Form.Add("vfolder_path", strings.TrimSpace(r.Form.Get(k)))
 			r.Form.Add("vfolder_path", strings.TrimSpace(r.Form.Get(k)))
@@ -2114,6 +2121,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
 		Filters: dataprovider.UserFilters{
 		Filters: dataprovider.UserFilters{
 			BaseUserFilters:       filters,
 			BaseUserFilters:       filters,
 			RequirePasswordChange: r.Form.Get("require_password_change") != "",
 			RequirePasswordChange: r.Form.Get("require_password_change") != "",
+			AdditionalEmails:      r.Form["additional_emails"],
 		},
 		},
 		VirtualFolders: getVirtualFoldersFromPostFields(r),
 		VirtualFolders: getVirtualFoldersFromPostFields(r),
 		FsConfig:       fsConfig,
 		FsConfig:       fsConfig,
@@ -3317,6 +3325,7 @@ func (s *httpdServer) handleWebTemplateUserGet(w http.ResponseWriter, r *http.Re
 			user.SetEmptySecrets()
 			user.SetEmptySecrets()
 			user.PublicKeys = nil
 			user.PublicKeys = nil
 			user.Email = ""
 			user.Email = ""
+			user.Filters.AdditionalEmails = nil
 			user.Description = ""
 			user.Description = ""
 			if user.ExpirationDate == 0 && admin.Filters.Preferences.DefaultUsersExpiration > 0 {
 			if user.ExpirationDate == 0 && admin.Filters.Preferences.DefaultUsersExpiration > 0 {
 				user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour * time.Duration(admin.Filters.Preferences.DefaultUsersExpiration)))
 				user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour * time.Duration(admin.Filters.Preferences.DefaultUsersExpiration)))

+ 20 - 7
internal/httpd/webclient.go

@@ -174,13 +174,15 @@ type clientMessagePage struct {
 
 
 type clientProfilePage struct {
 type clientProfilePage struct {
 	baseClientPage
 	baseClientPage
-	PublicKeys      []string
-	TLSCerts        []string
-	CanSubmit       bool
-	AllowAPIKeyAuth bool
-	Email           string
-	Description     string
-	Error           *util.I18nError
+	PublicKeys             []string
+	TLSCerts               []string
+	CanSubmit              bool
+	AllowAPIKeyAuth        bool
+	Email                  string
+	AdditionalEmails       []string
+	AdditionalEmailsString string
+	Description            string
+	Error                  *util.I18nError
 }
 }
 
 
 type changeClientPasswordPage struct {
 type changeClientPasswordPage struct {
@@ -841,6 +843,8 @@ func (s *httpdServer) renderClientProfilePage(w http.ResponseWriter, r *http.Req
 	data.TLSCerts = user.Filters.TLSCerts
 	data.TLSCerts = user.Filters.TLSCerts
 	data.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth
 	data.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth
 	data.Email = user.Email
 	data.Email = user.Email
+	data.AdditionalEmails = user.Filters.AdditionalEmails
+	data.AdditionalEmailsString = strings.Join(data.AdditionalEmails, ", ")
 	data.Description = user.Description
 	data.Description = user.Description
 	data.CanSubmit = userMerged.CanUpdateProfile()
 	data.CanSubmit = userMerged.CanUpdateProfile()
 	renderClientTemplate(w, templateClientProfile, data)
 	renderClientTemplate(w, templateClientProfile, data)
@@ -1661,6 +1665,15 @@ func (s *httpdServer) handleWebClientProfilePost(w http.ResponseWriter, r *http.
 	if userMerged.CanChangeInfo() {
 	if userMerged.CanChangeInfo() {
 		user.Email = strings.TrimSpace(r.Form.Get("email"))
 		user.Email = strings.TrimSpace(r.Form.Get("email"))
 		user.Description = r.Form.Get("description")
 		user.Description = r.Form.Get("description")
+		for k := range r.Form {
+			if hasPrefixAndSuffix(k, "additional_emails[", "][additional_email]") {
+				email := strings.TrimSpace(r.Form.Get(k))
+				if email != "" {
+					r.Form.Add("additional_emails", email)
+				}
+			}
+		}
+		user.Filters.AdditionalEmails = r.Form["additional_emails"]
 	}
 	}
 	err = dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, ipAddr, user.Role)
 	err = dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, ipAddr, user.Role)
 	if err != nil {
 	if err != nil {

+ 3 - 0
internal/httpdtest/httpdtest.go

@@ -2037,6 +2037,9 @@ func checkUser(expected *dataprovider.User, actual *dataprovider.User) error {
 	if expected.Email != actual.Email {
 	if expected.Email != actual.Email {
 		return errors.New("email mismatch")
 		return errors.New("email mismatch")
 	}
 	}
+	if !slices.Equal(expected.Filters.AdditionalEmails, actual.Filters.AdditionalEmails) {
+		return errors.New("additional emails mismatch")
+	}
 	if expected.Filters.RequirePasswordChange != actual.Filters.RequirePasswordChange {
 	if expected.Filters.RequirePasswordChange != actual.Filters.RequirePasswordChange {
 		return errors.New("require_password_change mismatch")
 		return errors.New("require_password_change mismatch")
 	}
 	}

+ 1 - 0
static/locales/en/translation.json

@@ -540,6 +540,7 @@
         "invalid_quota_size": "Invalid quota size",
         "invalid_quota_size": "Invalid quota size",
         "expires_in": "Expires in",
         "expires_in": "Expires in",
         "expires_in_help": "Account expiration as number of days from the creation. 0 means no expiration",
         "expires_in_help": "Account expiration as number of days from the creation. 0 means no expiration",
+        "additional_emails": "Additional emails",
         "tls_certs": "TLS certificates",
         "tls_certs": "TLS certificates",
         "tls_certs_help": "TLS certificates can be used for FTP and/or WebDAV authentication",
         "tls_certs_help": "TLS certificates can be used for FTP and/or WebDAV authentication",
         "tls_cert_help": "Paste a PEM encoded TLS certificate here",
         "tls_cert_help": "Paste a PEM encoded TLS certificate here",

+ 1 - 0
static/locales/it/translation.json

@@ -540,6 +540,7 @@
         "invalid_quota_size": "Quota (dimensione) non valida",
         "invalid_quota_size": "Quota (dimensione) non valida",
         "expires_in": "Scadenza",
         "expires_in": "Scadenza",
         "expires_in_help": "Scadenza dell'account espressa in numero di giorni dalla creazione. 0 significa nessuna scadenza",
         "expires_in_help": "Scadenza dell'account espressa in numero di giorni dalla creazione. 0 significa nessuna scadenza",
+        "additional_emails": "Email aggiuntive",
         "tls_certs": "Certificati TLS",
         "tls_certs": "Certificati TLS",
         "tls_certs_help": "I certificati TLS possono essere utilizzati per l'autenticazione FTP e/o WebDAV",
         "tls_certs_help": "I certificati TLS possono essere utilizzati per l'autenticazione FTP e/o WebDAV",
         "tls_cert_help": "Incolla qui un tuo certificato TLS codificato PEM",
         "tls_cert_help": "Incolla qui un tuo certificato TLS codificato PEM",

+ 65 - 0
templates/webadmin/user.html

@@ -461,6 +461,70 @@ explicit grant from the SFTPGo Team ([email protected]).
                                 </div>
                                 </div>
                             </div>
                             </div>
 
 
+                            <div class="card mt-10">
+                                <div class="card-header bg-light">
+                                    <h3 data-i18n="user.additional_emails" class="card-title section-title-inner">Additional emails</h3>
+                                </div>
+                                <div class="card-body">
+                                    <div id="additional_emails">
+                                        <div class="form-group">
+                                            <div data-repeater-list="additional_emails">
+                                                {{- range $idx, $val := .User.Filters.AdditionalEmails}}
+                                                <div data-repeater-item>
+                                                    <div class="form-group row">
+                                                        <div class="col-md-10 mt-3 mt-md-8">
+                                                            <input type="email" class="form-control" placeholder="" name="additional_email" value="{{$val}}" maxlength="255" autocomplete="off" spellcheck="false" />
+                                                        </div>
+                                                        <div class="col-md-2 mt-3 mt-md-8">
+                                                            <a href="#" data-repeater-delete
+                                                                class="btn btn-light-danger">
+                                                                <i class="ki-duotone ki-trash fs-5">
+                                                                    <span class="path1"></span>
+                                                                    <span class="path2"></span>
+                                                                    <span class="path3"></span>
+                                                                    <span class="path4"></span>
+                                                                    <span class="path5"></span>
+                                                                </i>
+                                                                <span data-i18n="general.delete">Delete</span>
+                                                            </a>
+                                                        </div>
+                                                    </div>
+                                                </div>
+                                                {{- else}}
+                                                <div data-repeater-item>
+                                                    <div class="form-group row">
+                                                        <div class="col-md-10 mt-3 mt-md-8">
+                                                            <input type="email" class="form-control" placeholder="" name="additional_email" value="" maxlength="255" autocomplete="off" spellcheck="false" />
+                                                        </div>
+                                                        <div class="col-md-2 mt-3 mt-md-8">
+                                                            <a href="#" data-repeater-delete
+                                                                class="btn btn-light-danger">
+                                                                <i class="ki-duotone ki-trash fs-5">
+                                                                    <span class="path1"></span>
+                                                                    <span class="path2"></span>
+                                                                    <span class="path3"></span>
+                                                                    <span class="path4"></span>
+                                                                    <span class="path5"></span>
+                                                                </i>
+                                                                <span data-i18n="general.delete">Delete</span>
+                                                            </a>
+                                                        </div>
+                                                    </div>
+                                                </div>
+                                                {{- end}}
+                                            </div>
+                                        </div>
+
+                                        <div class="form-group mt-5">
+                                            <a href="#" data-repeater-create class="btn btn-light-primary">
+                                                <i class="ki-duotone ki-plus fs-3"></i>
+                                                <span data-i18n="general.add">Add</span>
+                                            </a>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+
                             {{- template "user_group_profile" .User.Filters}}
                             {{- template "user_group_profile" .User.Filters}}
 
 
                             <div class="form-group row mt-10">
                             <div class="form-group row mt-10">
@@ -789,6 +853,7 @@ explicit grant from the SFTPGo Team ([email protected]).
             initRepeater('#src_bandwidth_limits');
             initRepeater('#src_bandwidth_limits');
             initRepeater('#tls_certs');
             initRepeater('#tls_certs');
             initRepeater('#access_time_restrictions');
             initRepeater('#access_time_restrictions');
+            initRepeater('#additional_emails');
             initRepeaterItems();
             initRepeaterItems();
             //{{- if .Error}}
             //{{- if .Error}}
             //{{- if ne .LoggedUser.Filters.Preferences.VisibleUserPageSections 0}}
             //{{- if ne .LoggedUser.Filters.Preferences.VisibleUserPageSections 0}}

+ 77 - 0
templates/webclient/profile.html

@@ -31,6 +31,80 @@ explicit grant from the SFTPGo Team ([email protected]).
                 </div>
                 </div>
             </div>
             </div>
 
 
+            {{- if .LoggedUser.CanChangeInfo}}
+            <div class="card mt-10">
+                <div class="card-header bg-light">
+                    <h3 data-i18n="user.additional_emails" class="card-title section-title-inner">Additional emails</h3>
+                </div>
+                <div class="card-body">
+                    <div id="additional_emails">
+                        <div class="form-group">
+                            <div data-repeater-list="additional_emails">
+                                {{- range $idx, $val := .AdditionalEmails}}
+                                <div data-repeater-item>
+                                    <div class="form-group row">
+                                        <div class="col-md-10 mt-3 mt-md-8">
+                                            <input type="email" class="form-control" placeholder="" name="additional_email" value="{{$val}}" maxlength="255" autocomplete="off" spellcheck="false" />
+                                        </div>
+                                        <div class="col-md-2 mt-3 mt-md-8">
+                                            <a href="#" data-repeater-delete
+                                                class="btn btn-light-danger">
+                                                <i class="ki-duotone ki-trash fs-5">
+                                                    <span class="path1"></span>
+                                                    <span class="path2"></span>
+                                                    <span class="path3"></span>
+                                                    <span class="path4"></span>
+                                                    <span class="path5"></span>
+                                                </i>
+                                                <span data-i18n="general.delete">Delete</span>
+                                            </a>
+                                        </div>
+                                    </div>
+                                </div>
+                                {{- else}}
+                                <div data-repeater-item>
+                                    <div class="form-group row">
+                                        <div class="col-md-10 mt-3 mt-md-8">
+                                            <input type="email" class="form-control" placeholder="" name="additional_email" value="" maxlength="255" autocomplete="off" spellcheck="false" />
+                                        </div>
+                                        <div class="col-md-2 mt-3 mt-md-8">
+                                            <a href="#" data-repeater-delete
+                                                class="btn btn-light-danger">
+                                                <i class="ki-duotone ki-trash fs-5">
+                                                    <span class="path1"></span>
+                                                    <span class="path2"></span>
+                                                    <span class="path3"></span>
+                                                    <span class="path4"></span>
+                                                    <span class="path5"></span>
+                                                </i>
+                                                <span data-i18n="general.delete">Delete</span>
+                                            </a>
+                                        </div>
+                                    </div>
+                                </div>
+                                {{- end}}
+                            </div>
+                        </div>
+
+                        <div class="form-group mt-5">
+                            <a href="#" data-repeater-create class="btn btn-light-primary">
+                                <i class="ki-duotone ki-plus fs-3"></i>
+                                <span data-i18n="general.add">Add</span>
+                            </a>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            {{- else}}
+            <div class="form-group row mt-10">
+                <label for="idAdditionalEmails" data-i18n="user.additional_emails" class="col-md-3 col-form-label">Additional emails</label>
+                <div class="col-md-9">
+                    <input type="text" id="idAdditionalEmails" name="description" placeholder="" value="{{.AdditionalEmailsString}}"
+                        class="form-control-plaintext readonly-input" readonly>
+                </div>
+            </div>
+            {{- end}}
+
             <div class="form-group row mt-10">
             <div class="form-group row mt-10">
                 <label for="idDescription" data-i18n="general.description" class="col-md-3 col-form-label">Description</label>
                 <label for="idDescription" data-i18n="general.description" class="col-md-3 col-form-label">Description</label>
                 <div class="col-md-9">
                 <div class="col-md-9">
@@ -217,6 +291,9 @@ explicit grant from the SFTPGo Team ([email protected]).
         //{{- if .LoggedUser.CanManageTLSCerts}}
         //{{- if .LoggedUser.CanManageTLSCerts}}
         initRepeater('#tls_certs');
         initRepeater('#tls_certs');
         //{{- end}}
         //{{- end}}
+        //{{- if .LoggedUser.CanChangeInfo}}
+        initRepeater('#additional_emails');
+        //{{- end}}
         initRepeaterItems();
         initRepeaterItems();
         //{{- end}}
         //{{- end}}