Merge pull request #376 from wneessen/feature/embed-fs-to-io-fs

Support attachments/embeds via io/fs.FS
This commit is contained in:
Winni Neessen 2024-11-19 11:00:14 +01:00 committed by GitHub
commit f37ab2457b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 190 additions and 17 deletions

View file

@ -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

75
msg.go
View file

@ -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()
},

View file

@ -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{}