diff --git a/docs/eventmanager.md b/docs/eventmanager.md index ef1b90da..2b854b31 100644 --- a/docs/eventmanager.md +++ b/docs/eventmanager.md @@ -59,6 +59,7 @@ If you are running multiple SFTPGo instances connected to the same data provider Some actions are not supported for some triggers, rules containing incompatible actions are skipped at runtime: - `Filesystem events`, folder quota reset cannot be executed, we don't have a direct way to get the affected folder. -- `Provider events`, user quota reset, transfer quota reset, data retention check and filesystem actions can be executed only if we modify a user. They will be executed for the affected user. Folder quota reset can be executed only for folders. Filesystem actions are not executed for `delete` user events because the actions is executed after the user deletion. +- `Provider events`, user quota reset, transfer quota reset, data retention check and filesystem actions can be executed only if a user is updated. They will be executed for the affected user. Folder quota reset can be executed only for folders. Filesystem actions are not executed for `delete` user events because the actions is executed after the user deletion. - `IP Blocked`, user quota reset, folder quota reset, transfer quota reset, data retention check and filesystem actions cannot be executed, we only have an IP. - `Certificate`, user quota reset, folder quota reset, transfer quota reset, data retention check and filesystem actions cannot be executed. +- `Email with attachments` are supported for filesystem events and provider events if a user is updated. We need a user to get the files to attach. diff --git a/go.mod b/go.mod index 2178dc1a..6f71ed6b 100644 --- a/go.mod +++ b/go.mod @@ -155,7 +155,7 @@ require ( golang.org/x/tools v0.1.12 // indirect golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20220819174105-e9f053255caa // indirect + google.golang.org/genproto v0.0.0-20220822141531-cb6d359b7ced // indirect google.golang.org/grpc v1.48.0 // indirect google.golang.org/protobuf v1.28.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index a1e6fb4b..05d7b262 100644 --- a/go.sum +++ b/go.sum @@ -1229,8 +1229,8 @@ google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljW google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/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-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220819174105-e9f053255caa h1:Ux9yJCyf598uEniFPSyp8g1jtGTt77m+lzYyVgrWQaQ= -google.golang.org/genproto v0.0.0-20220819174105-e9f053255caa/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220822141531-cb6d359b7ced h1:ZjPHtZXcQ2EaCGgKb4iX6m/4q2HpogJuLR31in3Zp50= +google.golang.org/genproto v0.0.0-20220822141531-cb6d359b7ced/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= 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.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= diff --git a/internal/common/eventmanager.go b/internal/common/eventmanager.go index 90350c6f..ab736c40 100644 --- a/internal/common/eventmanager.go +++ b/internal/common/eventmanager.go @@ -32,6 +32,7 @@ import ( "github.com/robfig/cron/v3" "github.com/rs/xid" + mail "github.com/xhit/go-simple-mail/v2" "github.com/drakkan/sftpgo/v2/internal/dataprovider" "github.com/drakkan/sftpgo/v2/internal/logger" @@ -42,7 +43,8 @@ import ( ) const ( - ipBlockedEventName = "IP Blocked" + ipBlockedEventName = "IP Blocked" + emailAttachmentsMaxSize = int64(10 * 1024 * 1024) ) var ( @@ -426,13 +428,21 @@ func (p *EventParams) getUsers() ([]dataprovider.User, error) { if p.sender == "" { return dataprovider.DumpUsers() } - user, err := dataprovider.UserExists(p.sender) + user, err := p.getUserFromSender() if err != nil { - return nil, fmt.Errorf("error getting user %q: %w", p.sender, err) + return nil, err } return []dataprovider.User{user}, nil } +func (p *EventParams) getUserFromSender() (dataprovider.User, error) { + user, err := dataprovider.UserExists(p.sender) + if err != nil { + return user, fmt.Errorf("error getting user %q: %w", p.sender, err) + } + return user, nil +} + func (p *EventParams) getFolders() ([]vfs.BaseVirtualFolder, error) { if p.sender == "" { return dataprovider.DumpFolders() @@ -469,6 +479,72 @@ func (p *EventParams) getStringReplacements(addObjectData bool) []string { return replacements } +func getFileContent(conn *BaseConnection, virtualPath string, expectedSize int) ([]byte, error) { + fs, fsPath, err := conn.GetFsAndResolvedPath(virtualPath) + if err != nil { + return nil, err + } + f, r, cancelFn, err := fs.Open(fsPath, 0) + if err != nil { + return nil, err + } + if cancelFn == nil { + cancelFn = func() {} + } + defer cancelFn() + + var reader io.ReadCloser + if f != nil { + reader = f + } else { + reader = r + } + defer reader.Close() + + data := make([]byte, expectedSize) + _, err = io.ReadFull(reader, data) + return data, err +} + +func getMailAttachments(user dataprovider.User, attachments []string, replacer *strings.Replacer) ([]mail.File, error) { + var files []mail.File + user, err := getUserForEventAction(user) + if err != nil { + return nil, err + } + connectionID := fmt.Sprintf("%s_%s", protocolEventAction, xid.New().String()) + err = user.CheckFsRoot(connectionID) + defer user.CloseFs() //nolint:errcheck + if err != nil { + return nil, err + } + conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user) + totalSize := int64(0) + for _, virtualPath := range attachments { + virtualPath = util.CleanPath(replaceWithReplacer(virtualPath, replacer)) + info, err := conn.DoStat(virtualPath, 0, false) + if err != nil { + return nil, fmt.Errorf("unable to get info for file %q, user %q: %w", virtualPath, conn.User.Username, err) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("cannot attach non regular file %q", virtualPath) + } + totalSize += info.Size() + if totalSize > emailAttachmentsMaxSize { + return nil, fmt.Errorf("unable to send files as attachment, size too large: %s", util.ByteCountIEC(totalSize)) + } + data, err := getFileContent(conn, virtualPath, int(info.Size())) + if err != nil { + return nil, fmt.Errorf("unable to get content for file %q, user %q: %w", virtualPath, conn.User.Username, err) + } + files = append(files, mail.File{ + Name: path.Base(virtualPath), + Data: data, + }) + } + return files, nil +} + func replaceWithReplacer(input string, replacer *strings.Replacer) string { if !strings.Contains(input, "{{") { return input @@ -622,10 +698,24 @@ func executeEmailRuleAction(c dataprovider.EventActionEmailConfig, params EventP body := replaceWithReplacer(c.Body, replacer) subject := replaceWithReplacer(c.Subject, replacer) startTime := time.Now() - err := smtp.SendEmail(c.Recipients, subject, body, smtp.EmailContentTypeTextPlain) + var files []mail.File + if len(c.Attachments) > 0 { + user, err := params.getUserFromSender() + if err != nil { + return err + } + files, err = getMailAttachments(user, c.Attachments, replacer) + if err != nil { + return err + } + } + err := smtp.SendEmail(c.Recipients, subject, body, smtp.EmailContentTypeTextPlain, files...) eventManagerLog(logger.LevelDebug, "executed email notification action, elapsed: %s, error: %v", time.Since(startTime), err) - return err + if err != nil { + return fmt.Errorf("unable to send email: %w", err) + } + return nil } func getUserForEventAction(user dataprovider.User) (dataprovider.User, error) { @@ -1228,6 +1318,10 @@ func (j *eventCronJob) Run() { eventManagerLog(logger.LevelError, "unable to load rule with name %q", j.ruleName) return } + if err = rule.CheckActionsConsistency(""); err != nil { + eventManagerLog(logger.LevelWarn, "scheduled rule %q skipped: %v", rule.Name, err) + return + } task, err := j.getTask(rule) if err != nil { return diff --git a/internal/common/eventmanager_test.go b/internal/common/eventmanager_test.go index 89f1dc10..4e7c019f 100644 --- a/internal/common/eventmanager_test.go +++ b/internal/common/eventmanager_test.go @@ -15,6 +15,7 @@ package common import ( + "crypto/rand" "fmt" "net/http" "os" @@ -339,6 +340,14 @@ func TestEventManagerErrors(t *testing.T) { }, }) assert.Error(t, err) + _, err = getMailAttachments(dataprovider.User{ + Groups: []sdk.GroupMapping{ + { + Name: groupName, + Type: sdk.GroupTypePrimary, + }, + }}, []string{"/a", "/b"}, nil) + assert.Error(t, err) dataRetentionAction := dataprovider.BaseEventAction{ Type: dataprovider.ActionTypeDataRetentionCheck, @@ -848,6 +857,68 @@ func TestEventRuleActions(t *testing.T) { assert.NoError(t, err) } +func TestGetFileContent(t *testing.T) { + username := "test_user_get_file_content" + user := dataprovider.User{ + BaseUser: sdk.BaseUser{ + Username: username, + Permissions: map[string][]string{ + "/": {dataprovider.PermAny}, + }, + HomeDir: filepath.Join(os.TempDir(), username), + }, + } + err := dataprovider.AddUser(&user, "", "") + assert.NoError(t, err) + err = os.MkdirAll(user.GetHomeDir(), os.ModePerm) + assert.NoError(t, err) + fileContent := []byte("test file content") + err = os.WriteFile(filepath.Join(user.GetHomeDir(), "file.txt"), fileContent, 0666) + assert.NoError(t, err) + replacer := strings.NewReplacer("old", "new") + files, err := getMailAttachments(user, []string{"/file.txt"}, replacer) + assert.NoError(t, err) + if assert.Len(t, files, 1) { + assert.Equal(t, fileContent, files[0].Data) + } + // missing file + _, err = getMailAttachments(user, []string{"/file1.txt"}, replacer) + assert.Error(t, err) + // directory + _, err = getMailAttachments(user, []string{"/"}, replacer) + assert.Error(t, err) + // files too large + content := make([]byte, emailAttachmentsMaxSize/2+1) + _, err = rand.Read(content) + assert.NoError(t, err) + err = os.WriteFile(filepath.Join(user.GetHomeDir(), "file1.txt"), content, 0666) + assert.NoError(t, err) + err = os.WriteFile(filepath.Join(user.GetHomeDir(), "file2.txt"), content, 0666) + assert.NoError(t, err) + files, err = getMailAttachments(user, []string{"/file1.txt"}, replacer) + assert.NoError(t, err) + if assert.Len(t, files, 1) { + assert.Equal(t, content, files[0].Data) + } + _, err = getMailAttachments(user, []string{"/file1.txt", "/file2.txt"}, replacer) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "size too large") + } + // change the filesystem provider + user.FsConfig.Provider = sdk.CryptedFilesystemProvider + user.FsConfig.CryptConfig.Passphrase = kms.NewPlainSecret("pwd") + err = dataprovider.UpdateUser(&user, "", "") + assert.NoError(t, err) + // the file is not encrypted so reading the encryption header will fail + _, err = getMailAttachments(user, []string{"/file.txt"}, replacer) + assert.Error(t, err) + + err = dataprovider.DeleteUser(username, "", "") + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestFilesystemActionErrors(t *testing.T) { err := executeFsRuleAction(dataprovider.EventActionFilesystemConfig{}, dataprovider.ConditionOptions{}, EventParams{}) if assert.Error(t, err) { @@ -874,6 +945,15 @@ func TestFilesystemActionErrors(t *testing.T) { }, }, } + err = executeEmailRuleAction(dataprovider.EventActionEmailConfig{ + Recipients: []string{"test@example.net"}, + Subject: "subject", + Body: "body", + Attachments: []string{"/file.txt"}, + }, EventParams{ + sender: username, + }) + assert.Error(t, err) conn := NewBaseConnection("", protocolEventAction, "", "", user) err = executeDeleteFileFsAction(conn, "", nil) assert.Error(t, err) @@ -888,6 +968,17 @@ func TestFilesystemActionErrors(t *testing.T) { assert.Error(t, err) err = executeExistFsActionForUser(nil, testReplacer, user) assert.Error(t, err) + err = executeEmailRuleAction(dataprovider.EventActionEmailConfig{ + Recipients: []string{"test@example.net"}, + Subject: "subject", + Body: "body", + Attachments: []string{"/file1.txt"}, + }, EventParams{ + sender: username, + }) + assert.Error(t, err) + _, err = getFileContent(NewBaseConnection("", protocolEventAction, "", "", user), "/f.txt", 1234) + assert.Error(t, err) user.FsConfig.Provider = sdk.LocalFilesystemProvider user.Permissions["/"] = []string{dataprovider.PermUpload} @@ -1130,6 +1221,19 @@ func TestScheduledActions(t *testing.T) { job.Run() assert.DirExists(t, backupsPath) + action.Type = dataprovider.ActionTypeEmail + action.Options = dataprovider.BaseEventActionOptions{ + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: []string{"example@example.com"}, + Subject: "test with attachments", + Body: "body", + Attachments: []string{"/file1.txt"}, + }, + } + err = dataprovider.UpdateEventAction(action, "", "") + assert.NoError(t, err) + job.Run() // action is not compatible with a scheduled rule + err = dataprovider.DeleteEventRule(rule.Name, "", "") assert.NoError(t, err) err = dataprovider.DeleteEventAction(action.Name, "", "") diff --git a/internal/common/protocol_test.go b/internal/common/protocol_test.go index 72f49987..268dbdc8 100644 --- a/internal/common/protocol_test.go +++ b/internal/common/protocol_test.go @@ -408,7 +408,6 @@ func TestChtimesOpenHandle(t *testing.T) { sftpUser, _, err := httpdtest.AddUser(getTestSFTPUser(), http.StatusCreated) assert.NoError(t, err) u := getCryptFsUser() - u.Username += "_crypt" cryptFsUser, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) @@ -2326,7 +2325,6 @@ func TestCrossFolderRename(t *testing.T) { assert.NoError(t, err, string(resp)) u := getCryptFsUser() - u.Username += "_crypt" u.VirtualFolders = []vfs.VirtualFolder{ { BaseVirtualFolder: vfs.BaseVirtualFolder{ @@ -3764,6 +3762,99 @@ func TestEventRuleFsActions(t *testing.T) { assert.NoError(t, err) } +func TestEventActionEmailAttachments(t *testing.T) { + smtpCfg := smtp.Config{ + Host: "127.0.0.1", + Port: 2525, + From: "notify@example.com", + TemplatesPath: "templates", + } + err := smtpCfg.Initialize(configDir) + require.NoError(t, err) + + a1 := dataprovider.BaseEventAction{ + Name: "action1", + Type: dataprovider.ActionTypeEmail, + Options: dataprovider.BaseEventActionOptions{ + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: []string{"test@example.com"}, + Subject: `"{{Event}}" from "{{Name}}"`, + Body: "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}}", + Attachments: []string{"/{{VirtualPath}}"}, + }, + }, + } + action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + assert.NoError(t, err) + r1 := dataprovider.EventRule{ + Name: "test email with attachment", + Trigger: dataprovider.EventTriggerFsEvent, + Conditions: dataprovider.EventConditions{ + FsEvents: []string{"upload"}, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Order: 1, + }, + }, + } + rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated) + assert.NoError(t, err) + localUser, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + u := getTestSFTPUser() + u.FsConfig.SFTPConfig.BufferSize = 1 + sftpUser, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + cryptFsUser, _, err := httpdtest.AddUser(getCryptFsUser(), http.StatusCreated) + assert.NoError(t, err) + for _, user := range []dataprovider.User{localUser, sftpUser, cryptFsUser} { + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + lastReceivedEmail.reset() + f, err := client.Create(testFileName) + assert.NoError(t, err) + _, err = f.Write(testFileContent) + assert.NoError(t, err) + err = f.Close() + assert.NoError(t, err) + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 1500*time.Millisecond, 100*time.Millisecond) + email := lastReceivedEmail.get() + assert.Len(t, email.To, 1) + 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") + } + } + + _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(sftpUser, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(localUser, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(localUser.GetHomeDir()) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(cryptFsUser, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(cryptFsUser.GetHomeDir()) + assert.NoError(t, err) + + smtpCfg = smtp.Config{} + err = smtpCfg.Initialize(configDir) + require.NoError(t, err) +} + func TestEventRuleFirstUploadDownloadActions(t *testing.T) { smtpCfg := smtp.Config{ Host: "127.0.0.1", @@ -5112,6 +5203,7 @@ func getTestSFTPUser() dataprovider.User { func getCryptFsUser() dataprovider.User { u := getTestUser() + u.Username += "_crypt" u.FsConfig.Provider = sdk.CryptedFilesystemProvider u.FsConfig.CryptConfig.Passphrase = kms.NewPlainSecret(defaultPassword) return u diff --git a/internal/dataprovider/eventrule.go b/internal/dataprovider/eventrule.go index 345b015d..17fe6e5f 100644 --- a/internal/dataprovider/eventrule.go +++ b/internal/dataprovider/eventrule.go @@ -17,6 +17,7 @@ package dataprovider import ( "crypto/tls" "encoding/json" + "errors" "fmt" "net/http" "path" @@ -295,9 +296,10 @@ func (c *EventActionCommandConfig) validate() error { // EventActionEmailConfig defines the configuration options for SMTP event actions type EventActionEmailConfig struct { - Recipients []string `json:"recipients,omitempty"` - Subject string `json:"subject,omitempty"` - Body string `json:"body,omitempty"` + Recipients []string `json:"recipients,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body,omitempty"` + Attachments []string `json:"attachments,omitempty"` } // GetRecipientsAsString returns the list of recipients as comma separated string @@ -305,6 +307,11 @@ func (c EventActionEmailConfig) GetRecipientsAsString() string { return strings.Join(c.Recipients, ",") } +// GetAttachmentsAsString returns the list of attachments as comma separated string +func (c EventActionEmailConfig) GetAttachmentsAsString() string { + return strings.Join(c.Attachments, ",") +} + func (c *EventActionEmailConfig) validate() error { if len(c.Recipients) == 0 { return util.NewValidationError("at least one email recipient is required") @@ -321,6 +328,14 @@ func (c *EventActionEmailConfig) validate() error { if c.Body == "" { return util.NewValidationError("email body is required") } + for idx, val := range c.Attachments { + val = strings.TrimSpace(val) + if val == "" { + return util.NewValidationError("invalid path to attach") + } + c.Attachments[idx] = util.CleanPath(val) + } + c.Attachments = util.RemoveDuplicates(c.Attachments, false) return nil } @@ -549,6 +564,8 @@ func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions { o.SetEmptySecretsIfNil() emailRecipients := make([]string, len(o.EmailConfig.Recipients)) copy(emailRecipients, o.EmailConfig.Recipients) + emailAttachments := make([]string, len(o.EmailConfig.Attachments)) + copy(emailAttachments, o.EmailConfig.Attachments) folders := make([]FolderRetention, 0, len(o.RetentionConfig.Folders)) for _, folder := range o.RetentionConfig.Folders { folders = append(folders, FolderRetention{ @@ -577,9 +594,10 @@ func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions { EnvVars: cloneKeyValues(o.CmdConfig.EnvVars), }, EmailConfig: EventActionEmailConfig{ - Recipients: emailRecipients, - Subject: o.EmailConfig.Subject, - Body: o.EmailConfig.Body, + Recipients: emailRecipients, + Subject: o.EmailConfig.Subject, + Body: o.EmailConfig.Body, + Attachments: emailAttachments, }, RetentionConfig: EventActionDataRetentionConfig{ Folders: folders, @@ -1102,6 +1120,16 @@ func (r *EventRule) checkProviderEventActions(providerObjectType string) error { return nil } +func (r *EventRule) hasUserAssociated(providerObjectType string) bool { + switch r.Trigger { + case EventTriggerProviderEvent: + return providerObjectType == actionObjectUser + case EventTriggerFsEvent: + return true + } + return false +} + // CheckActionsConsistency returns an error if the actions cannot be executed func (r *EventRule) CheckActionsConsistency(providerObjectType string) error { switch r.Trigger { @@ -1122,6 +1150,13 @@ func (r *EventRule) CheckActionsConsistency(providerObjectType string) error { return err } } + for _, action := range r.Actions { + if action.Type == ActionTypeEmail && len(action.BaseEventAction.Options.EmailConfig.Attachments) > 0 { + if !r.hasUserAssociated(providerObjectType) { + return errors.New("cannot send an email with attachments for a rule with no user associated") + } + } + } return nil } diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index 08ac1aa2..e1cf284b 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -1144,9 +1144,10 @@ func TestBasicActionRulesHandling(t *testing.T) { a.Type = dataprovider.ActionTypeEmail a.Options = dataprovider.BaseEventActionOptions{ EmailConfig: dataprovider.EventActionEmailConfig{ - Recipients: []string{"email@example.com"}, - Subject: "Event: {{Event}}", - Body: "test mail body", + Recipients: []string{"email@example.com"}, + Subject: "Event: {{Event}}", + Body: "test mail body", + Attachments: []string{"/{{VirtualPath}}"}, }, } @@ -18828,14 +18829,16 @@ func TestWebEventAction(t *testing.T) { // change action type again action.Type = dataprovider.ActionTypeEmail action.Options.EmailConfig = dataprovider.EventActionEmailConfig{ - Recipients: []string{"address1@example.com", "address2@example.com"}, - Subject: "subject", - Body: "body", + Recipients: []string{"address1@example.com", "address2@example.com"}, + Subject: "subject", + Body: "body", + Attachments: []string{"/file1.txt", "/file2.txt"}, } form.Set("type", fmt.Sprintf("%d", action.Type)) form.Set("email_recipients", "address1@example.com, address2@example.com") form.Set("email_subject", action.Options.EmailConfig.Subject) form.Set("email_body", action.Options.EmailConfig.Body) + form.Set("email_attachments", "file1.txt, file2.txt") req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name), bytes.NewBuffer([]byte(form.Encode()))) assert.NoError(t, err) @@ -18850,6 +18853,7 @@ func TestWebEventAction(t *testing.T) { assert.Equal(t, action.Options.EmailConfig.Recipients, actionGet.Options.EmailConfig.Recipients) assert.Equal(t, action.Options.EmailConfig.Subject, actionGet.Options.EmailConfig.Subject) assert.Equal(t, action.Options.EmailConfig.Body, actionGet.Options.EmailConfig.Body) + assert.Equal(t, action.Options.EmailConfig.Attachments, actionGet.Options.EmailConfig.Attachments) assert.Equal(t, dataprovider.EventActionHTTPConfig{}, actionGet.Options.HTTPConfig) assert.Empty(t, actionGet.Options.CmdConfig.Cmd) assert.Equal(t, 0, actionGet.Options.CmdConfig.Timeout) diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go index 207b2002..3a81d471 100644 --- a/internal/httpd/webadmin.go +++ b/internal/httpd/webadmin.go @@ -1907,9 +1907,10 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven EnvVars: getKeyValsFromPostFields(r, "cmd_env_key", "cmd_env_val"), }, EmailConfig: dataprovider.EventActionEmailConfig{ - Recipients: strings.Split(strings.ReplaceAll(r.Form.Get("email_recipients"), " ", ""), ","), - Subject: r.Form.Get("email_subject"), - Body: r.Form.Get("email_body"), + Recipients: strings.Split(strings.ReplaceAll(r.Form.Get("email_recipients"), " ", ""), ","), + Subject: r.Form.Get("email_subject"), + Body: r.Form.Get("email_body"), + Attachments: strings.Split(strings.ReplaceAll(r.Form.Get("email_attachments"), " ", ""), ","), }, RetentionConfig: dataprovider.EventActionDataRetentionConfig{ Folders: foldersRetention, diff --git a/internal/httpdtest/httpdtest.go b/internal/httpdtest/httpdtest.go index 7431fbfe..ea268f4e 100644 --- a/internal/httpdtest/httpdtest.go +++ b/internal/httpdtest/httpdtest.go @@ -2244,6 +2244,14 @@ func compareEventActionEmailConfigFields(expected, actual dataprovider.EventActi if expected.Body != actual.Body { return errors.New("email body mismatch") } + if len(expected.Attachments) != len(actual.Attachments) { + return errors.New("email attachments mismatch") + } + for _, v := range expected.Attachments { + if !util.Contains(actual.Attachments, v) { + return errors.New("email attachments content mismatch") + } + } return nil } diff --git a/internal/smtp/smtp.go b/internal/smtp/smtp.go index c216f700..ff16746b 100644 --- a/internal/smtp/smtp.go +++ b/internal/smtp/smtp.go @@ -183,7 +183,7 @@ func RenderPasswordResetTemplate(buf *bytes.Buffer, data any) error { } // SendEmail tries to send an email using the specified parameters. -func SendEmail(to []string, subject, body string, contentType EmailContentType) error { +func SendEmail(to []string, subject, body string, contentType EmailContentType, attachments ...mail.File) error { if smtpServer == nil { return errors.New("smtp: not configured") } @@ -207,6 +207,9 @@ func SendEmail(to []string, subject, body string, contentType EmailContentType) default: return fmt.Errorf("smtp: unsupported body content type %v", contentType) } + for _, attachment := range attachments { + email.Attach(&attachment) + } if email.Error != nil { return fmt.Errorf("smtp: email error: %w", email.Error) } diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index f37743b6..d7b71045 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -4978,7 +4978,7 @@ components: description: | Defines how to check if this config points to the same server as another config. If different configs point to the same server the renaming between the fs configs is allowed: * `0` username and endpoint must match. This is the default - * `1` only the endpoint must match + * `1` only the endpoint must match HTTPFsConfig: type: object properties: @@ -6071,6 +6071,11 @@ components: type: string body: type: string + attachments: + type: array + items: + type: string + description: 'list of file paths to attach. The total size is limited to 10 MB' EventActionDataRetentionConfig: type: object properties: diff --git a/templates/webadmin/eventaction.html b/templates/webadmin/eventaction.html index ab79ec4a..a92c796b 100644 --- a/templates/webadmin/eventaction.html +++ b/templates/webadmin/eventaction.html @@ -342,6 +342,17 @@ along with this program. If not, see . +
+ +
+ + + Comma separated paths to attach. Placeholders are supported. The total size is limited to 10 MB. + +
+
+
Data retention