Browse Source

user: add additional emails

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 1 year ago
parent
commit
eba4c93efd

+ 1 - 1
go.mod

@@ -52,7 +52,7 @@ require (
 	github.com/rs/cors v1.11.1
 	github.com/rs/xid v1.6.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/spf13/afero v1.11.0
 	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/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
 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/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
 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"
 	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",
 			user.Username, err, time.Since(startTime))
 		return err
@@ -2554,6 +2554,9 @@ func preserveUserProfile(user, newUser *dataprovider.User) {
 		if user.Email != "" {
 			newUser.Email = user.Email
 		}
+		if len(user.Filters.AdditionalEmails) > 0 {
+			newUser.Filters.AdditionalEmails = user.Filters.AdditionalEmails
+		}
 	}
 	if newUser.CanChangeAPIKeyAuth() {
 		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
 	user.Password = "secret"
 	user.Email = "[email protected]"
+	user.Filters.AdditionalEmails = []string{"[email protected]"}
 	user.Description = "some desc"
 	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"}
@@ -1432,6 +1433,7 @@ func TestIDPAccountCheckRule(t *testing.T) {
 	assert.Len(t, user.PublicKeys, 1)
 	assert.Len(t, user.Filters.TLSCerts, 1)
 	assert.NotEmpty(t, user.Email)
+	assert.Len(t, user.Filters.AdditionalEmails, 1)
 	assert.NotEmpty(t, user.Description)
 
 	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)
 	assert.NoError(t, err)
 	user.Email = "[email protected]"
+	user.Filters.AdditionalEmails = []string{"[email protected]"}
 	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
 	assert.NoError(t, err)
 	conn, client, err = getSftpClient(user)
@@ -7724,8 +7725,9 @@ func TestEventRulePasswordExpiration(t *testing.T) {
 			return lastReceivedEmail.get().From != ""
 		}, 1500*time.Millisecond, 100*time.Millisecond)
 		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.Filters.AdditionalEmails[0])
 		assert.Contains(t, email.Data, "your SFTPGo password expires in 5 days")
 		err = client.RemoveDirectory(dirName)
 		assert.NoError(t, err)

+ 20 - 5
internal/dataprovider/dataprovider.go

@@ -3230,6 +3230,24 @@ func validateCombinedUserFilters(user *User) error {
 	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 {
 	if user.Username == "" {
 		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 {
 		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) {
 		return util.NewI18nError(

+ 13 - 0
internal/dataprovider/user.go

@@ -124,6 +124,8 @@ type UserFilters struct {
 	sdk.BaseUserFilters
 	// User must change password from WebClient/REST API at next login.
 	RequirePasswordChange bool `json:"require_password_change,omitempty"`
+	// AdditionalEmails defines additional email addresses
+	AdditionalEmails []string `json:"additional_emails,omitempty"`
 	// Time-based one time passwords configuration
 	TOTPConfig UserTOTPConfig `json:"totp_config,omitempty"`
 	// 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
 }
 
+// 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
 func (u *User) GetSubDirPermissions() []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.Protocols = make([]string, len(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))
 	for _, code := range u.Filters.RecoveryCodes {
 		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,
 			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)
 }
@@ -508,6 +509,7 @@ func updateUserProfile(w http.ResponseWriter, r *http.Request) {
 	}
 	if userMerged.CanChangeInfo() {
 		user.Email = req.Email
+		user.Filters.AdditionalEmails = req.AdditionalEmails
 		user.Description = req.Description
 	}
 	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 {
 	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) {
@@ -786,7 +787,8 @@ func getActiveUser(username string, r *http.Request) (dataprovider.User, 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 admin dataprovider.Admin
 	var user dataprovider.User
@@ -796,11 +798,13 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
 	}
 	if isAdmin {
 		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)
 	} else {
 		user, err = getActiveUser(username, r)
-		email = user.Email
+		emails = user.GetEmailAddresses()
 		subject = fmt.Sprintf("Email Verification Code for user %q", username)
 		if err == nil {
 			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)
 	}
-	if email == "" {
+	if len(emails) == 0 {
 		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.I18nErrorPwdResetNoEmail,
@@ -836,7 +840,7 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
 		return util.NewGenericError("Unable to render password reset template")
 	}
 	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",
 			err, time.Since(startTime))
 		return util.NewI18nError(
@@ -844,8 +848,8 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
 			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)
 }
 

+ 29 - 0
internal/httpd/httpd_test.go

@@ -623,6 +623,7 @@ func TestInitialization(t *testing.T) {
 func TestBasicUserHandling(t *testing.T) {
 	u := getTestUser()
 	u.Email = "[email protected]"
+	u.Filters.AdditionalEmails = []string{"[email protected]", "[email protected]"}
 	user, resp, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err, string(resp))
 	_, resp, err = httpdtest.AddUser(u, http.StatusConflict)
@@ -663,6 +664,12 @@ func TestBasicUserHandling(t *testing.T) {
 	assert.NoError(t, err)
 	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)
 	assert.NoError(t, err)
 }
@@ -11298,6 +11305,7 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
 	checkResponseCode(t, http.StatusBadRequest, rr)
 
 	email := "[email protected]"
+	additionalEmails := []string{"[email protected]"}
 	description := "user API description"
 	profileReq := make(map[string]any)
 	profileReq["allow_api_key_auth"] = true
@@ -11305,6 +11313,7 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
 	profileReq["description"] = description
 	profileReq["public_keys"] = []string{testPubKey, testPubKey1}
 	profileReq["tls_certs"] = []string{httpsCert}
+	profileReq["additional_emails"] = additionalEmails
 	asJSON, err := json.Marshal(profileReq)
 	assert.NoError(t, err)
 	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)
 	assert.NoError(t, err)
 	assert.Equal(t, email, profileReq["email"].(string))
+	assert.Len(t, profileReq["additional_emails"].([]interface{}), 1)
 	assert.Equal(t, description, profileReq["description"].(string))
 	assert.True(t, profileReq["allow_api_key_auth"].(bool))
 	val, ok := profileReq["public_keys"].([]any)
@@ -11343,6 +11353,17 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	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
 	profileReq = make(map[string]any)
 	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[1][public_key]", testPubKey1)
 	form.Set("tls_certs[0][tls_cert]", httpsCert)
+	form.Set("additional_emails[0][additional_email]", "[email protected]")
 	// no csrf token
 	req, err := http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
 	assert.NoError(t, err)
@@ -19885,6 +19907,9 @@ func TestWebUserProfile(t *testing.T) {
 	assert.Len(t, user.Filters.TLSCerts, 1)
 	assert.Equal(t, email, user.Email)
 	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
 	form.Set("email", "not an email")
@@ -21268,6 +21293,7 @@ func TestWebUserAddMock(t *testing.T) {
 	user.AdditionalInfo = "info"
 	user.Description = "user dsc"
 	user.Email = "[email protected]"
+	user.Filters.AdditionalEmails = []string{"[email protected]", "[email protected]"}
 	mappedDir := filepath.Join(os.TempDir(), "mapped")
 	folderName := filepath.Base(mappedDir)
 	f := vfs.BaseVirtualFolder{
@@ -21285,6 +21311,8 @@ func TestWebUserAddMock(t *testing.T) {
 	form.Set(csrfFormToken, csrfToken)
 	form.Set("username", user.Username)
 	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("osfs_read_buffer_size", "2")
 	form.Set("osfs_write_buffer_size", "3")
@@ -21611,6 +21639,7 @@ func TestWebUserAddMock(t *testing.T) {
 	assert.True(t, newUser.Filters.DisableFsChecks)
 	assert.False(t, newUser.Filters.AllowAPIKeyAuth)
 	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, 0, newUser.Filters.FTPSecurity)
 	assert.Equal(t, 10, newUser.Filters.DefaultSharesExpiration)

+ 9 - 0
internal/httpd/webadmin.go

@@ -1981,6 +1981,13 @@ func updateRepeaterFormFields(r *http.Request) {
 			}
 			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]") {
 			base, _ := strings.CutSuffix(k, "[vfolder_path]")
 			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{
 			BaseUserFilters:       filters,
 			RequirePasswordChange: r.Form.Get("require_password_change") != "",
+			AdditionalEmails:      r.Form["additional_emails"],
 		},
 		VirtualFolders: getVirtualFoldersFromPostFields(r),
 		FsConfig:       fsConfig,
@@ -3317,6 +3325,7 @@ func (s *httpdServer) handleWebTemplateUserGet(w http.ResponseWriter, r *http.Re
 			user.SetEmptySecrets()
 			user.PublicKeys = nil
 			user.Email = ""
+			user.Filters.AdditionalEmails = nil
 			user.Description = ""
 			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)))

+ 20 - 7
internal/httpd/webclient.go

@@ -174,13 +174,15 @@ type clientMessagePage struct {
 
 type clientProfilePage struct {
 	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 {
@@ -841,6 +843,8 @@ func (s *httpdServer) renderClientProfilePage(w http.ResponseWriter, r *http.Req
 	data.TLSCerts = user.Filters.TLSCerts
 	data.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth
 	data.Email = user.Email
+	data.AdditionalEmails = user.Filters.AdditionalEmails
+	data.AdditionalEmailsString = strings.Join(data.AdditionalEmails, ", ")
 	data.Description = user.Description
 	data.CanSubmit = userMerged.CanUpdateProfile()
 	renderClientTemplate(w, templateClientProfile, data)
@@ -1661,6 +1665,15 @@ func (s *httpdServer) handleWebClientProfilePost(w http.ResponseWriter, r *http.
 	if userMerged.CanChangeInfo() {
 		user.Email = strings.TrimSpace(r.Form.Get("email"))
 		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)
 	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 {
 		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 {
 		return errors.New("require_password_change mismatch")
 	}

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

@@ -540,6 +540,7 @@
         "invalid_quota_size": "Invalid quota size",
         "expires_in": "Expires in",
         "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_help": "TLS certificates can be used for FTP and/or WebDAV authentication",
         "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",
         "expires_in": "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_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",

+ 65 - 0
templates/webadmin/user.html

@@ -461,6 +461,70 @@ explicit grant from the SFTPGo Team ([email protected]).
                                 </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}}
 
                             <div class="form-group row mt-10">
@@ -789,6 +853,7 @@ explicit grant from the SFTPGo Team ([email protected]).
             initRepeater('#src_bandwidth_limits');
             initRepeater('#tls_certs');
             initRepeater('#access_time_restrictions');
+            initRepeater('#additional_emails');
             initRepeaterItems();
             //{{- if .Error}}
             //{{- 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>
 
+            {{- 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">
                 <label for="idDescription" data-i18n="general.description" class="col-md-3 col-form-label">Description</label>
                 <div class="col-md-9">
@@ -217,6 +291,9 @@ explicit grant from the SFTPGo Team ([email protected]).
         //{{- if .LoggedUser.CanManageTLSCerts}}
         initRepeater('#tls_certs');
         //{{- end}}
+        //{{- if .LoggedUser.CanChangeInfo}}
+        initRepeater('#additional_emails');
+        //{{- end}}
         initRepeaterItems();
         //{{- end}}