diff --git a/go.mod b/go.mod index 78313eb5..f07e1b6d 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 83989ae7..a9efaf28 100644 --- a/go.sum +++ b/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= diff --git a/internal/common/eventmanager.go b/internal/common/eventmanager.go index 5e79efa0..9c28bab5 100644 --- a/internal/common/eventmanager.go +++ b/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 diff --git a/internal/common/eventmanager_test.go b/internal/common/eventmanager_test.go index f23a31cc..a120ea1b 100644 --- a/internal/common/eventmanager_test.go +++ b/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 = "example@example.com" + user.Filters.AdditionalEmails = []string{"alias@example.com"} 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, "", "", "") diff --git a/internal/common/protocol_test.go b/internal/common/protocol_test.go index 840fb374..85e9543b 100644 --- a/internal/common/protocol_test.go +++ b/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 = "user@example.net" + user.Filters.AdditionalEmails = []string{"additional@example.net"} _, _, 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) diff --git a/internal/dataprovider/dataprovider.go b/internal/dataprovider/dataprovider.go index bda89e99..200c7e2c 100644 --- a/internal/dataprovider/dataprovider.go +++ b/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( diff --git a/internal/dataprovider/user.go b/internal/dataprovider/user.go index cf3d5b87..c2d91bb7 100644 --- a/internal/dataprovider/user.go +++ b/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 { diff --git a/internal/httpd/api_http_user.go b/internal/httpd/api_http_user.go index b3292383..8a189a6e 100644 --- a/internal/httpd/api_http_user.go +++ b/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 { diff --git a/internal/httpd/api_utils.go b/internal/httpd/api_utils.go index 3230695e..d8f25d71 100644 --- a/internal/httpd/api_utils.go +++ b/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) } diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index cdd0a79d..77f6a267 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -623,6 +623,7 @@ func TestInitialization(t *testing.T) { func TestBasicUserHandling(t *testing.T) { u := getTestUser() u.Email = "user@user.com" + u.Filters.AdditionalEmails = []string{"email1@user.com", "email2@user.com"} 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 := "userapi@example.com" + additionalEmails := []string{"userapi1@example.com"} 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]", "email1@user.com") // 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, "email1@user.com", 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 = "test@test.com" + user.Filters.AdditionalEmails = []string{"example1@test.com", "example2@test.com"} 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) diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go index b87df790..9d367f20 100644 --- a/internal/httpd/webadmin.go +++ b/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))) diff --git a/internal/httpd/webclient.go b/internal/httpd/webclient.go index 6f6a611e..ba7301b9 100644 --- a/internal/httpd/webclient.go +++ b/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 { diff --git a/internal/httpdtest/httpdtest.go b/internal/httpdtest/httpdtest.go index 68d0a8b5..6dc55ea2 100644 --- a/internal/httpdtest/httpdtest.go +++ b/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") } diff --git a/static/locales/en/translation.json b/static/locales/en/translation.json index 33cdc1b3..d827c134 100644 --- a/static/locales/en/translation.json +++ b/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", diff --git a/static/locales/it/translation.json b/static/locales/it/translation.json index c8a2f4a6..bfc32313 100644 --- a/static/locales/it/translation.json +++ b/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", diff --git a/templates/webadmin/user.html b/templates/webadmin/user.html index 5027abac..a74a2d4d 100644 --- a/templates/webadmin/user.html +++ b/templates/webadmin/user.html @@ -461,6 +461,70 @@ explicit grant from the SFTPGo Team (support@sftpgo.com). +