From 14e409eb660617c4b98407caae6122885fe767ad Mon Sep 17 00:00:00 2001 From: Guilhem Bonnefille Date: Thu, 14 Nov 2024 17:12:58 +0100 Subject: [PATCH] Support CSV for recipients CSV is a classical input format. --- config/config.go | 8 +++++ config/fs.go | 11 +++++- config/fs_test.go | 26 ++++++++++++++ mail/campaign.go | 13 +++++-- mail/campaign_test.go | 69 +++++++++++++++++++++++++++++++++++++ mail/recipients_csv.go | 51 +++++++++++++++++++++++++++ mail/recipients_csv_test.go | 31 +++++++++++++++++ mail/recipients_yaml.go | 16 +++++++++ 8 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 config/fs_test.go create mode 100644 mail/campaign_test.go create mode 100644 mail/recipients_csv.go create mode 100644 mail/recipients_csv_test.go create mode 100644 mail/recipients_yaml.go diff --git a/config/config.go b/config/config.go index 2cd53fd..b43e574 100644 --- a/config/config.go +++ b/config/config.go @@ -43,6 +43,9 @@ type ConfigFile struct { // Validation DKIM map[string]interface{} + // CSV parsing + CSV CSVConfig + // Directories ContentDir string LayoutDir string @@ -66,6 +69,11 @@ type BuildInfo struct { BuildDate string } +// Configuration for CSV parsing +type CSVConfig struct { + Comma string +} + func (i BuildInfo) String() string { return fmt.Sprintf("v%s %s/%s (%s)", i.Version, runtime.GOOS, runtime.GOARCH, i.BuildDate) } diff --git a/config/fs.go b/config/fs.go index 1657018..49dc11c 100644 --- a/config/fs.go +++ b/config/fs.go @@ -4,13 +4,14 @@ import ( "os" "path/filepath" "slices" + "strings" "github.com/spf13/afero" ) var ( contentExts = []string{".md"} - listExts = []string{".yaml", ".yml"} + listExts = []string{".yaml", ".yml", ".csv"} ) type Fs struct { @@ -99,3 +100,11 @@ func (f *Fs) isDir(dir string) bool { s, err := f.Stat(dir) return err == nil && s.IsDir() } + +func (f *Fs) IsYaml(path string) bool { + return strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml") +} + +func (f *Fs) IsCsv(path string) bool { + return strings.HasSuffix(path, ".csv") +} diff --git a/config/fs_test.go b/config/fs_test.go new file mode 100644 index 0000000..a1fcb7b --- /dev/null +++ b/config/fs_test.go @@ -0,0 +1,26 @@ +package config + +import "testing" + +func TestIsYaml(t *testing.T) { + fs := Fs{} + if !fs.IsYaml("file.yaml") { + t.Error("file.yaml should be yaml") + } + if !fs.IsYaml("file.yml") { + t.Error("file.yml should be yaml") + } + if fs.IsYaml("file.json") { + t.Error("file.json should not be yaml") + } +} + +func TestIsCsv(t *testing.T) { + fs := Fs{} + if !fs.IsCsv("file.csv") { + t.Error("file.csv should be csv") + } + if fs.IsYaml("file.json") { + t.Error("file.json should not be csv") + } +} diff --git a/mail/campaign.go b/mail/campaign.go index 16fc0ca..41451bb 100644 --- a/mail/campaign.go +++ b/mail/campaign.go @@ -8,7 +8,6 @@ import ( "path/filepath" "text/template" - "github.com/ghodss/yaml" "github.com/go-gomail/gomail" "github.com/jtacoma/uritemplates" "github.com/microcosm-cc/bluemonday" @@ -201,11 +200,19 @@ func parseRecipients(appFs *config.Fs, path string) ([]*ctxRecipient, error) { fmt.Println("Loading recipients", path) raw, err := afero.ReadFile(appFs, path) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to read file %s: %w", path, err) } var data []map[string]interface{} - if err := yaml.Unmarshal(raw, &data); err != nil { + + if appFs.IsYaml(path) { + data, err = unmarshalYamlRecipients(appFs, raw) + } else if appFs.IsCsv(path) { + data, err = unmarshalCsvRecipients(appFs, raw) + } else { + return nil, fmt.Errorf("unsupported recipient format: %s", path) + } + if err != nil { return nil, err } diff --git a/mail/campaign_test.go b/mail/campaign_test.go new file mode 100644 index 0000000..fcb8abd --- /dev/null +++ b/mail/campaign_test.go @@ -0,0 +1,69 @@ +package mail + +import ( + "path/filepath" + "testing" + + "github.com/rykov/paperboy/config" + "github.com/spf13/afero" +) + +func TestParseRecipientsCsv(t *testing.T) { + aFs := afero.NewMemMapFs() + + // Write and load fake configuration + cPath, _ := filepath.Abs("./recipients.csv") + afero.WriteFile(aFs, cPath, []byte(`email,name,extra +jhon.doe@example.com,J Doe,Extra Data +`), 0644) + + appFs := config.Fs{ + Config: &config.AConfig{ + ConfigFile: config.ConfigFile{ + CSV: config.CSVConfig{ + Comma: ",", + }, + }, + }, + Fs: aFs, + } + + recipients, err := parseRecipients(&appFs, cPath) + if err != nil { + t.Error(err) + } + if len(recipients) != 1 { + t.Errorf("Expected 1 recipient, got %d", len(recipients)) + } +} + +func TestParseRecipientsYaml(t *testing.T) { + aFs := afero.NewMemMapFs() + + // Write and load fake configuration + cPath, _ := filepath.Abs("./recipients.yml") + afero.WriteFile(aFs, cPath, []byte(`--- +- email: jhon.doe@example.com + name: J Doe + extra: Extra Data +`), 0644) + + appFs := config.Fs{ + Config: &config.AConfig{ + ConfigFile: config.ConfigFile{ + CSV: config.CSVConfig{ + Comma: ",", + }, + }, + }, + Fs: aFs, + } + + recipients, err := parseRecipients(&appFs, cPath) + if err != nil { + t.Error(err) + } + if len(recipients) != 1 { + t.Errorf("Expected 1 recipient, got %d", len(recipients)) + } +} diff --git a/mail/recipients_csv.go b/mail/recipients_csv.go new file mode 100644 index 0000000..36f8629 --- /dev/null +++ b/mail/recipients_csv.go @@ -0,0 +1,51 @@ +package mail + +import ( + "bytes" + "encoding/csv" + "io" + + "github.com/rykov/paperboy/config" +) + +func unmarshalCsvRecipients(appFs *config.Fs, raw []byte) ([]map[string]interface{}, error) { + var data []map[string]interface{} + + // CSV + csvReader := newCSVReader(appFs.Config.ConfigFile.CSV, bytes.NewReader(raw)) + + // Read header + header, err := csvReader.Read() + if err != nil { + return nil, err + } + + // Read CSV line by line + for { + record, err := csvReader.Read() + if err == io.EOF { + break + } else if err != nil { + return nil, err + } + + // Create a map for each record + rec := make(map[string]interface{}) + for i, h := range header { + rec[h] = record[i] + } + + data = append(data, rec) + } + + return data, nil +} + +func newCSVReader(cfg config.CSVConfig, r io.Reader) *csv.Reader { + csvReader := csv.NewReader(r) + // Deal with separator + if len(cfg.Comma) > 0 { + csvReader.Comma = []rune(cfg.Comma)[0] + } + return csvReader +} diff --git a/mail/recipients_csv_test.go b/mail/recipients_csv_test.go new file mode 100644 index 0000000..8da0795 --- /dev/null +++ b/mail/recipients_csv_test.go @@ -0,0 +1,31 @@ +package mail + +import ( + "testing" + + "github.com/rykov/paperboy/config" +) + +func TestUnmarshalCsv(t *testing.T) { + content := []byte(`email,name,extra +jhon.doe@example.com,J Doe,Extra Data +`) + + appFs := config.Fs{ + Config: &config.AConfig{ + ConfigFile: config.ConfigFile{ + CSV: config.CSVConfig{ + Comma: ",", + }, + }, + }, + } + + recipients, err := unmarshalCsvRecipients(&appFs, content) + if err != nil { + t.Error(err) + } + if len(recipients) != 1 { + t.Errorf("Expected 1 recipient, got %d", len(recipients)) + } +} diff --git a/mail/recipients_yaml.go b/mail/recipients_yaml.go new file mode 100644 index 0000000..19aed66 --- /dev/null +++ b/mail/recipients_yaml.go @@ -0,0 +1,16 @@ +package mail + +import ( + "github.com/ghodss/yaml" + "github.com/rykov/paperboy/config" +) + +func unmarshalYamlRecipients(appFs *config.Fs, raw []byte) ([]map[string]interface{}, error) { + var data []map[string]interface{} + + if err := yaml.Unmarshal(raw, &data); err != nil { + return nil, err + } + + return data, nil +}