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