Skip to content

Commit

Permalink
Merge pull request #376 from wneessen/feature/embed-fs-to-io-fs
Browse files Browse the repository at this point in the history
Support attachments/embeds via io/fs.FS
  • Loading branch information
wneessen authored Nov 19, 2024
2 parents bcc3252 + 101a35f commit f37ab24
Show file tree
Hide file tree
Showing 3 changed files with 190 additions and 17 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 59 additions & 16 deletions msg.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"fmt"
ht "html/template"
"io"
"io/fs"
"mime"
"net/mail"
"os"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -2666,39 +2705,43 @@ 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.
// - An error if the file cannot be opened or read from the embedded filesystem.
//
// 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()
},
Expand Down
130 changes: 130 additions & 0 deletions msg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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{}

Expand Down

0 comments on commit f37ab24

Please sign in to comment.