فهرست منبع

eventmanager: allow to add attachments to email actions

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 2 سال پیش
والد
کامیت
6777008aec

+ 2 - 1
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.

+ 1 - 1
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

+ 2 - 2
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=

+ 99 - 5
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

+ 104 - 0
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, "", "")

+ 94 - 2
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

+ 41 - 6
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
 }
 

+ 10 - 6
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)

+ 4 - 3
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,

+ 8 - 0
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
 }
 

+ 4 - 1
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)
 	}

+ 6 - 1
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:

+ 11 - 0
templates/webadmin/eventaction.html

@@ -342,6 +342,17 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                 </div>
             </div>
 
+            <div class="form-group row action-type action-smtp">
+                <label for="idEmailAttachments" class="col-sm-2 col-form-label">Email attachments</label>
+                <div class="col-sm-10">
+                    <textarea class="form-control" id="idEmailAttachments" name="email_attachments" rows="2" placeholder=""
+                        aria-describedby="smtpAttachmentsHelpBlock">{{.Action.Options.EmailConfig.GetAttachmentsAsString}}</textarea>
+                    <small id="smtpAttachmentsHelpBlock" class="form-text text-muted">
+                        Comma separated paths to attach. Placeholders are supported. The total size is limited to 10 MB.
+                    </small>
+                </div>
+            </div>
+
             <div class="card bg-light mb-3 action-type action-dataretention">
                 <div class="card-header">
                     <b>Data retention</b>