Browse Source

eventmanager placeholders: add StatusString and ErrorString

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 2 years ago
parent
commit
56bf51277c

+ 3 - 1
docs/eventmanager.md

@@ -23,6 +23,8 @@ The following placeholders are supported:
 - `{{Name}}`. Username, folder name or admin username for provider actions.
 - `{{Name}}`. Username, folder name or admin username for provider actions.
 - `{{Event}}`. Event name, for example `upload`, `download` for filesystem events or `add`, `update` for provider events.
 - `{{Event}}`. Event name, for example `upload`, `download` for filesystem events or `add`, `update` for provider events.
 - `{{Status}}`. Status for `upload`, `download` and `ssh_cmd` events. 1 means no error, 2 means a generic error occurred, 3 means quota exceeded error.
 - `{{Status}}`. Status for `upload`, `download` and `ssh_cmd` events. 1 means no error, 2 means a generic error occurred, 3 means quota exceeded error.
+- `{{StatusString}}`. Status as string. Possible values "OK", "KO".
+- `{{ErrorString}}`. Error details. Replaced with an empty string if no errors occur.
 - `{{VirtualPath}}`. Path seen by SFTPGo users, for example `/adir/afile.txt`.
 - `{{VirtualPath}}`. Path seen by SFTPGo users, for example `/adir/afile.txt`.
 - `{{FsPath}}`. Full filesystem path, for example `/user/homedir/adir/afile.txt` or `C:/data/user/homedir/adir/afile.txt` on Windows.
 - `{{FsPath}}`. Full filesystem path, for example `/user/homedir/adir/afile.txt` or `C:/data/user/homedir/adir/afile.txt` on Windows.
 - `{{ObjectName}}`. File/directory name, for example `afile.txt` or provider object name.
 - `{{ObjectName}}`. File/directory name, for example `afile.txt` or provider object name.
@@ -51,7 +53,7 @@ Actions such as user quota reset, transfer quota reset, data retention check, fo
 Actions are executed in a sequential order except for sync actions that are executed before the others. For each action associated to a rule you can define the following settings:
 Actions are executed in a sequential order except for sync actions that are executed before the others. For each action associated to a rule you can define the following settings:
 
 
 - `Stop on failure`, the next action will not be executed if the current one fails.
 - `Stop on failure`, the next action will not be executed if the current one fails.
-- `Failure action`, this action will be executed only if at least another one fails.
+- `Failure action`, this action will be executed only if at least another one fails. :warning: Please note that a failure action isn't executed if the event fails, for example if a download fails the main action is executed. The failure action is executed only if one of the non-failure actions associated to a rule fails.
 - `Execute sync`, for upload events, you can execute the action synchronously. Executing an action synchronously means that SFTPGo will not return a result code to the client (which is waiting for it) until your action have completed its execution. If your acion takes a long time to complete this could cause a timeout on the client side, which wouldn't receive the server response in a timely manner and eventually drop the connection.
 - `Execute sync`, for upload events, you can execute the action synchronously. Executing an action synchronously means that SFTPGo will not return a result code to the client (which is waiting for it) until your action have completed its execution. If your acion takes a long time to complete this could cause a timeout on the client side, which wouldn't receive the server response in a timely manner and eventually drop the connection.
 
 
 If you are running multiple SFTPGo instances connected to the same data provider, you can choose whether to allow simultaneous execution for scheduled actions.
 If you are running multiple SFTPGo instances connected to the same data provider, you can choose whether to allow simultaneous execution for scheduled actions.

+ 2 - 2
go.mod

@@ -50,7 +50,7 @@ require (
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/rs/cors v1.8.3-0.20220619195839-da52b0701de5
 	github.com/rs/cors v1.8.3-0.20220619195839-da52b0701de5
 	github.com/rs/xid v1.4.0
 	github.com/rs/xid v1.4.0
-	github.com/rs/zerolog v1.27.0
+	github.com/rs/zerolog v1.28.0
 	github.com/sftpgo/sdk v0.1.2-0.20220828084006-f9e2fffac657
 	github.com/sftpgo/sdk v0.1.2-0.20220828084006-f9e2fffac657
 	github.com/shirou/gopsutil/v3 v3.22.7
 	github.com/shirou/gopsutil/v3 v3.22.7
 	github.com/spf13/afero v1.9.2
 	github.com/spf13/afero v1.9.2
@@ -155,7 +155,7 @@ require (
 	golang.org/x/tools v0.1.12 // indirect
 	golang.org/x/tools v0.1.12 // indirect
 	golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
 	golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc // indirect
+	google.golang.org/genproto v0.0.0-20220829144015-23454907ede3 // indirect
 	google.golang.org/grpc v1.49.0 // indirect
 	google.golang.org/grpc v1.49.0 // indirect
 	google.golang.org/protobuf v1.28.1 // indirect
 	google.golang.org/protobuf v1.28.1 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect

+ 4 - 5
go.sum

@@ -701,13 +701,12 @@ github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBO
 github.com/rs/cors v1.8.3-0.20220619195839-da52b0701de5 h1:7PcjxKTsfGXpTMiTNNa1VllbsYSZJN5nhvVEWQMdX8Y=
 github.com/rs/cors v1.8.3-0.20220619195839-da52b0701de5 h1:7PcjxKTsfGXpTMiTNNa1VllbsYSZJN5nhvVEWQMdX8Y=
 github.com/rs/cors v1.8.3-0.20220619195839-da52b0701de5/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
 github.com/rs/cors v1.8.3-0.20220619195839-da52b0701de5/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
 github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
 github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
-github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
 github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY=
 github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY=
 github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
 github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
 github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
 github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
 github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
 github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
-github.com/rs/zerolog v1.27.0 h1:1T7qCieN22GVc8S4Q2yuexzBb1EqjbgjSH9RohbMjKs=
-github.com/rs/zerolog v1.27.0/go.mod h1:7frBqO0oezxmnO7GF86FY++uy8I0Tk/If5ni1G9Qc0U=
+github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
+github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
@@ -1221,8 +1220,8 @@ google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP
 google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
-google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc h1:Nf+EdcTLHR8qDNN/KfkQL0u0ssxt9OhbaWCl5C0ucEI=
-google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
+google.golang.org/genproto v0.0.0-20220829144015-23454907ede3 h1:4wwmycAWg7WUIFWgzxP6Wumy2GBLxmATgkhgpFnJl2U=
+google.golang.org/genproto v0.0.0-20220829144015-23454907ede3/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=

+ 2 - 2
internal/acme/acme.go

@@ -568,14 +568,14 @@ func (c *Configuration) notifyCertificateRenewal(domain string, err error) {
 	}
 	}
 	params := common.EventParams{
 	params := common.EventParams{
 		Name:      domain,
 		Name:      domain,
+		Event:     "Certificate renewal",
 		Timestamp: time.Now().UnixNano(),
 		Timestamp: time.Now().UnixNano(),
 	}
 	}
 	if err != nil {
 	if err != nil {
 		params.Status = 2
 		params.Status = 2
-		params.Event = "Certificate renewal failed"
+		params.AddError(err)
 	} else {
 	} else {
 		params.Status = 1
 		params.Status = 1
-		params.Event = "Successful certificate renewal"
 	}
 	}
 	common.HandleCertificateEvent(params)
 	common.HandleCertificateEvent(params)
 }
 }

+ 6 - 2
internal/common/actions.go

@@ -122,7 +122,7 @@ func ExecuteActionNotification(conn *BaseConnection, operation, filePath, virtua
 	}
 	}
 	var errRes error
 	var errRes error
 	if hasRules {
 	if hasRules {
-		errRes = eventManager.handleFsEvent(EventParams{
+		params := EventParams{
 			Name:              notification.Username,
 			Name:              notification.Username,
 			Event:             notification.Action,
 			Event:             notification.Action,
 			Status:            notification.Status,
 			Status:            notification.Status,
@@ -136,7 +136,11 @@ func ExecuteActionNotification(conn *BaseConnection, operation, filePath, virtua
 			IP:                notification.IP,
 			IP:                notification.IP,
 			Timestamp:         notification.Timestamp,
 			Timestamp:         notification.Timestamp,
 			Object:            nil,
 			Object:            nil,
-		})
+		}
+		if err != nil {
+			params.AddError(fmt.Errorf("%q failed: %w", params.Event, err))
+		}
+		errRes = eventManager.handleFsEvent(params)
 	}
 	}
 	if hasHook {
 	if hasHook {
 		if util.Contains(Config.Actions.ExecuteSync, operation) {
 		if util.Contains(Config.Actions.ExecuteSync, operation) {

+ 119 - 65
internal/common/eventmanager.go

@@ -406,21 +406,50 @@ func (r *eventRulesContainer) handleCertificateEvent(params EventParams) {
 
 
 // EventParams defines the supported event parameters
 // EventParams defines the supported event parameters
 type EventParams struct {
 type EventParams struct {
-	Name              string
-	Event             string
-	Status            int
-	VirtualPath       string
-	FsPath            string
-	VirtualTargetPath string
-	FsTargetPath      string
-	ObjectName        string
-	ObjectType        string
-	FileSize          int64
-	Protocol          string
-	IP                string
-	Timestamp         int64
-	Object            plugin.Renderer
-	sender            string
+	Name                  string
+	Event                 string
+	Status                int
+	VirtualPath           string
+	FsPath                string
+	VirtualTargetPath     string
+	FsTargetPath          string
+	ObjectName            string
+	ObjectType            string
+	FileSize              int64
+	Protocol              string
+	IP                    string
+	Timestamp             int64
+	Object                plugin.Renderer
+	sender                string
+	updateStatusFromError bool
+	errors                []string
+}
+
+func (p *EventParams) getACopy() *EventParams {
+	params := *p
+	params.errors = make([]string, len(p.errors))
+	copy(params.errors, p.errors)
+	return &params
+}
+
+// AddError adds a new error to the event params and update the status if needed
+func (p *EventParams) AddError(err error) {
+	if err == nil {
+		return
+	}
+	if p.updateStatusFromError && p.Status == 1 {
+		p.Status = 2
+	}
+	p.errors = append(p.errors, err.Error())
+}
+
+func (p *EventParams) getStatusString() string {
+	switch p.Status {
+	case 1:
+		return "OK"
+	default:
+		return "KO"
+	}
 }
 }
 
 
 // getUsers returns users with group settings not applied
 // getUsers returns users with group settings not applied
@@ -469,11 +498,18 @@ func (p *EventParams) getStringReplacements(addObjectData bool) []string {
 		"{{Protocol}}", p.Protocol,
 		"{{Protocol}}", p.Protocol,
 		"{{IP}}", p.IP,
 		"{{IP}}", p.IP,
 		"{{Timestamp}}", fmt.Sprintf("%d", p.Timestamp),
 		"{{Timestamp}}", fmt.Sprintf("%d", p.Timestamp),
+		"{{StatusString}}", p.getStatusString(),
 	}
 	}
+	if len(p.errors) > 0 {
+		replacements = append(replacements, "{{ErrorString}}", strings.Join(p.errors, ", "))
+	} else {
+		replacements = append(replacements, "{{ErrorString}}", "")
+	}
+	replacements = append(replacements, "{{ObjectData}}", "")
 	if addObjectData {
 	if addObjectData {
 		data, err := p.Object.RenderAsJSON(p.Event != operationDelete)
 		data, err := p.Object.RenderAsJSON(p.Event != operationDelete)
 		if err == nil {
 		if err == nil {
-			replacements = append(replacements, "{{ObjectData}}", string(data))
+			replacements[len(replacements)-1] = string(data)
 		}
 		}
 	}
 	}
 	return replacements
 	return replacements
@@ -516,7 +552,7 @@ func getMailAttachments(user dataprovider.User, attachments []string, replacer *
 	err = user.CheckFsRoot(connectionID)
 	err = user.CheckFsRoot(connectionID)
 	defer user.CloseFs() //nolint:errcheck
 	defer user.CloseFs() //nolint:errcheck
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("error getting email attachments, unable to check root fs for user %q: %w", user.Username, err)
 	}
 	}
 	conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
 	conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
 	totalSize := int64(0)
 	totalSize := int64(0)
@@ -596,7 +632,7 @@ func getHTTPRuleActionEndpoint(c dataprovider.EventActionHTTPConfig, replacer *s
 	return c.Endpoint, nil
 	return c.Endpoint, nil
 }
 }
 
 
-func executeHTTPRuleAction(c dataprovider.EventActionHTTPConfig, params EventParams) error {
+func executeHTTPRuleAction(c dataprovider.EventActionHTTPConfig, params *EventParams) error {
 	if !c.Password.IsEmpty() {
 	if !c.Password.IsEmpty() {
 		if err := c.Password.TryDecrypt(); err != nil {
 		if err := c.Password.TryDecrypt(); err != nil {
 			return fmt.Errorf("unable to decrypt password: %w", err)
 			return fmt.Errorf("unable to decrypt password: %w", err)
@@ -653,7 +689,7 @@ func executeHTTPRuleAction(c dataprovider.EventActionHTTPConfig, params EventPar
 	return nil
 	return nil
 }
 }
 
 
-func executeCommandRuleAction(c dataprovider.EventActionCommandConfig, params EventParams) error {
+func executeCommandRuleAction(c dataprovider.EventActionCommandConfig, params *EventParams) error {
 	envVars := make([]string, 0, len(c.EnvVars))
 	envVars := make([]string, 0, len(c.EnvVars))
 	addObjectData := false
 	addObjectData := false
 	if params.Object != nil {
 	if params.Object != nil {
@@ -686,7 +722,7 @@ func executeCommandRuleAction(c dataprovider.EventActionCommandConfig, params Ev
 	return err
 	return err
 }
 }
 
 
-func executeEmailRuleAction(c dataprovider.EventActionEmailConfig, params EventParams) error {
+func executeEmailRuleAction(c dataprovider.EventActionEmailConfig, params *EventParams) error {
 	addObjectData := false
 	addObjectData := false
 	if params.Object != nil {
 	if params.Object != nil {
 		if strings.Contains(c.Body, "{{ObjectData}}") {
 		if strings.Contains(c.Body, "{{ObjectData}}") {
@@ -748,7 +784,7 @@ func executeDeleteFsActionForUser(deletes []string, replacer *strings.Replacer,
 	err = user.CheckFsRoot(connectionID)
 	err = user.CheckFsRoot(connectionID)
 	defer user.CloseFs() //nolint:errcheck
 	defer user.CloseFs() //nolint:errcheck
 	if err != nil {
 	if err != nil {
-		return err
+		return fmt.Errorf("delete error, unable to check root fs for user %q: %w", user.Username, err)
 	}
 	}
 	conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
 	conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
 	for _, item := range deletes {
 	for _, item := range deletes {
@@ -775,7 +811,7 @@ func executeDeleteFsActionForUser(deletes []string, replacer *strings.Replacer,
 }
 }
 
 
 func executeDeleteFsRuleAction(deletes []string, replacer *strings.Replacer,
 func executeDeleteFsRuleAction(deletes []string, replacer *strings.Replacer,
-	conditions dataprovider.ConditionOptions, params EventParams,
+	conditions dataprovider.ConditionOptions, params *EventParams,
 ) error {
 ) error {
 	users, err := params.getUsers()
 	users, err := params.getUsers()
 	if err != nil {
 	if err != nil {
@@ -792,6 +828,7 @@ func executeDeleteFsRuleAction(deletes []string, replacer *strings.Replacer,
 		}
 		}
 		executed++
 		executed++
 		if err = executeDeleteFsActionForUser(deletes, replacer, user); err != nil {
 		if err = executeDeleteFsActionForUser(deletes, replacer, user); err != nil {
+			params.AddError(err)
 			failures = append(failures, user.Username)
 			failures = append(failures, user.Username)
 			continue
 			continue
 		}
 		}
@@ -815,7 +852,7 @@ func executeMkDirsFsActionForUser(dirs []string, replacer *strings.Replacer, use
 	err = user.CheckFsRoot(connectionID)
 	err = user.CheckFsRoot(connectionID)
 	defer user.CloseFs() //nolint:errcheck
 	defer user.CloseFs() //nolint:errcheck
 	if err != nil {
 	if err != nil {
-		return err
+		return fmt.Errorf("mkdir error, unable to check root fs for user %q: %w", user.Username, err)
 	}
 	}
 	conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
 	conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
 	for _, item := range dirs {
 	for _, item := range dirs {
@@ -832,7 +869,7 @@ func executeMkDirsFsActionForUser(dirs []string, replacer *strings.Replacer, use
 }
 }
 
 
 func executeMkdirFsRuleAction(dirs []string, replacer *strings.Replacer,
 func executeMkdirFsRuleAction(dirs []string, replacer *strings.Replacer,
-	conditions dataprovider.ConditionOptions, params EventParams,
+	conditions dataprovider.ConditionOptions, params *EventParams,
 ) error {
 ) error {
 	users, err := params.getUsers()
 	users, err := params.getUsers()
 	if err != nil {
 	if err != nil {
@@ -874,7 +911,7 @@ func executeRenameFsActionForUser(renames []dataprovider.KeyValue, replacer *str
 	err = user.CheckFsRoot(connectionID)
 	err = user.CheckFsRoot(connectionID)
 	defer user.CloseFs() //nolint:errcheck
 	defer user.CloseFs() //nolint:errcheck
 	if err != nil {
 	if err != nil {
-		return err
+		return fmt.Errorf("rename error, unable to check root fs for user %q: %w", user.Username, err)
 	}
 	}
 	conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
 	conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
 	for _, item := range renames {
 	for _, item := range renames {
@@ -899,7 +936,7 @@ func executeExistFsActionForUser(exist []string, replacer *strings.Replacer,
 	err = user.CheckFsRoot(connectionID)
 	err = user.CheckFsRoot(connectionID)
 	defer user.CloseFs() //nolint:errcheck
 	defer user.CloseFs() //nolint:errcheck
 	if err != nil {
 	if err != nil {
-		return err
+		return fmt.Errorf("existence check error, unable to check root fs for user %q: %w", user.Username, err)
 	}
 	}
 	conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
 	conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
 	for _, item := range exist {
 	for _, item := range exist {
@@ -913,7 +950,7 @@ func executeExistFsActionForUser(exist []string, replacer *strings.Replacer,
 }
 }
 
 
 func executeRenameFsRuleAction(renames []dataprovider.KeyValue, replacer *strings.Replacer,
 func executeRenameFsRuleAction(renames []dataprovider.KeyValue, replacer *strings.Replacer,
-	conditions dataprovider.ConditionOptions, params EventParams,
+	conditions dataprovider.ConditionOptions, params *EventParams,
 ) error {
 ) error {
 	users, err := params.getUsers()
 	users, err := params.getUsers()
 	if err != nil {
 	if err != nil {
@@ -931,6 +968,7 @@ func executeRenameFsRuleAction(renames []dataprovider.KeyValue, replacer *string
 		executed++
 		executed++
 		if err = executeRenameFsActionForUser(renames, replacer, user); err != nil {
 		if err = executeRenameFsActionForUser(renames, replacer, user); err != nil {
 			failures = append(failures, user.Username)
 			failures = append(failures, user.Username)
+			params.AddError(err)
 			continue
 			continue
 		}
 		}
 	}
 	}
@@ -945,7 +983,7 @@ func executeRenameFsRuleAction(renames []dataprovider.KeyValue, replacer *string
 }
 }
 
 
 func executeExistFsRuleAction(exist []string, replacer *strings.Replacer, conditions dataprovider.ConditionOptions,
 func executeExistFsRuleAction(exist []string, replacer *strings.Replacer, conditions dataprovider.ConditionOptions,
-	params EventParams,
+	params *EventParams,
 ) error {
 ) error {
 	users, err := params.getUsers()
 	users, err := params.getUsers()
 	if err != nil {
 	if err != nil {
@@ -963,6 +1001,7 @@ func executeExistFsRuleAction(exist []string, replacer *strings.Replacer, condit
 		executed++
 		executed++
 		if err = executeExistFsActionForUser(exist, replacer, user); err != nil {
 		if err = executeExistFsActionForUser(exist, replacer, user); err != nil {
 			failures = append(failures, user.Username)
 			failures = append(failures, user.Username)
+			params.AddError(err)
 			continue
 			continue
 		}
 		}
 	}
 	}
@@ -977,7 +1016,7 @@ func executeExistFsRuleAction(exist []string, replacer *strings.Replacer, condit
 }
 }
 
 
 func executeFsRuleAction(c dataprovider.EventActionFilesystemConfig, conditions dataprovider.ConditionOptions,
 func executeFsRuleAction(c dataprovider.EventActionFilesystemConfig, conditions dataprovider.ConditionOptions,
-	params EventParams,
+	params *EventParams,
 ) error {
 ) error {
 	addObjectData := false
 	addObjectData := false
 	replacements := params.getStringReplacements(addObjectData)
 	replacements := params.getStringReplacements(addObjectData)
@@ -1003,25 +1042,25 @@ func executeQuotaResetForUser(user dataprovider.User) error {
 		return err
 		return err
 	}
 	}
 	if !QuotaScans.AddUserQuotaScan(user.Username) {
 	if !QuotaScans.AddUserQuotaScan(user.Username) {
-		eventManagerLog(logger.LevelError, "another quota scan is already in progress for user %s", user.Username)
-		return fmt.Errorf("another quota scan is in progress for user %s", user.Username)
+		eventManagerLog(logger.LevelError, "another quota scan is already in progress for user %q", user.Username)
+		return fmt.Errorf("another quota scan is in progress for user %q", user.Username)
 	}
 	}
 	defer QuotaScans.RemoveUserQuotaScan(user.Username)
 	defer QuotaScans.RemoveUserQuotaScan(user.Username)
 
 
 	numFiles, size, err := user.ScanQuota()
 	numFiles, size, err := user.ScanQuota()
 	if err != nil {
 	if err != nil {
-		eventManagerLog(logger.LevelError, "error scanning quota for user %s: %v", user.Username, err)
-		return err
+		eventManagerLog(logger.LevelError, "error scanning quota for user %q: %v", user.Username, err)
+		return fmt.Errorf("error scanning quota for user %q: %w", user.Username, err)
 	}
 	}
 	err = dataprovider.UpdateUserQuota(&user, numFiles, size, true)
 	err = dataprovider.UpdateUserQuota(&user, numFiles, size, true)
 	if err != nil {
 	if err != nil {
-		eventManagerLog(logger.LevelError, "error updating quota for user %s: %v", user.Username, err)
-		return err
+		eventManagerLog(logger.LevelError, "error updating quota for user %q: %v", user.Username, err)
+		return fmt.Errorf("error updating quota for user %q: %w", user.Username, err)
 	}
 	}
 	return nil
 	return nil
 }
 }
 
 
-func executeUsersQuotaResetRuleAction(conditions dataprovider.ConditionOptions, params EventParams) error {
+func executeUsersQuotaResetRuleAction(conditions dataprovider.ConditionOptions, params *EventParams) error {
 	users, err := params.getUsers()
 	users, err := params.getUsers()
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("unable to get users: %w", err)
 		return fmt.Errorf("unable to get users: %w", err)
@@ -1031,12 +1070,13 @@ func executeUsersQuotaResetRuleAction(conditions dataprovider.ConditionOptions,
 	for _, user := range users {
 	for _, user := range users {
 		// if sender is set, the conditions have already been evaluated
 		// if sender is set, the conditions have already been evaluated
 		if params.sender == "" && !checkEventConditionPatterns(user.Username, conditions.Names) {
 		if params.sender == "" && !checkEventConditionPatterns(user.Username, conditions.Names) {
-			eventManagerLog(logger.LevelDebug, "skipping scheduled quota reset for user %s, name conditions don't match",
+			eventManagerLog(logger.LevelDebug, "skipping quota reset for user %q, name conditions don't match",
 				user.Username)
 				user.Username)
 			continue
 			continue
 		}
 		}
 		executed++
 		executed++
 		if err = executeQuotaResetForUser(user); err != nil {
 		if err = executeQuotaResetForUser(user); err != nil {
+			params.AddError(err)
 			failedResets = append(failedResets, user.Username)
 			failedResets = append(failedResets, user.Username)
 			continue
 			continue
 		}
 		}
@@ -1051,7 +1091,7 @@ func executeUsersQuotaResetRuleAction(conditions dataprovider.ConditionOptions,
 	return nil
 	return nil
 }
 }
 
 
-func executeFoldersQuotaResetRuleAction(conditions dataprovider.ConditionOptions, params EventParams) error {
+func executeFoldersQuotaResetRuleAction(conditions dataprovider.ConditionOptions, params *EventParams) error {
 	folders, err := params.getFolders()
 	folders, err := params.getFolders()
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("unable to get folders: %w", err)
 		return fmt.Errorf("unable to get folders: %w", err)
@@ -1066,7 +1106,8 @@ func executeFoldersQuotaResetRuleAction(conditions dataprovider.ConditionOptions
 			continue
 			continue
 		}
 		}
 		if !QuotaScans.AddVFolderQuotaScan(folder.Name) {
 		if !QuotaScans.AddVFolderQuotaScan(folder.Name) {
-			eventManagerLog(logger.LevelError, "another quota scan is already in progress for folder %s", folder.Name)
+			eventManagerLog(logger.LevelError, "another quota scan is already in progress for folder %q", folder.Name)
+			params.AddError(fmt.Errorf("another quota scan is already in progress for folder %q", folder.Name))
 			failedResets = append(failedResets, folder.Name)
 			failedResets = append(failedResets, folder.Name)
 			continue
 			continue
 		}
 		}
@@ -1078,13 +1119,15 @@ func executeFoldersQuotaResetRuleAction(conditions dataprovider.ConditionOptions
 		numFiles, size, err := f.ScanQuota()
 		numFiles, size, err := f.ScanQuota()
 		QuotaScans.RemoveVFolderQuotaScan(folder.Name)
 		QuotaScans.RemoveVFolderQuotaScan(folder.Name)
 		if err != nil {
 		if err != nil {
-			eventManagerLog(logger.LevelError, "error scanning quota for folder %s: %v", folder.Name, err)
+			eventManagerLog(logger.LevelError, "error scanning quota for folder %q: %v", folder.Name, err)
+			params.AddError(fmt.Errorf("error scanning quota for folder %q: %w", folder.Name, err))
 			failedResets = append(failedResets, folder.Name)
 			failedResets = append(failedResets, folder.Name)
 			continue
 			continue
 		}
 		}
 		err = dataprovider.UpdateVirtualFolderQuota(&folder, numFiles, size, true)
 		err = dataprovider.UpdateVirtualFolderQuota(&folder, numFiles, size, true)
 		if err != nil {
 		if err != nil {
-			eventManagerLog(logger.LevelError, "error updating quota for folder %s: %v", folder.Name, err)
+			eventManagerLog(logger.LevelError, "error updating quota for folder %q: %v", folder.Name, err)
+			params.AddError(fmt.Errorf("error updating quota for folder %q: %w", folder.Name, err))
 			failedResets = append(failedResets, folder.Name)
 			failedResets = append(failedResets, folder.Name)
 		}
 		}
 	}
 	}
@@ -1098,7 +1141,7 @@ func executeFoldersQuotaResetRuleAction(conditions dataprovider.ConditionOptions
 	return nil
 	return nil
 }
 }
 
 
-func executeTransferQuotaResetRuleAction(conditions dataprovider.ConditionOptions, params EventParams) error {
+func executeTransferQuotaResetRuleAction(conditions dataprovider.ConditionOptions, params *EventParams) error {
 	users, err := params.getUsers()
 	users, err := params.getUsers()
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("unable to get users: %w", err)
 		return fmt.Errorf("unable to get users: %w", err)
@@ -1115,7 +1158,8 @@ func executeTransferQuotaResetRuleAction(conditions dataprovider.ConditionOption
 		executed++
 		executed++
 		err = dataprovider.UpdateUserTransferQuota(&user, 0, 0, true)
 		err = dataprovider.UpdateUserTransferQuota(&user, 0, 0, true)
 		if err != nil {
 		if err != nil {
-			eventManagerLog(logger.LevelError, "error updating transfer quota for user %s: %v", user.Username, err)
+			eventManagerLog(logger.LevelError, "error updating transfer quota for user %q: %v", user.Username, err)
+			params.AddError(fmt.Errorf("error updating transfer quota for user %q: %w", user.Username, err))
 			failedResets = append(failedResets, user.Username)
 			failedResets = append(failedResets, user.Username)
 		}
 		}
 	}
 	}
@@ -1140,18 +1184,18 @@ func executeDataRetentionCheckForUser(user dataprovider.User, folders []dataprov
 	}
 	}
 	c := RetentionChecks.Add(check, &user)
 	c := RetentionChecks.Add(check, &user)
 	if c == nil {
 	if c == nil {
-		eventManagerLog(logger.LevelError, "another retention check is already in progress for user %s", user.Username)
-		return fmt.Errorf("another retention check is in progress for user %s", user.Username)
+		eventManagerLog(logger.LevelError, "another retention check is already in progress for user %q", user.Username)
+		return fmt.Errorf("another retention check is in progress for user %q", user.Username)
 	}
 	}
 	if err := c.Start(); err != nil {
 	if err := c.Start(); err != nil {
-		eventManagerLog(logger.LevelError, "error checking retention for user %s: %v", user.Username, err)
-		return err
+		eventManagerLog(logger.LevelError, "error checking retention for user %q: %v", user.Username, err)
+		return fmt.Errorf("error checking retention for user %q: %w", user.Username, err)
 	}
 	}
 	return nil
 	return nil
 }
 }
 
 
 func executeDataRetentionCheckRuleAction(config dataprovider.EventActionDataRetentionConfig,
 func executeDataRetentionCheckRuleAction(config dataprovider.EventActionDataRetentionConfig,
-	conditions dataprovider.ConditionOptions, params EventParams,
+	conditions dataprovider.ConditionOptions, params *EventParams,
 ) error {
 ) error {
 	users, err := params.getUsers()
 	users, err := params.getUsers()
 	if err != nil {
 	if err != nil {
@@ -1169,6 +1213,7 @@ func executeDataRetentionCheckRuleAction(config dataprovider.EventActionDataRete
 		executed++
 		executed++
 		if err = executeDataRetentionCheckForUser(user, config.Folders); err != nil {
 		if err = executeDataRetentionCheckForUser(user, config.Folders); err != nil {
 			failedChecks = append(failedChecks, user.Username)
 			failedChecks = append(failedChecks, user.Username)
+			params.AddError(err)
 			continue
 			continue
 		}
 		}
 	}
 	}
@@ -1182,29 +1227,37 @@ func executeDataRetentionCheckRuleAction(config dataprovider.EventActionDataRete
 	return nil
 	return nil
 }
 }
 
 
-func executeRuleAction(action dataprovider.BaseEventAction, params EventParams, conditions dataprovider.ConditionOptions) error {
+func executeRuleAction(action dataprovider.BaseEventAction, params *EventParams, conditions dataprovider.ConditionOptions) error {
+	var err error
+
 	switch action.Type {
 	switch action.Type {
 	case dataprovider.ActionTypeHTTP:
 	case dataprovider.ActionTypeHTTP:
-		return executeHTTPRuleAction(action.Options.HTTPConfig, params)
+		err = executeHTTPRuleAction(action.Options.HTTPConfig, params)
 	case dataprovider.ActionTypeCommand:
 	case dataprovider.ActionTypeCommand:
-		return executeCommandRuleAction(action.Options.CmdConfig, params)
+		err = executeCommandRuleAction(action.Options.CmdConfig, params)
 	case dataprovider.ActionTypeEmail:
 	case dataprovider.ActionTypeEmail:
-		return executeEmailRuleAction(action.Options.EmailConfig, params)
+		err = executeEmailRuleAction(action.Options.EmailConfig, params)
 	case dataprovider.ActionTypeBackup:
 	case dataprovider.ActionTypeBackup:
-		return dataprovider.ExecuteBackup()
+		err = dataprovider.ExecuteBackup()
 	case dataprovider.ActionTypeUserQuotaReset:
 	case dataprovider.ActionTypeUserQuotaReset:
-		return executeUsersQuotaResetRuleAction(conditions, params)
+		err = executeUsersQuotaResetRuleAction(conditions, params)
 	case dataprovider.ActionTypeFolderQuotaReset:
 	case dataprovider.ActionTypeFolderQuotaReset:
-		return executeFoldersQuotaResetRuleAction(conditions, params)
+		err = executeFoldersQuotaResetRuleAction(conditions, params)
 	case dataprovider.ActionTypeTransferQuotaReset:
 	case dataprovider.ActionTypeTransferQuotaReset:
-		return executeTransferQuotaResetRuleAction(conditions, params)
+		err = executeTransferQuotaResetRuleAction(conditions, params)
 	case dataprovider.ActionTypeDataRetentionCheck:
 	case dataprovider.ActionTypeDataRetentionCheck:
-		return executeDataRetentionCheckRuleAction(action.Options.RetentionConfig, conditions, params)
+		err = executeDataRetentionCheckRuleAction(action.Options.RetentionConfig, conditions, params)
 	case dataprovider.ActionTypeFilesystem:
 	case dataprovider.ActionTypeFilesystem:
-		return executeFsRuleAction(action.Options.FsConfig, conditions, params)
+		err = executeFsRuleAction(action.Options.FsConfig, conditions, params)
 	default:
 	default:
-		return fmt.Errorf("unsupported action type: %d", action.Type)
+		err = fmt.Errorf("unsupported action type: %d", action.Type)
+	}
+
+	if err != nil {
+		err = fmt.Errorf("action %q failed: %w", action.Name, err)
 	}
 	}
+	params.AddError(err)
+	return err
 }
 }
 
 
 func executeSyncRulesActions(rules []dataprovider.EventRule, params EventParams) error {
 func executeSyncRulesActions(rules []dataprovider.EventRule, params EventParams) error {
@@ -1212,10 +1265,11 @@ func executeSyncRulesActions(rules []dataprovider.EventRule, params EventParams)
 
 
 	for _, rule := range rules {
 	for _, rule := range rules {
 		var failedActions []string
 		var failedActions []string
+		paramsCopy := params.getACopy()
 		for _, action := range rule.Actions {
 		for _, action := range rule.Actions {
 			if !action.Options.IsFailureAction && action.Options.ExecuteSync {
 			if !action.Options.IsFailureAction && action.Options.ExecuteSync {
 				startTime := time.Now()
 				startTime := time.Now()
-				if err := executeRuleAction(action.BaseEventAction, params, rule.Conditions.Options); err != nil {
+				if err := executeRuleAction(action.BaseEventAction, paramsCopy, rule.Conditions.Options); err != nil {
 					eventManagerLog(logger.LevelError, "unable to execute sync action %q for rule %q, elapsed %s, err: %v",
 					eventManagerLog(logger.LevelError, "unable to execute sync action %q for rule %q, elapsed %s, err: %v",
 						action.Name, rule.Name, time.Since(startTime), err)
 						action.Name, rule.Name, time.Since(startTime), err)
 					failedActions = append(failedActions, action.Name)
 					failedActions = append(failedActions, action.Name)
@@ -1231,7 +1285,7 @@ func executeSyncRulesActions(rules []dataprovider.EventRule, params EventParams)
 			}
 			}
 		}
 		}
 		// execute async actions if any, including failure actions
 		// execute async actions if any, including failure actions
-		go executeRuleAsyncActions(rule, params, failedActions)
+		go executeRuleAsyncActions(rule, paramsCopy, failedActions)
 	}
 	}
 
 
 	return errRes
 	return errRes
@@ -1242,11 +1296,11 @@ func executeAsyncRulesActions(rules []dataprovider.EventRule, params EventParams
 	defer eventManager.removeAsyncTask()
 	defer eventManager.removeAsyncTask()
 
 
 	for _, rule := range rules {
 	for _, rule := range rules {
-		executeRuleAsyncActions(rule, params, nil)
+		executeRuleAsyncActions(rule, params.getACopy(), nil)
 	}
 	}
 }
 }
 
 
-func executeRuleAsyncActions(rule dataprovider.EventRule, params EventParams, failedActions []string) {
+func executeRuleAsyncActions(rule dataprovider.EventRule, params *EventParams, failedActions []string) {
 	for _, action := range rule.Actions {
 	for _, action := range rule.Actions {
 		if !action.Options.IsFailureAction && !action.Options.ExecuteSync {
 		if !action.Options.IsFailureAction && !action.Options.ExecuteSync {
 			startTime := time.Now()
 			startTime := time.Now()
@@ -1361,9 +1415,9 @@ func (j *eventCronJob) Run() {
 			}
 			}
 		}(task.Name)
 		}(task.Name)
 
 
-		executeAsyncRulesActions([]dataprovider.EventRule{rule}, EventParams{})
+		executeAsyncRulesActions([]dataprovider.EventRule{rule}, EventParams{Status: 1, updateStatusFromError: true})
 	} else {
 	} else {
-		executeAsyncRulesActions([]dataprovider.EventRule{rule}, EventParams{})
+		executeAsyncRulesActions([]dataprovider.EventRule{rule}, EventParams{Status: 1, updateStatusFromError: true})
 	}
 	}
 	eventManagerLog(logger.LevelDebug, "execution for scheduled rule %q finished", j.ruleName)
 	eventManagerLog(logger.LevelDebug, "execution for scheduled rule %q finished", j.ruleName)
 }
 }

+ 76 - 42
internal/common/eventmanager_test.go

@@ -270,19 +270,19 @@ func TestEventManagerErrors(t *testing.T) {
 	_, err = params.getFolders()
 	_, err = params.getFolders()
 	assert.Error(t, err)
 	assert.Error(t, err)
 
 
-	err = executeUsersQuotaResetRuleAction(dataprovider.ConditionOptions{}, EventParams{})
+	err = executeUsersQuotaResetRuleAction(dataprovider.ConditionOptions{}, &EventParams{})
 	assert.Error(t, err)
 	assert.Error(t, err)
-	err = executeFoldersQuotaResetRuleAction(dataprovider.ConditionOptions{}, EventParams{})
+	err = executeFoldersQuotaResetRuleAction(dataprovider.ConditionOptions{}, &EventParams{})
 	assert.Error(t, err)
 	assert.Error(t, err)
-	err = executeTransferQuotaResetRuleAction(dataprovider.ConditionOptions{}, EventParams{})
+	err = executeTransferQuotaResetRuleAction(dataprovider.ConditionOptions{}, &EventParams{})
 	assert.Error(t, err)
 	assert.Error(t, err)
-	err = executeDeleteFsRuleAction(nil, nil, dataprovider.ConditionOptions{}, EventParams{})
+	err = executeDeleteFsRuleAction(nil, nil, dataprovider.ConditionOptions{}, &EventParams{})
 	assert.Error(t, err)
 	assert.Error(t, err)
-	err = executeMkdirFsRuleAction(nil, nil, dataprovider.ConditionOptions{}, EventParams{})
+	err = executeMkdirFsRuleAction(nil, nil, dataprovider.ConditionOptions{}, &EventParams{})
 	assert.Error(t, err)
 	assert.Error(t, err)
-	err = executeRenameFsRuleAction(nil, nil, dataprovider.ConditionOptions{}, EventParams{})
+	err = executeRenameFsRuleAction(nil, nil, dataprovider.ConditionOptions{}, &EventParams{})
 	assert.Error(t, err)
 	assert.Error(t, err)
-	err = executeExistFsRuleAction(nil, nil, dataprovider.ConditionOptions{}, EventParams{})
+	err = executeExistFsRuleAction(nil, nil, dataprovider.ConditionOptions{}, &EventParams{})
 	assert.Error(t, err)
 	assert.Error(t, err)
 
 
 	groupName := "agroup"
 	groupName := "agroup"
@@ -362,7 +362,7 @@ func TestEventManagerErrors(t *testing.T) {
 			},
 			},
 		},
 		},
 	}
 	}
-	err = executeRuleAction(dataRetentionAction, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(dataRetentionAction, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: "username1",
 				Pattern: "username1",
@@ -421,10 +421,10 @@ func TestEventRuleActions(t *testing.T) {
 		Name: actionName,
 		Name: actionName,
 		Type: dataprovider.ActionTypeBackup,
 		Type: dataprovider.ActionTypeBackup,
 	}
 	}
-	err := executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{})
+	err := executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{})
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	action.Type = -1
 	action.Type = -1
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{})
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{})
 	assert.Error(t, err)
 	assert.Error(t, err)
 
 
 	action = dataprovider.BaseEventAction{
 	action = dataprovider.BaseEventAction{
@@ -454,12 +454,12 @@ func TestEventRuleActions(t *testing.T) {
 		},
 		},
 	}
 	}
 	action.Options.SetEmptySecretsIfNil()
 	action.Options.SetEmptySecretsIfNil()
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{})
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{})
 	if assert.Error(t, err) {
 	if assert.Error(t, err) {
 		assert.Contains(t, err.Error(), "invalid endpoint")
 		assert.Contains(t, err.Error(), "invalid endpoint")
 	}
 	}
 	action.Options.HTTPConfig.Endpoint = fmt.Sprintf("http://%v", httpAddr)
 	action.Options.HTTPConfig.Endpoint = fmt.Sprintf("http://%v", httpAddr)
-	params := EventParams{
+	params := &EventParams{
 		Name: "a",
 		Name: "a",
 		Object: &dataprovider.User{
 		Object: &dataprovider.User{
 			BaseUser: sdk.BaseUser{
 			BaseUser: sdk.BaseUser{
@@ -472,7 +472,7 @@ func TestEventRuleActions(t *testing.T) {
 	action.Options.HTTPConfig.Endpoint = fmt.Sprintf("http://%v/404", httpAddr)
 	action.Options.HTTPConfig.Endpoint = fmt.Sprintf("http://%v/404", httpAddr)
 	err = executeRuleAction(action, params, dataprovider.ConditionOptions{})
 	err = executeRuleAction(action, params, dataprovider.ConditionOptions{})
 	if assert.Error(t, err) {
 	if assert.Error(t, err) {
-		assert.Equal(t, err.Error(), "unexpected status code: 404")
+		assert.Contains(t, err.Error(), "unexpected status code: 404")
 	}
 	}
 	action.Options.HTTPConfig.Endpoint = "http://invalid:1234"
 	action.Options.HTTPConfig.Endpoint = "http://invalid:1234"
 	err = executeRuleAction(action, params, dataprovider.ConditionOptions{})
 	err = executeRuleAction(action, params, dataprovider.ConditionOptions{})
@@ -517,7 +517,7 @@ func TestEventRuleActions(t *testing.T) {
 	action = dataprovider.BaseEventAction{
 	action = dataprovider.BaseEventAction{
 		Type: dataprovider.ActionTypeUserQuotaReset,
 		Type: dataprovider.ActionTypeUserQuotaReset,
 	}
 	}
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: username1,
 				Pattern: username1,
@@ -530,7 +530,7 @@ func TestEventRuleActions(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	err = os.WriteFile(filepath.Join(user1.GetHomeDir(), "file.txt"), []byte("user"), 0666)
 	err = os.WriteFile(filepath.Join(user1.GetHomeDir(), "file.txt"), []byte("user"), 0666)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: username1,
 				Pattern: username1,
@@ -544,7 +544,7 @@ func TestEventRuleActions(t *testing.T) {
 	assert.Equal(t, int64(4), userGet.UsedQuotaSize)
 	assert.Equal(t, int64(4), userGet.UsedQuotaSize)
 	// simulate another quota scan in progress
 	// simulate another quota scan in progress
 	assert.True(t, QuotaScans.AddUserQuotaScan(username1))
 	assert.True(t, QuotaScans.AddUserQuotaScan(username1))
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: username1,
 				Pattern: username1,
@@ -554,7 +554,7 @@ func TestEventRuleActions(t *testing.T) {
 	assert.Error(t, err)
 	assert.Error(t, err)
 	assert.True(t, QuotaScans.RemoveUserQuotaScan(username1))
 	assert.True(t, QuotaScans.RemoveUserQuotaScan(username1))
 	// non matching pattern
 	// non matching pattern
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: "don't match",
 				Pattern: "don't match",
@@ -578,7 +578,7 @@ func TestEventRuleActions(t *testing.T) {
 			},
 			},
 		},
 		},
 	}
 	}
-	err = executeRuleAction(dataRetentionAction, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(dataRetentionAction, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: username1,
 				Pattern: username1,
@@ -622,7 +622,7 @@ func TestEventRuleActions(t *testing.T) {
 	err = os.Chtimes(file4, timeBeforeRetention, timeBeforeRetention)
 	err = os.Chtimes(file4, timeBeforeRetention, timeBeforeRetention)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 
 
-	err = executeRuleAction(dataRetentionAction, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(dataRetentionAction, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: username1,
 				Pattern: username1,
@@ -637,7 +637,7 @@ func TestEventRuleActions(t *testing.T) {
 	// simulate another check in progress
 	// simulate another check in progress
 	c := RetentionChecks.Add(RetentionCheck{}, &user1)
 	c := RetentionChecks.Add(RetentionCheck{}, &user1)
 	assert.NotNil(t, c)
 	assert.NotNil(t, c)
-	err = executeRuleAction(dataRetentionAction, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(dataRetentionAction, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: username1,
 				Pattern: username1,
@@ -647,7 +647,7 @@ func TestEventRuleActions(t *testing.T) {
 	assert.Error(t, err)
 	assert.Error(t, err)
 	RetentionChecks.remove(user1.Username)
 	RetentionChecks.remove(user1.Username)
 
 
-	err = executeRuleAction(dataRetentionAction, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(dataRetentionAction, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: "no match",
 				Pattern: "no match",
@@ -667,7 +667,7 @@ func TestEventRuleActions(t *testing.T) {
 			},
 			},
 		},
 		},
 	}
 	}
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: "no match",
 				Pattern: "no match",
@@ -677,7 +677,7 @@ func TestEventRuleActions(t *testing.T) {
 	if assert.Error(t, err) {
 	if assert.Error(t, err) {
 		assert.Contains(t, err.Error(), "no existence check executed")
 		assert.Contains(t, err.Error(), "no existence check executed")
 	}
 	}
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: username1,
 				Pattern: username1,
@@ -686,7 +686,7 @@ func TestEventRuleActions(t *testing.T) {
 	})
 	})
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	action.Options.FsConfig.Exist = []string{"/file1.txt", path.Join("/", retentionDir, "file2.txt")}
 	action.Options.FsConfig.Exist = []string{"/file1.txt", path.Join("/", retentionDir, "file2.txt")}
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: username1,
 				Pattern: username1,
@@ -702,7 +702,7 @@ func TestEventRuleActions(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 
 
 	action.Type = dataprovider.ActionTypeTransferQuotaReset
 	action.Type = dataprovider.ActionTypeTransferQuotaReset
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: username1,
 				Pattern: username1,
@@ -715,7 +715,7 @@ func TestEventRuleActions(t *testing.T) {
 	assert.Equal(t, int64(0), userGet.UsedDownloadDataTransfer)
 	assert.Equal(t, int64(0), userGet.UsedDownloadDataTransfer)
 	assert.Equal(t, int64(0), userGet.UsedUploadDataTransfer)
 	assert.Equal(t, int64(0), userGet.UsedUploadDataTransfer)
 
 
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: "no match",
 				Pattern: "no match",
@@ -737,7 +737,7 @@ func TestEventRuleActions(t *testing.T) {
 			},
 			},
 		},
 		},
 	}
 	}
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: "no match",
 				Pattern: "no match",
@@ -753,7 +753,7 @@ func TestEventRuleActions(t *testing.T) {
 			Deletes: []string{"/dir1"},
 			Deletes: []string{"/dir1"},
 		},
 		},
 	}
 	}
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: "no match",
 				Pattern: "no match",
@@ -769,7 +769,7 @@ func TestEventRuleActions(t *testing.T) {
 			Deletes: []string{"/dir1"},
 			Deletes: []string{"/dir1"},
 		},
 		},
 	}
 	}
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: "no match",
 				Pattern: "no match",
@@ -802,7 +802,7 @@ func TestEventRuleActions(t *testing.T) {
 	action = dataprovider.BaseEventAction{
 	action = dataprovider.BaseEventAction{
 		Type: dataprovider.ActionTypeFolderQuotaReset,
 		Type: dataprovider.ActionTypeFolderQuotaReset,
 	}
 	}
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: foldername1,
 				Pattern: foldername1,
@@ -814,7 +814,7 @@ func TestEventRuleActions(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	err = os.WriteFile(filepath.Join(folder1.MappedPath, "file.txt"), []byte("folder"), 0666)
 	err = os.WriteFile(filepath.Join(folder1.MappedPath, "file.txt"), []byte("folder"), 0666)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: foldername1,
 				Pattern: foldername1,
@@ -828,7 +828,7 @@ func TestEventRuleActions(t *testing.T) {
 	assert.Equal(t, int64(6), folderGet.UsedQuotaSize)
 	assert.Equal(t, int64(6), folderGet.UsedQuotaSize)
 	// simulate another quota scan in progress
 	// simulate another quota scan in progress
 	assert.True(t, QuotaScans.AddVFolderQuotaScan(foldername1))
 	assert.True(t, QuotaScans.AddVFolderQuotaScan(foldername1))
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: foldername1,
 				Pattern: foldername1,
@@ -838,7 +838,7 @@ func TestEventRuleActions(t *testing.T) {
 	assert.Error(t, err)
 	assert.Error(t, err)
 	assert.True(t, QuotaScans.RemoveVFolderQuotaScan(foldername1))
 	assert.True(t, QuotaScans.RemoveVFolderQuotaScan(foldername1))
 
 
-	err = executeRuleAction(action, EventParams{}, dataprovider.ConditionOptions{
+	err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: "no folder match",
 				Pattern: "no folder match",
@@ -920,7 +920,7 @@ func TestGetFileContent(t *testing.T) {
 }
 }
 
 
 func TestFilesystemActionErrors(t *testing.T) {
 func TestFilesystemActionErrors(t *testing.T) {
-	err := executeFsRuleAction(dataprovider.EventActionFilesystemConfig{}, dataprovider.ConditionOptions{}, EventParams{})
+	err := executeFsRuleAction(dataprovider.EventActionFilesystemConfig{}, dataprovider.ConditionOptions{}, &EventParams{})
 	if assert.Error(t, err) {
 	if assert.Error(t, err) {
 		assert.Contains(t, err.Error(), "unsupported filesystem action")
 		assert.Contains(t, err.Error(), "unsupported filesystem action")
 	}
 	}
@@ -950,7 +950,7 @@ func TestFilesystemActionErrors(t *testing.T) {
 		Subject:     "subject",
 		Subject:     "subject",
 		Body:        "body",
 		Body:        "body",
 		Attachments: []string{"/file.txt"},
 		Attachments: []string{"/file.txt"},
-	}, EventParams{
+	}, &EventParams{
 		sender: username,
 		sender: username,
 	})
 	})
 	assert.Error(t, err)
 	assert.Error(t, err)
@@ -973,7 +973,7 @@ func TestFilesystemActionErrors(t *testing.T) {
 		Subject:     "subject",
 		Subject:     "subject",
 		Body:        "body",
 		Body:        "body",
 		Attachments: []string{"/file1.txt"},
 		Attachments: []string{"/file1.txt"},
-	}, EventParams{
+	}, &EventParams{
 		sender: username,
 		sender: username,
 	})
 	})
 	assert.Error(t, err)
 	assert.Error(t, err)
@@ -1008,7 +1008,7 @@ func TestFilesystemActionErrors(t *testing.T) {
 				},
 				},
 			},
 			},
 		},
 		},
-	}, EventParams{}, dataprovider.ConditionOptions{
+	}, &EventParams{}, dataprovider.ConditionOptions{
 		Names: []dataprovider.ConditionPattern{
 		Names: []dataprovider.ConditionPattern{
 			{
 			{
 				Pattern: username,
 				Pattern: username,
@@ -1045,7 +1045,7 @@ func TestFilesystemActionErrors(t *testing.T) {
 					Deletes: []string{"/adir/sub/f.dat"},
 					Deletes: []string{"/adir/sub/f.dat"},
 				},
 				},
 			},
 			},
-		}, EventParams{}, dataprovider.ConditionOptions{
+		}, &EventParams{}, dataprovider.ConditionOptions{
 			Names: []dataprovider.ConditionPattern{
 			Names: []dataprovider.ConditionPattern{
 				{
 				{
 					Pattern: username,
 					Pattern: username,
@@ -1071,7 +1071,7 @@ func TestFilesystemActionErrors(t *testing.T) {
 					MkDirs: []string{"/adir/sub/sub1"},
 					MkDirs: []string{"/adir/sub/sub1"},
 				},
 				},
 			},
 			},
-		}, EventParams{}, dataprovider.ConditionOptions{
+		}, &EventParams{}, dataprovider.ConditionOptions{
 			Names: []dataprovider.ConditionPattern{
 			Names: []dataprovider.ConditionPattern{
 				{
 				{
 					Pattern: username,
 					Pattern: username,
@@ -1119,7 +1119,7 @@ func TestQuotaActionsWithQuotaTrackDisabled(t *testing.T) {
 	err = os.MkdirAll(user.GetHomeDir(), os.ModePerm)
 	err = os.MkdirAll(user.GetHomeDir(), os.ModePerm)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	err = executeRuleAction(dataprovider.BaseEventAction{Type: dataprovider.ActionTypeUserQuotaReset},
 	err = executeRuleAction(dataprovider.BaseEventAction{Type: dataprovider.ActionTypeUserQuotaReset},
-		EventParams{}, dataprovider.ConditionOptions{
+		&EventParams{}, dataprovider.ConditionOptions{
 			Names: []dataprovider.ConditionPattern{
 			Names: []dataprovider.ConditionPattern{
 				{
 				{
 					Pattern: username,
 					Pattern: username,
@@ -1129,7 +1129,7 @@ func TestQuotaActionsWithQuotaTrackDisabled(t *testing.T) {
 	assert.Error(t, err)
 	assert.Error(t, err)
 
 
 	err = executeRuleAction(dataprovider.BaseEventAction{Type: dataprovider.ActionTypeTransferQuotaReset},
 	err = executeRuleAction(dataprovider.BaseEventAction{Type: dataprovider.ActionTypeTransferQuotaReset},
-		EventParams{}, dataprovider.ConditionOptions{
+		&EventParams{}, dataprovider.ConditionOptions{
 			Names: []dataprovider.ConditionPattern{
 			Names: []dataprovider.ConditionPattern{
 				{
 				{
 					Pattern: username,
 					Pattern: username,
@@ -1154,7 +1154,7 @@ func TestQuotaActionsWithQuotaTrackDisabled(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 
 
 	err = executeRuleAction(dataprovider.BaseEventAction{Type: dataprovider.ActionTypeFolderQuotaReset},
 	err = executeRuleAction(dataprovider.BaseEventAction{Type: dataprovider.ActionTypeFolderQuotaReset},
-		EventParams{}, dataprovider.ConditionOptions{
+		&EventParams{}, dataprovider.ConditionOptions{
 			Names: []dataprovider.ConditionPattern{
 			Names: []dataprovider.ConditionPattern{
 				{
 				{
 					Pattern: foldername,
 					Pattern: foldername,
@@ -1242,3 +1242,37 @@ func TestScheduledActions(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	stopEventScheduler()
 	stopEventScheduler()
 }
 }
+
+func TestEventParamsCopy(t *testing.T) {
+	params := EventParams{
+		Name:   "name",
+		Event:  "event",
+		Status: 1,
+		errors: []string{"error1"},
+	}
+	paramsCopy := params.getACopy()
+	assert.Equal(t, params, *paramsCopy)
+	params.Name = "name mod"
+	paramsCopy.Event = "event mod"
+	paramsCopy.Status = 2
+	params.errors = append(params.errors, "error2")
+	paramsCopy.errors = append(paramsCopy.errors, "error3")
+	assert.Equal(t, []string{"error1", "error3"}, paramsCopy.errors)
+	assert.Equal(t, []string{"error1", "error2"}, params.errors)
+	assert.Equal(t, "name mod", params.Name)
+	assert.Equal(t, "name", paramsCopy.Name)
+	assert.Equal(t, "event", params.Event)
+	assert.Equal(t, "event mod", paramsCopy.Event)
+	assert.Equal(t, 1, params.Status)
+	assert.Equal(t, 2, paramsCopy.Status)
+}
+
+func TestEventParamsStatusFromError(t *testing.T) {
+	params := EventParams{Status: 1}
+	params.AddError(os.ErrNotExist)
+	assert.Equal(t, 1, params.Status)
+
+	params = EventParams{Status: 1, updateStatusFromError: true}
+	params.AddError(os.ErrNotExist)
+	assert.Equal(t, 2, params.Status)
+}

+ 67 - 31
internal/common/protocol_test.go

@@ -19,6 +19,7 @@ import (
 	"bytes"
 	"bytes"
 	"crypto/rand"
 	"crypto/rand"
 	"encoding/json"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
 	"math"
 	"math"
@@ -3064,8 +3065,8 @@ func TestEventRule(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"test1@example.com", "test2@example.com"},
 				Recipients: []string{"test1@example.com", "test2@example.com"},
-				Subject:    `New "{{Event}}" from "{{Name}}"`,
-				Body:       "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}} Data: {{ObjectData}}",
+				Subject:    `New "{{Event}}" from "{{Name}}" status {{StatusString}}`,
+				Body:       "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}} Data: {{ObjectData}} {{ErrorString}}",
 			},
 			},
 		},
 		},
 	}
 	}
@@ -3076,7 +3077,7 @@ func TestEventRule(t *testing.T) {
 			EmailConfig: dataprovider.EventActionEmailConfig{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"failure@example.com"},
 				Recipients: []string{"failure@example.com"},
 				Subject:    `Failed "{{Event}}" from "{{Name}}"`,
 				Subject:    `Failed "{{Event}}" from "{{Name}}"`,
-				Body:       "Fs path {{FsPath}}, protocol: {{Protocol}}, IP: {{IP}}",
+				Body:       "Fs path {{FsPath}}, protocol: {{Protocol}}, IP: {{IP}} {{ErrorString}}",
 			},
 			},
 		},
 		},
 	}
 	}
@@ -3187,6 +3188,7 @@ func TestEventRule(t *testing.T) {
 
 
 	uploadScriptPath := filepath.Join(os.TempDir(), "upload.sh")
 	uploadScriptPath := filepath.Join(os.TempDir(), "upload.sh")
 	u := getTestUser()
 	u := getTestUser()
+	u.DownloadDataTransfer = 1
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	movedFileName := "moved.dat"
 	movedFileName := "moved.dat"
@@ -3230,6 +3232,8 @@ func TestEventRule(t *testing.T) {
 		dirName := "subdir"
 		dirName := "subdir"
 		err = client.Mkdir(dirName)
 		err = client.Mkdir(dirName)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
+		err = client.Mkdir("subdir1")
+		assert.NoError(t, err)
 		// rule conditions match
 		// rule conditions match
 		lastReceivedEmail.reset()
 		lastReceivedEmail.reset()
 		err = writeSFTPFileNoCheck(path.Join(dirName, testFileName), size, client)
 		err = writeSFTPFileNoCheck(path.Join(dirName, testFileName), size, client)
@@ -3247,7 +3251,32 @@ func TestEventRule(t *testing.T) {
 		assert.Len(t, email.To, 2)
 		assert.Len(t, email.To, 2)
 		assert.True(t, util.Contains(email.To, "test1@example.com"))
 		assert.True(t, util.Contains(email.To, "test1@example.com"))
 		assert.True(t, util.Contains(email.To, "test2@example.com"))
 		assert.True(t, util.Contains(email.To, "test2@example.com"))
-		assert.Contains(t, string(email.Data), fmt.Sprintf(`Subject: New "upload" from "%s"`, user.Username))
+		assert.Contains(t, email.Data, fmt.Sprintf(`Subject: New "upload" from "%s" status OK`, user.Username))
+		// test the failure action, we download a file that exceeds the transfer quota limit
+		err = writeSFTPFileNoCheck(path.Join("subdir1", testFileName), 1*1024*1024+65535, client)
+		assert.NoError(t, err)
+		lastReceivedEmail.reset()
+		f, err := client.Open(path.Join("subdir1", testFileName))
+		assert.NoError(t, err)
+		_, err = io.ReadAll(f)
+		if assert.Error(t, err) {
+			assert.Contains(t, err.Error(), common.ErrReadQuotaExceeded.Error())
+		}
+		err = f.Close()
+		assert.Error(t, err)
+		assert.Eventually(t, func() bool {
+			return lastReceivedEmail.get().From != ""
+		}, 3000*time.Millisecond, 100*time.Millisecond)
+		email = lastReceivedEmail.get()
+		assert.Len(t, email.To, 2)
+		assert.True(t, util.Contains(email.To, "test1@example.com"))
+		assert.True(t, util.Contains(email.To, "test2@example.com"))
+		assert.Contains(t, email.Data, fmt.Sprintf(`Subject: New "download" from "%s" status KO`, user.Username))
+		assert.Contains(t, email.Data, `"download" failed`)
+		assert.Contains(t, email.Data, common.ErrReadQuotaExceeded.Error())
+		_, err = httpdtest.UpdateTransferQuotaUsage(user, "", http.StatusOK)
+		assert.NoError(t, err)
+
 		// remove the upload script to test the failure action
 		// remove the upload script to test the failure action
 		err = os.Remove(uploadScriptPath)
 		err = os.Remove(uploadScriptPath)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
@@ -3260,10 +3289,11 @@ func TestEventRule(t *testing.T) {
 		email = lastReceivedEmail.get()
 		email = lastReceivedEmail.get()
 		assert.Len(t, email.To, 1)
 		assert.Len(t, email.To, 1)
 		assert.True(t, util.Contains(email.To, "failure@example.com"))
 		assert.True(t, util.Contains(email.To, "failure@example.com"))
-		assert.Contains(t, string(email.Data), fmt.Sprintf(`Subject: Failed "upload" from "%s"`, user.Username))
+		assert.Contains(t, email.Data, fmt.Sprintf(`Subject: Failed "upload" from "%s"`, user.Username))
+		assert.Contains(t, email.Data, fmt.Sprintf(`action %q failed`, action1.Name))
 		// now test the download rule
 		// now test the download rule
 		lastReceivedEmail.reset()
 		lastReceivedEmail.reset()
-		f, err := client.Open(movedFileName)
+		f, err = client.Open(movedFileName)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 		contents, err := io.ReadAll(f)
 		contents, err := io.ReadAll(f)
 		assert.NoError(t, err)
 		assert.NoError(t, err)
@@ -3277,7 +3307,7 @@ func TestEventRule(t *testing.T) {
 		assert.Len(t, email.To, 2)
 		assert.Len(t, email.To, 2)
 		assert.True(t, util.Contains(email.To, "test1@example.com"))
 		assert.True(t, util.Contains(email.To, "test1@example.com"))
 		assert.True(t, util.Contains(email.To, "test2@example.com"))
 		assert.True(t, util.Contains(email.To, "test2@example.com"))
-		assert.Contains(t, string(email.Data), fmt.Sprintf(`Subject: New "download" from "%s"`, user.Username))
+		assert.Contains(t, email.Data, fmt.Sprintf(`Subject: New "download" from "%s"`, user.Username))
 	}
 	}
 
 
 	_, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
 	_, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
@@ -3291,7 +3321,7 @@ func TestEventRule(t *testing.T) {
 	assert.Len(t, email.To, 2)
 	assert.Len(t, email.To, 2)
 	assert.True(t, util.Contains(email.To, "test1@example.com"))
 	assert.True(t, util.Contains(email.To, "test1@example.com"))
 	assert.True(t, util.Contains(email.To, "test2@example.com"))
 	assert.True(t, util.Contains(email.To, "test2@example.com"))
-	assert.Contains(t, string(email.Data), `Subject: New "delete" from "admin"`)
+	assert.Contains(t, email.Data, `Subject: New "delete" from "admin"`)
 	_, err = httpdtest.RemoveEventRule(rule3, http.StatusOK)
 	_, err = httpdtest.RemoveEventRule(rule3, http.StatusOK)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	_, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
 	_, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
@@ -3443,7 +3473,7 @@ func TestEventRuleProviderEvents(t *testing.T) {
 		email := lastReceivedEmail.get()
 		email := lastReceivedEmail.get()
 		assert.Len(t, email.To, 1)
 		assert.Len(t, email.To, 1)
 		assert.True(t, util.Contains(email.To, "test3@example.com"))
 		assert.True(t, util.Contains(email.To, "test3@example.com"))
-		assert.Contains(t, string(email.Data), `Subject: New "update" from "admin"`)
+		assert.Contains(t, email.Data, `Subject: New "update" from "admin"`)
 	}
 	}
 	// now delete the script to generate an error
 	// now delete the script to generate an error
 	lastReceivedEmail.reset()
 	lastReceivedEmail.reset()
@@ -3458,8 +3488,8 @@ func TestEventRuleProviderEvents(t *testing.T) {
 	email := lastReceivedEmail.get()
 	email := lastReceivedEmail.get()
 	assert.Len(t, email.To, 1)
 	assert.Len(t, email.To, 1)
 	assert.True(t, util.Contains(email.To, "failure@example.com"))
 	assert.True(t, util.Contains(email.To, "failure@example.com"))
-	assert.Contains(t, string(email.Data), `Subject: Failed "update" from "admin"`)
-	assert.Contains(t, string(email.Data), fmt.Sprintf("Object name: %s object type: folder", folder.Name))
+	assert.Contains(t, email.Data, `Subject: Failed "update" from "admin"`)
+	assert.Contains(t, email.Data, fmt.Sprintf("Object name: %s object type: folder", folder.Name))
 	lastReceivedEmail.reset()
 	lastReceivedEmail.reset()
 	// generate an error for the failure action
 	// generate an error for the failure action
 	smtpCfg = smtp.Config{}
 	smtpCfg = smtp.Config{}
@@ -3830,8 +3860,8 @@ func TestEventActionEmailAttachments(t *testing.T) {
 			email := lastReceivedEmail.get()
 			email := lastReceivedEmail.get()
 			assert.Len(t, email.To, 1)
 			assert.Len(t, email.To, 1)
 			assert.True(t, util.Contains(email.To, "test@example.com"))
 			assert.True(t, util.Contains(email.To, "test@example.com"))
-			assert.Contains(t, string(email.Data), fmt.Sprintf(`Subject: "upload" from "%s"`, user.Username))
-			assert.Contains(t, string(email.Data), "Content-Disposition: attachment")
+			assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "upload" from "%s"`, user.Username))
+			assert.Contains(t, email.Data, "Content-Disposition: attachment")
 		}
 		}
 	}
 	}
 
 
@@ -3929,7 +3959,7 @@ func TestEventRuleFirstUploadDownloadActions(t *testing.T) {
 		email := lastReceivedEmail.get()
 		email := lastReceivedEmail.get()
 		assert.Len(t, email.To, 1)
 		assert.Len(t, email.To, 1)
 		assert.True(t, util.Contains(email.To, "test@example.com"))
 		assert.True(t, util.Contains(email.To, "test@example.com"))
-		assert.Contains(t, string(email.Data), fmt.Sprintf(`Subject: "first-upload" from "%s"`, user.Username))
+		assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "first-upload" from "%s"`, user.Username))
 		lastReceivedEmail.reset()
 		lastReceivedEmail.reset()
 		// a new upload will not produce a new notification
 		// a new upload will not produce a new notification
 		err = writeSFTPFileNoCheck(testFileName+"_1", 32768, client)
 		err = writeSFTPFileNoCheck(testFileName+"_1", 32768, client)
@@ -3952,7 +3982,7 @@ func TestEventRuleFirstUploadDownloadActions(t *testing.T) {
 		email = lastReceivedEmail.get()
 		email = lastReceivedEmail.get()
 		assert.Len(t, email.To, 1)
 		assert.Len(t, email.To, 1)
 		assert.True(t, util.Contains(email.To, "test@example.com"))
 		assert.True(t, util.Contains(email.To, "test@example.com"))
-		assert.Contains(t, string(email.Data), fmt.Sprintf(`Subject: "first-download" from "%s"`, user.Username))
+		assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "first-download" from "%s"`, user.Username))
 		// download again
 		// download again
 		lastReceivedEmail.reset()
 		lastReceivedEmail.reset()
 		f, err = client.Open(testFileName)
 		f, err = client.Open(testFileName)
@@ -4001,8 +4031,8 @@ func TestEventRuleCertificate(t *testing.T) {
 		Options: dataprovider.BaseEventActionOptions{
 		Options: dataprovider.BaseEventActionOptions{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 			EmailConfig: dataprovider.EventActionEmailConfig{
 				Recipients: []string{"test@example.com"},
 				Recipients: []string{"test@example.com"},
-				Subject:    `"{{Event}}"`,
-				Body:       "Domain: {{Name}} Timestamp: {{Timestamp}}",
+				Subject:    `"{{Event}} {{StatusString}}"`,
+				Body:       "Domain: {{Name}} Timestamp: {{Timestamp}} {{ErrorString}}",
 			},
 			},
 		},
 		},
 	}
 	}
@@ -4051,11 +4081,13 @@ func TestEventRuleCertificate(t *testing.T) {
 	rule2, _, err := httpdtest.AddEventRule(r2, http.StatusCreated)
 	rule2, _, err := httpdtest.AddEventRule(r2, http.StatusCreated)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 
 
+	renewalEvent := "Certificate renewal"
+
 	common.HandleCertificateEvent(common.EventParams{
 	common.HandleCertificateEvent(common.EventParams{
 		Name:      "example.com",
 		Name:      "example.com",
 		Timestamp: time.Now().UnixNano(),
 		Timestamp: time.Now().UnixNano(),
 		Status:    1,
 		Status:    1,
-		Event:     "Successful certificate renewal",
+		Event:     renewalEvent,
 	})
 	})
 	assert.Eventually(t, func() bool {
 	assert.Eventually(t, func() bool {
 		return lastReceivedEmail.get().From != ""
 		return lastReceivedEmail.get().From != ""
@@ -4063,24 +4095,28 @@ func TestEventRuleCertificate(t *testing.T) {
 	email := lastReceivedEmail.get()
 	email := lastReceivedEmail.get()
 	assert.Len(t, email.To, 1)
 	assert.Len(t, email.To, 1)
 	assert.True(t, util.Contains(email.To, "test@example.com"))
 	assert.True(t, util.Contains(email.To, "test@example.com"))
-	assert.Contains(t, string(email.Data), `Subject: "Successful certificate renewal"`)
-	assert.Contains(t, string(email.Data), `Domain: example.com Timestamp`)
+	assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "%s OK"`, renewalEvent))
+	assert.Contains(t, email.Data, `Domain: example.com Timestamp`)
 
 
 	lastReceivedEmail.reset()
 	lastReceivedEmail.reset()
-	common.HandleCertificateEvent(common.EventParams{
+	params := common.EventParams{
 		Name:      "example.com",
 		Name:      "example.com",
 		Timestamp: time.Now().UnixNano(),
 		Timestamp: time.Now().UnixNano(),
 		Status:    2,
 		Status:    2,
-		Event:     "Certificate renewal failed",
-	})
+		Event:     renewalEvent,
+	}
+	errRenew := errors.New("generic renew error")
+	params.AddError(errRenew)
+	common.HandleCertificateEvent(params)
 	assert.Eventually(t, func() bool {
 	assert.Eventually(t, func() bool {
 		return lastReceivedEmail.get().From != ""
 		return lastReceivedEmail.get().From != ""
 	}, 3000*time.Millisecond, 100*time.Millisecond)
 	}, 3000*time.Millisecond, 100*time.Millisecond)
 	email = lastReceivedEmail.get()
 	email = lastReceivedEmail.get()
 	assert.Len(t, email.To, 1)
 	assert.Len(t, email.To, 1)
 	assert.True(t, util.Contains(email.To, "test@example.com"))
 	assert.True(t, util.Contains(email.To, "test@example.com"))
-	assert.Contains(t, string(email.Data), `Subject: "Certificate renewal failed"`)
-	assert.Contains(t, string(email.Data), `Domain: example.com Timestamp`)
+	assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "%s KO"`, renewalEvent))
+	assert.Contains(t, email.Data, `Domain: example.com Timestamp`)
+	assert.Contains(t, email.Data, errRenew.Error())
 
 
 	_, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
 	_, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -4095,7 +4131,7 @@ func TestEventRuleCertificate(t *testing.T) {
 		Name:      "example.com",
 		Name:      "example.com",
 		Timestamp: time.Now().UnixNano(),
 		Timestamp: time.Now().UnixNano(),
 		Status:    1,
 		Status:    1,
-		Event:     "Successful certificate renewal",
+		Event:     renewalEvent,
 	})
 	})
 
 
 	smtpCfg = smtp.Config{}
 	smtpCfg = smtp.Config{}
@@ -4184,7 +4220,7 @@ func TestEventRuleIPBlocked(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	lastReceivedEmail.reset()
 	lastReceivedEmail.reset()
 	time.Sleep(300 * time.Millisecond)
 	time.Sleep(300 * time.Millisecond)
-	assert.Empty(t, lastReceivedEmail.get().From, string(lastReceivedEmail.get().Data))
+	assert.Empty(t, lastReceivedEmail.get().From, lastReceivedEmail.get().Data)
 
 
 	for i := 0; i < 3; i++ {
 	for i := 0; i < 3; i++ {
 		user.Password = "wrong_pwd"
 		user.Password = "wrong_pwd"
@@ -4203,7 +4239,7 @@ func TestEventRuleIPBlocked(t *testing.T) {
 	assert.Len(t, email.To, 2)
 	assert.Len(t, email.To, 2)
 	assert.True(t, util.Contains(email.To, "test3@example.com"))
 	assert.True(t, util.Contains(email.To, "test3@example.com"))
 	assert.True(t, util.Contains(email.To, "test4@example.com"))
 	assert.True(t, util.Contains(email.To, "test4@example.com"))
-	assert.Contains(t, string(email.Data), `Subject: New "IP Blocked"`)
+	assert.Contains(t, email.Data, `Subject: New "IP Blocked"`)
 
 
 	err = dataprovider.DeleteEventRule(rule1.Name, "", "")
 	err = dataprovider.DeleteEventRule(rule1.Name, "", "")
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -5305,7 +5341,7 @@ type receivedEmail struct {
 	sync.RWMutex
 	sync.RWMutex
 	From string
 	From string
 	To   []string
 	To   []string
-	Data []byte
+	Data string
 }
 }
 
 
 func (e *receivedEmail) set(from string, to []string, data []byte) {
 func (e *receivedEmail) set(from string, to []string, data []byte) {
@@ -5314,7 +5350,7 @@ func (e *receivedEmail) set(from string, to []string, data []byte) {
 
 
 	e.From = from
 	e.From = from
 	e.To = to
 	e.To = to
-	e.Data = data
+	e.Data = strings.ReplaceAll(string(data), "=\r\n", "")
 }
 }
 
 
 func (e *receivedEmail) reset() {
 func (e *receivedEmail) reset() {
@@ -5323,7 +5359,7 @@ func (e *receivedEmail) reset() {
 
 
 	e.From = ""
 	e.From = ""
 	e.To = nil
 	e.To = nil
-	e.Data = nil
+	e.Data = ""
 }
 }
 
 
 func (e *receivedEmail) get() receivedEmail {
 func (e *receivedEmail) get() receivedEmail {

+ 6 - 0
templates/webadmin/eventaction.html

@@ -541,6 +541,12 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                 <p>
                 <p>
                     <span class="shortcut"><b>{{`{{Status}}`}}</b></span> => Status for "upload", "download" and "ssh_cmd" events. 1 means no error, 2 means a generic error occurred, 3 means quota exceeded error.
                     <span class="shortcut"><b>{{`{{Status}}`}}</b></span> => Status for "upload", "download" and "ssh_cmd" events. 1 means no error, 2 means a generic error occurred, 3 means quota exceeded error.
                 </p>
                 </p>
+                <p>
+                    <span class="shortcut"><b>{{`{{StatusString}}`}}</b></span> => Status as string. Possible values "OK", "KO".
+                </p>
+                <p>
+                    <span class="shortcut"><b>{{`{{ErrorString}}`}}</b></span> => Error details. Replaced with an empty string if no errors occur.
+                </p>
                 <p>
                 <p>
                     <span class="shortcut"><b>{{`{{VirtualPath}}`}}</b></span> => Path seen by SFTPGo users, for example "/adir/afile.txt".
                     <span class="shortcut"><b>{{`{{VirtualPath}}`}}</b></span> => Path seen by SFTPGo users, for example "/adir/afile.txt".
                 </p>
                 </p>