diff --git a/README.md b/README.md index 947b6f2..9f17180 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Here are some highlights of go-mail's featureset: * [X] RFC5322 compliant mail address validation * [X] Support for common mail header field generation (Message-ID, Date, Bulk-Precedence, Priority, etc.) * [X] Concurrency-safe reusing the same SMTP connection to send multiple mails -* [X] Support for attachments and inline embeds (from file system, `io.Reader` or `embed.FS`) +* [X] Support for attachments and inline embeds (from file system, `io.Reader`, `embed.FS` or `fs.FS`) * [X] Support for different encodings * [X] Middleware support for 3rd-party libraries to alter mail messages * [X] Support sending mails via a local sendmail command diff --git a/msg.go b/msg.go index 0b06a22..762ec2c 100644 --- a/msg.go +++ b/msg.go @@ -12,6 +12,7 @@ import ( "fmt" ht "html/template" "io" + "io/fs" "mime" "net/mail" "os" @@ -1962,9 +1963,28 @@ func (m *Msg) AttachTextTemplate( // - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) AttachFromEmbedFS(name string, fs *embed.FS, opts ...FileOption) error { if fs == nil { - return fmt.Errorf("embed.FS must not be nil") + return errors.New("embed.FS must not be nil") } - file, err := fileFromEmbedFS(name, fs) + return m.AttachFromIOFS(name, *fs, opts...) +} + +// AttachFromIOFS attaches a file from a generic file system to the message. +// +// This function retrieves a file by name from an fs.FS instance, processes it, and appends it to the +// message's attachment collection. Additional file options can be provided for further customization. +// +// Parameters: +// - name: The name of the file to retrieve from the file system. +// - iofs: The file system (must not be nil). +// - opts: Optional file options to customize the attachment process. +// +// Returns: +// - An error if the file cannot be retrieved, the fs.FS is nil, or any other issue occurs. +func (m *Msg) AttachFromIOFS(name string, iofs fs.FS, opts ...FileOption) error { + if iofs == nil { + return errors.New("fs.FS must not be nil") + } + file, err := fileFromIOFS(name, iofs) if err != nil { return err } @@ -2108,9 +2128,28 @@ func (m *Msg) EmbedTextTemplate( // - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) EmbedFromEmbedFS(name string, fs *embed.FS, opts ...FileOption) error { if fs == nil { - return fmt.Errorf("embed.FS must not be nil") + return errors.New("embed.FS must not be nil") } - file, err := fileFromEmbedFS(name, fs) + return m.EmbedFromIOFS(name, *fs, opts...) +} + +// EmbedFromIOFS embeds a file from a generic file system into the message. +// +// This function retrieves a file by name from an fs.FS instance, processes it, and appends it to the +// message's embed collection. Additional file options can be provided for further customization. +// +// Parameters: +// - name: The name of the file to retrieve from the file system. +// - iofs: The file system (must not be nil). +// - opts: Optional file options to customize the embedding process. +// +// Returns: +// - An error if the file cannot be retrieved, the fs.FS is nil, or any other issue occurs. +func (m *Msg) EmbedFromIOFS(name string, iofs fs.FS, opts ...FileOption) error { + if iofs == nil { + return errors.New("fs.FS must not be nil") + } + file, err := fileFromIOFS(name, iofs) if err != nil { return err } @@ -2666,15 +2705,15 @@ func (m *Msg) addDefaultHeader() { m.SetGenHeader(HeaderMIMEVersion, string(m.mimever)) } -// fileFromEmbedFS returns a File pointer from a given file in the provided embed.FS. +// fileFromIOFS returns a File pointer from a given file in the provided fs.FS. // -// This method retrieves a file from the embedded filesystem (embed.FS) and returns a File structure +// This method retrieves a file from the provided io/fs (fs.FS) and returns a File structure // that can be used as an attachment or embed in the email message. The file's content is read when // writing to an io.Writer, and the file is identified by its base name. // // Parameters: // - name: The name of the file to retrieve from the embedded filesystem. -// - fs: A pointer to the embed.FS from which the file will be opened. +// - fs: An instance that satisfies the fs.FS interface // // Returns: // - A pointer to the File structure representing the embedded file. @@ -2682,23 +2721,27 @@ func (m *Msg) addDefaultHeader() { // // References: // - https://datatracker.ietf.org/doc/html/rfc2183 -func fileFromEmbedFS(name string, fs *embed.FS) (*File, error) { - _, err := fs.Open(name) +func fileFromIOFS(name string, iofs fs.FS) (*File, error) { + if iofs == nil { + return nil, errors.New("fs.FS is nil") + } + + _, err := iofs.Open(name) if err != nil { - return nil, fmt.Errorf("failed to open file from embed.FS: %w", err) + return nil, fmt.Errorf("failed to open file from fs.FS: %w", err) } return &File{ Name: filepath.Base(name), Header: make(map[string][]string), Writer: func(writer io.Writer) (int64, error) { - file, err := fs.Open(name) - if err != nil { - return 0, err + file, ferr := iofs.Open(name) + if ferr != nil { + return 0, fmt.Errorf("failed to open file from fs.FS: %w", ferr) } - numBytes, err := io.Copy(writer, file) - if err != nil { + numBytes, ferr := io.Copy(writer, file) + if ferr != nil { _ = file.Close() - return numBytes, fmt.Errorf("failed to copy file to io.Writer: %w", err) + return numBytes, fmt.Errorf("failed to copy file from fs.FS to io.Writer: %w", ferr) } return numBytes, file.Close() }, diff --git a/msg_test.go b/msg_test.go index 3f2cf94..ac1a32a 100644 --- a/msg_test.go +++ b/msg_test.go @@ -4970,6 +4970,75 @@ func TestMsg_AttachFromEmbedFS(t *testing.T) { }) } +func TestMsg_AttachFromIOFS(t *testing.T) { + t.Run("AttachFromIOFS successful", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.AttachFromIOFS("testdata/attachment.txt", efs, + WithFileName("attachment.txt")); err != nil { + t.Fatalf("failed to attach from embed FS: %s", err) + } + attachments := message.GetAttachments() + if len(attachments) != 1 { + t.Fatalf("failed to retrieve attachments list") + } + if attachments[0] == nil { + t.Fatal("expected attachment to be not nil") + } + if attachments[0].Name != "attachment.txt" { + t.Errorf("expected attachment name to be %s, got: %s", "attachment.txt", attachments[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err := attachments[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test attachment") { + t.Errorf("expected message body to be %s, got: %s", "This is a test attachment", got) + } + }) + t.Run("AttachFromIOFS with invalid path", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err := message.AttachFromIOFS("testdata/invalid.txt", efs, WithFileName("attachment.txt")) + if err == nil { + t.Fatal("expected error, got nil") + } + }) + t.Run("AttachFromIOFS with nil embed FS", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err := message.AttachFromIOFS("testdata/invalid.txt", nil, WithFileName("attachment.txt")) + if err == nil { + t.Fatal("expected error, got nil") + } + }) + t.Run("AttachFromIOFS with fs.FS fails on copy", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.AttachFromIOFS("testdata/attachment.txt", efs); err != nil { + t.Fatalf("failed to attach file from fs.FS: %s", err) + } + attachments := message.GetAttachments() + if len(attachments) != 1 { + t.Fatalf("failed to get attachments, expected 1, got: %d", len(attachments)) + } + _, err := attachments[0].Writer(failReadWriteSeekCloser{}) + if err == nil { + t.Error("writer func expected to fail, but didn't") + } + }) +} + func TestMsg_EmbedFile(t *testing.T) { t.Run("EmbedFile with file", func(t *testing.T) { message := NewMsg() @@ -5435,6 +5504,58 @@ func TestMsg_EmbedFromEmbedFS(t *testing.T) { }) } +func TestMsg_EmbedFromIOFS(t *testing.T) { + t.Run("EmbedFromIOFS successful", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.EmbedFromIOFS("testdata/embed.txt", efs, + WithFileName("embed.txt")); err != nil { + t.Fatalf("failed to embed from embed FS: %s", err) + } + embeds := message.GetEmbeds() + if len(embeds) != 1 { + t.Fatalf("failed to retrieve embeds list") + } + if embeds[0] == nil { + t.Fatal("expected embed to be not nil") + } + if embeds[0].Name != "embed.txt" { + t.Errorf("expected embed name to be %s, got: %s", "embed.txt", embeds[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err := embeds[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test embed") { + t.Errorf("expected message body to be %s, got: %s", "This is a test embed", got) + } + }) + t.Run("EmbedFromIOFS with invalid path", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err := message.EmbedFromIOFS("testdata/invalid.txt", efs, WithFileName("embed.txt")) + if err == nil { + t.Fatal("expected error, got nil") + } + }) + t.Run("EmbedFromIOFS with nil embed FS", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err := message.EmbedFromIOFS("testdata/invalid.txt", nil, WithFileName("embed.txt")) + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + func TestMsg_Reset(t *testing.T) { message := NewMsg() if message == nil { @@ -6440,6 +6561,15 @@ func TestMsg_addDefaultHeader(t *testing.T) { }) } +func TestMsg_fileFromIOFS(t *testing.T) { + t.Run("file from fs.FS where fs is nil ", func(t *testing.T) { + _, err := fileFromIOFS("testfile.txt", nil) + if err == nil { + t.Fatal("expected error for fs.FS that is nil") + } + }) +} + // uppercaseMiddleware is a middleware type that transforms the subject to uppercase. type uppercaseMiddleware struct{}