diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..726dda2 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,30 @@ +### Scope of changes + +[Describe the problem you're trying to solve and the fix/solution that you've implemented in this PR.] + + + +### Type of change + +- [ ] bug fix +- [ ] new feature +- [ ] documentation +- [ ] testing +- [ ] other (describe) + +### Acceptance criteria + +Describe how reviewers can test this change to be sure that it works correctly. Add a checklist if possible. + +### Author checklist + +- [ ] I have manually tested the change and/or added automation in the form of unit tests or integration tests +- [ ] I have updated the dependencies list +- [ ] I have added new test fixtures as needed to support added tests +- [ ] I have added or updated the documentation +- [ ] Check this box if a reviewer can merge this pull request after approval (leave it unchecked if you want to do it yourself) + +### Reviewer(s) checklist + +- [ ] Any new user-facing content that has been added for this PR has been QA'ed to ensure correct grammar, spelling, and understandability. +- [ ] To the best of my ability, I believe that this PR represents a good solution to the specified problem and that it should be merged into the main code base. \ No newline at end of file diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..58a1e2e --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,65 @@ +name: CI Tests +on: + push: + branches: + - main + - "v*" + tags: + - "v*" + pull_request: + +jobs: + go-lint: + name: Go Lint + runs-on: ubuntu-latest + steps: + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: 1.25.x + + - name: Install Staticcheck + run: go install honnef.co/go/tools/cmd/staticcheck@latest + + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Lint Go Code + run: staticcheck ./... + + go-test: + name: Go Test + runs-on: ubuntu-latest + env: + GOPATH: ${{ github.workspace }}/go + GOBIN: ${{ github.workspace }}/go/bin + GOTEST_GITHUB_ACTIONS: 1 + defaults: + run: + working-directory: ${{ env.GOPATH }}/src/go.rtnl.ai/nlp + steps: + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: 1.25.x + + - name: Cache Speedup + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Checkout Code + uses: actions/checkout@v4 + with: + path: ${{ env.GOPATH }}/src/go.rtnl.ai/nlp + + - name: Install Dependencies + run: | + go version + + + - name: Run Unit Tests + run: go test -v -coverprofile=coverage.txt -covermode=atomic --race ./... diff --git a/README.md b/README.md index f3e545a..2eb5ede 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,33 @@ # commo -An email sending package that can be configured to use either SendGrid or SMTP + +An email rendering and sending package that can be configured to use either SendGrid or SMTP. + +## Usage + +```go +// Load configuration from a .env file +conf := commo.Config{} +err := confire.Process("commo_email", &conf) +checkErr(err) + +// Load templates +var templates map[string]*template.Template +templates = ... // not shown here; see `commo/commo_test.go` for full example + +// Initialize commo +err = commo.Initialize(conf, templates) +checkErr(err) + +// Data is a struct that has all of the required fields for the template being used +data := struct{ ContactName string }{ContactName: "User Name"} + +// Create the email +email, err := commo.New("Test User ", "Email Subject", "template_name_no_ext", data) +checkErr(err) + +// Send the email +err = email.Send() +checkErr(err) +``` + +See the test(s) in [commo/commo_test.go](./commo/commo_test.go) for a full working example. diff --git a/commo/commo.go b/commo/commo.go new file mode 100644 index 0000000..330aaa9 --- /dev/null +++ b/commo/commo.go @@ -0,0 +1,146 @@ +package commo + +import ( + "context" + "errors" + "html/template" + + "github.com/jordan-wright/email" + "go.rtnl.ai/x/backoff" + + "github.com/sendgrid/rest" + "github.com/sendgrid/sendgrid-go" + sgmail "github.com/sendgrid/sendgrid-go/helpers/mail" +) + +// Package level variables +var ( + initialized bool + config Config + templs map[string]*template.Template + pool *email.Pool + sg *sendgrid.Client +) + +// Config for [backoff.ExponentialBackOff] +const ( + multiplier = 2.0 + randomizationFactor = 0.45 +) + +// Initialize the package to start sending emails. If there is no valid email +// configuration available then configuration is gracefully ignored without error. +func Initialize(conf Config, templates map[string]*template.Template) (err error) { + // Do not configure email if it is not available but also do not return an error. + if !conf.Available() { + return nil + } + + if err = conf.Validate(); err != nil { + return err + } + + // TODO: if in testing mode create a mock for sending emails. + + if conf.SMTP.Enabled() { + if pool, err = conf.SMTP.Pool(); err != nil { + return err + } + } + + if conf.SendGrid.Enabled() { + sg = conf.SendGrid.Client() + } + + config = conf + templs = templates + initialized = true + return nil +} + +// Loads templates into commo's internal template storage. Useful for testing. +func WithTemplates(templates map[string]*template.Template) { + templs = templates +} + +// Send an email using the configured send methodology. Uses exponential backoff to +// retry multiple times on error with an increasing delay between attempts. +func Send(email *Email) (err error) { + // The package must be initialized to send. + if !initialized { + return ErrNotInitialized + } + + // Select the send function to deliver the email with. + var send sender + switch { + case config.SMTP.Enabled(): + send = sendSMTP + case config.SendGrid.Enabled(): + send = sendSendGrid + case config.Testing: + send = sendMock + default: + panic("unhandled send email method") + } + + exponential := backoff.ExponentialBackOff{ + InitialInterval: config.Backoff.InitialInterval, + RandomizationFactor: randomizationFactor, + Multiplier: multiplier, + MaxInterval: config.Backoff.MaxInterval, + } + + // Attempt to send the message with multiple retries. + if _, err = backoff.Retry(context.Background(), func() (any, serr error) { + serr = send(email) + return nil, serr + }, + backoff.WithBackOff(&exponential), + backoff.WithMaxElapsedTime(config.Backoff.MaxElapsedTime), + ); err != nil { + return err + } + + return nil + +} + +type sender func(*Email) error + +func sendSMTP(e *Email) (err error) { + var msg *email.Email + if msg, err = e.ToSMTP(); err != nil { + return err + } + + if err = pool.Send(msg, config.Backoff.Timeout); err != nil { + return err + } + return nil +} + +func sendSendGrid(e *Email) (err error) { + var msg *sgmail.SGMailV3 + if msg, err = e.ToSendGrid(); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), config.Backoff.Timeout) + defer cancel() + + var rep *rest.Response + if rep, err = sg.SendWithContext(ctx, msg); err != nil { + return err + } + + if rep.StatusCode < 200 || rep.StatusCode >= 300 { + return errors.New(rep.Body) + } + + return nil +} + +func sendMock(*Email) (err error) { + return errors.New("not implemented") +} diff --git a/commo/commo_test.go b/commo/commo_test.go new file mode 100644 index 0000000..880a992 --- /dev/null +++ b/commo/commo_test.go @@ -0,0 +1,113 @@ +package commo_test + +import ( + "embed" + "html/template" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/joho/godotenv" + "github.com/rotationalio/confire" + "github.com/stretchr/testify/require" + "go.rtnl.ai/commo/commo" +) + +func TestLiveEmails(t *testing.T) { + // Load local .env if it exists to make setting envvars easier. + godotenv.Load("testdata/.env") + + // This test will send actual emails to an account as configured by the environment. + // The $TEST_LIVE_EMAILS environment variable must be set to true to not skip. + SkipByEnvVar(t, "TEST_LIVE_EMAILS") + CheckEnvVars(t, "TEST_LIVE_EMAIL_RECIPIENT") + + // Configure email sending from the environment. See .env.template for requirements. + conf := commo.Config{} + err := confire.Process("commo_email", &conf) + require.NoError(t, err, "environment not setup to send live emails; see .env.template") + require.True(t, conf.Available(), "no backend setup to send live emails; see .env.template") + + err = commo.Initialize(conf, loadTestTemplates()) + require.NoError(t, err, "could not configure email sending") + + recipient := os.Getenv("TEST_LIVE_EMAIL_RECIPIENT") + + t.Run("TestEmail", func(t *testing.T) { + data := struct{ ContactName string }{ContactName: "User Name"} + + email, err := commo.New(recipient, "Test Subject", "test_email", data) + require.NoError(t, err, "could not create reset password email") + + err = email.Send() + require.NoError(t, err, "could not send reset password email") + }) +} + +// ############################################################################ +// Helpers +// ############################################################################ + +func CheckEnvVars(t *testing.T, envs ...string) { + for _, env := range envs { + require.NotEmpty(t, os.Getenv(env), "required environment variable $%s not set", env) + } +} + +func SkipByEnvVar(t *testing.T, env string) { + val := strings.ToLower(strings.TrimSpace(os.Getenv(env))) + switch val { + case "1", "t", "true": + return + default: + t.Skipf("this test depends on the $%s envvar to run", env) + } +} + +// ############################################################################ +// Test Template Loading +// ############################################################################ + +const ( + templatesDir = "testdata/templates" + partialsDir = "partials" +) + +var ( + //go:embed testdata/templates/*.html testdata/templates/*.txt testdata/templates/partials/*html + files embed.FS +) + +// Load templates +func loadTestTemplates() map[string]*template.Template { + var ( + err error + templateFiles []fs.DirEntry + ) + + templates := make(map[string]*template.Template) + if templateFiles, err = fs.ReadDir(files, templatesDir); err != nil { + panic(err) + } + + // Each template needs to be parsed independently to ensure that define directives + // are not overriden if they have the same name; e.g. to use the base template. + for _, file := range templateFiles { + if file.IsDir() { + continue + } + + // Each template will be accessible by its base name in the global map + patterns := make([]string, 0, 2) + patterns = append(patterns, filepath.Join(templatesDir, file.Name())) + switch filepath.Ext(file.Name()) { + case ".html": + patterns = append(patterns, filepath.Join(templatesDir, partialsDir, "*.html")) + } + + templates[file.Name()] = template.Must(template.ParseFS(files, patterns...)) + } + return templates +} diff --git a/commo/config.go b/commo/config.go new file mode 100644 index 0000000..2abe879 --- /dev/null +++ b/commo/config.go @@ -0,0 +1,185 @@ +package commo + +import ( + "fmt" + "net/mail" + "net/smtp" + "time" + + "github.com/jordan-wright/email" + "github.com/sendgrid/sendgrid-go" +) + +// The emails config allows users to either send messages via SendGrid or via SMTP. +type Config struct { + Sender string `split_words:"true" desc:"the email address that messages are sent from"` + SenderName string `split_words:"true" desc:"the name of the sender, usually the name of the organization"` + Testing bool `split_words:"true" default:"false" desc:"set the emailer to testing mode to ensure no live emails are sent"` + SMTP SMTPConfig `split_words:"true"` + SendGrid SendGridConfig `split_words:"false"` + Backoff BackoffConfig `split_words:"true"` +} + +// Configuration for sending emails via SMTP. +type SMTPConfig struct { + Host string `required:"false" desc:"the smtp host without the port e.g. smtp.example.com; if set SMTP will be used, cannot be set with sendgrid api key"` + Port uint16 `default:"587" desc:"the port to access the smtp server on"` + Username string `required:"false" desc:"the username for authentication with the smtp server"` + Password string `required:"false" desc:"the password for authentication with the smtp server"` + UseCRAMMD5 bool `env:"USE_CRAM_MD5" default:"false" desc:"use CRAM-MD5 auth as defined in RFC 2195 instead of simple authentication"` + PoolSize int `split_words:"true" default:"2" desc:"the smtp connection pool size to use for concurrent email sending"` +} + +// Configuration for sending emails using SendGrid. +type SendGridConfig struct { + APIKey string `split_words:"true" required:"false" desc:"set the sendgrid api key to use sendgrid as the email backend"` +} + +// Configuration for timeouts and retries when sending emails. +type BackoffConfig struct { + Timeout time.Duration `default:"30s" desc:"the time to wait for emails to send (default: 30 seconds)"` + InitialInterval time.Duration `split_words:"true" default:"3s" desc:"the initial time between tries (default: 3 seconds)"` + MaxInterval time.Duration `split_words:"true" default:"45s" desc:"the maximum time between tries (default: 45 seconds)"` + MaxElapsedTime time.Duration `split_words:"true" default:"180s" desc:"the the overall maximum time to try to send emails (default: 180 seconds)"` +} + +// Returns true if either SMTP is configured or SendGrid is. +func (c Config) Available() bool { + return c.SMTP.Enabled() || c.SendGrid.Enabled() +} + +func (c Config) Validate() (err error) { + // It is important that if we're in testing mode that we do not validate the + // config because this creates dependencies for config validation in other modules. + // If the config is not available, then do not validate it. + if c.Testing || !c.Available() { + return nil + } + + // Check that a from email exists and that it is parseable + if c.Sender == "" { + return ErrConfigMissingSender + } + + if _, perr := mail.ParseAddress(c.Sender); perr != nil { + return ErrConfigInvalidSender + } + + // Cannot specify both email mechanisms + if c.SMTP.Enabled() && c.SendGrid.Enabled() { + return ErrConfigConflict + } + + // Validate the SMTP configuration + if c.SMTP.Enabled() { + if err = c.SMTP.Validate(); err != nil { + return err + } + } + + // Validate the SendGrid configuration + if c.SendGrid.Enabled() { + if err = c.SendGrid.Validate(); err != nil { + return err + } + } + + // Validate the backoff configuration + if err = c.Backoff.Validate(); err != nil { + return err + } + + return nil +} + +func (c SMTPConfig) Enabled() bool { + return c.Host != "" +} + +func (c SMTPConfig) Validate() (err error) { + // Do not validate if not enabled + if !c.Enabled() { + return nil + } + + if c.Port == 0 { + return ErrConfigMissingPort + } + + if c.PoolSize < 1 { + return ErrConfigPoolSize + } + + if c.UseCRAMMD5 { + if c.Username == "" || c.Password == "" { + return ErrConfigCRAMMD5Auth + } + } + + return nil +} + +func (c SMTPConfig) Pool() (*email.Pool, error) { + return email.NewPool(c.Addr(), c.PoolSize, c.Auth()) +} + +func (c SMTPConfig) Auth() smtp.Auth { + if c.UseCRAMMD5 { + return smtp.CRAMMD5Auth(c.Username, c.Password) + } + return smtp.PlainAuth("", c.Username, c.Password, c.Host) +} + +func (c SMTPConfig) Addr() string { + return fmt.Sprintf("%s:%d", c.Host, c.Port) +} + +func (c SendGridConfig) Enabled() bool { + return c.APIKey != "" +} + +func (c SendGridConfig) Validate() (err error) { + // Do not validate if not enabled + if !c.Enabled() { + return nil + } + return nil +} + +func (c SendGridConfig) Client() *sendgrid.Client { + return sendgrid.NewSendClient(c.APIKey) +} + +func (c Config) GetSenderName() string { + if c.SenderName != "" { + return c.SenderName + } + + if c.Sender != "" { + if addr, err := mail.ParseAddress(c.Sender); err == nil { + return addr.Name + } + } + + return "" +} + +func (c BackoffConfig) Validate() (err error) { + if c.Timeout <= 0 { + return ErrConfigTimeout + } + + if c.InitialInterval <= 0 { + return ErrConfigInitialInterval + } + + if c.MaxInterval <= 0 { + return ErrConfigMaxInterval + } + + if c.MaxElapsedTime <= 0 { + return ErrConfigMaxElapsedTime + } + + return nil +} diff --git a/commo/config_test.go b/commo/config_test.go new file mode 100644 index 0000000..b366280 --- /dev/null +++ b/commo/config_test.go @@ -0,0 +1,432 @@ +package commo_test + +import ( + "os" + "testing" + "time" + + "github.com/rotationalio/confire" + "github.com/stretchr/testify/require" + "go.rtnl.ai/commo/commo" +) + +var testEnv = map[string]string{ + "EMAIL_SENDER": "Jane Szack ", + "EMAIL_SENDER_NAME": "Jane Szack", + "EMAIL_TESTING": "true", + "EMAIL_SMTP_HOST": "smtp.example.com", + "EMAIL_SMTP_PORT": "25", + "EMAIL_SMTP_USERNAME": "jszack", + "EMAIL_SMTP_PASSWORD": "supersecret", + "EMAIL_SMTP_USE_CRAM_MD5": "true", + "EMAIL_SMTP_POOL_SIZE": "16", + "EMAIL_SENDGRID_API_KEY": "sg:fakeapikey", +} + +func TestConfig(t *testing.T) { + // Set required environment variables and cleanup after the test is complete. + t.Cleanup(cleanupEnv()) + setEnv() + + // NOTE: no validation is run while creating the config from the environment + conf, err := config() + require.Equal(t, testEnv["EMAIL_SENDER"], conf.Sender) + require.Equal(t, testEnv["EMAIL_SENDER_NAME"], conf.SenderName) + require.True(t, conf.Testing) + require.Equal(t, testEnv["EMAIL_SMTP_HOST"], conf.SMTP.Host) + require.Equal(t, uint16(25), conf.SMTP.Port) + require.Equal(t, testEnv["EMAIL_SMTP_USERNAME"], conf.SMTP.Username) + require.Equal(t, testEnv["EMAIL_SMTP_PASSWORD"], conf.SMTP.Password) + require.True(t, conf.SMTP.UseCRAMMD5) + require.Equal(t, 16, conf.SMTP.PoolSize) + require.Equal(t, testEnv["EMAIL_SENDGRID_API_KEY"], conf.SendGrid.APIKey) + require.NoError(t, err, "could not process configuration from the environment") +} + +func TestConfigAvailable(t *testing.T) { + testCases := []struct { + conf commo.Config + assert require.BoolAssertionFunc + }{ + { + commo.Config{}, + require.False, + }, + { + commo.Config{ + SMTP: commo.SMTPConfig{Host: "email.example.com"}, + }, + require.True, + }, + { + commo.Config{ + SendGrid: commo.SendGridConfig{APIKey: "sg:fakeapikey"}, + }, + require.True, + }, + } + + for i, tc := range testCases { + tc.assert(t, tc.conf.Available(), "test case %d failed", i) + } +} + +func TestConfigValidation(t *testing.T) { + validBackoff := commo.BackoffConfig{ + Timeout: 1 * time.Second, + InitialInterval: 1 * time.Second, + MaxInterval: 1 * time.Second, + MaxElapsedTime: 1 * time.Second, + } + + t.Run("Valid", func(t *testing.T) { + testCases := []commo.Config{ + { + Testing: false, + Backoff: validBackoff, + }, + { + Sender: "peony@example.com", + Testing: false, + SendGrid: commo.SendGridConfig{ + APIKey: "sg:fakeapikey", + }, + Backoff: validBackoff, + }, + { + Sender: "peony@example.com", + Testing: false, + SMTP: commo.SMTPConfig{ + Host: "smtp.example.com", + Port: 587, + Username: "admin", + Password: "supersecret", + UseCRAMMD5: false, + PoolSize: 4, + }, + Backoff: validBackoff, + }, + { + Testing: true, + }, + { + Sender: "Peony Quarterdeck ", + Testing: false, + SendGrid: commo.SendGridConfig{ + APIKey: "sg:fakeapikey", + }, + Backoff: validBackoff, + }, + { + Sender: "peony@example.com", + Testing: false, + SendGrid: commo.SendGridConfig{ + APIKey: "sg:fakeapikey", + }, + Backoff: validBackoff, + }, + { + Sender: "peony@example.com", + Testing: false, + SendGrid: commo.SendGridConfig{ + APIKey: "sg:fakeapikey", + }, + Backoff: validBackoff, + }, + { + Sender: "peony@example.com", + Testing: false, + SendGrid: commo.SendGridConfig{ + APIKey: "sg:fakeapikey", + }, + Backoff: validBackoff, + }, + { + Sender: "peony@example.com", + Testing: false, + SendGrid: commo.SendGridConfig{ + APIKey: "sg:fakeapikey", + }, + Backoff: validBackoff, + }, + } + + for i, conf := range testCases { + require.NoError(t, conf.Validate(), "test case %d failed", i) + } + + }) + + t.Run("Invalid", func(t *testing.T) { + testCases := []struct { + conf commo.Config + err error + }{ + { + commo.Config{ + Testing: false, + SMTP: commo.SMTPConfig{Host: "email.example.com"}, + }, + commo.ErrConfigMissingSender, + }, + { + commo.Config{ + Testing: false, + SendGrid: commo.SendGridConfig{APIKey: "sg:fakeapikey"}, + }, + commo.ErrConfigMissingSender, + }, + { + commo.Config{ + Sender: "foo", + Testing: false, + SMTP: commo.SMTPConfig{Host: "smtp.example.com"}, + }, + commo.ErrConfigInvalidSender, + }, + { + commo.Config{ + Sender: "orchid@example.com", + Testing: false, + SMTP: commo.SMTPConfig{ + Host: "smtp.example.com", + }, + SendGrid: commo.SendGridConfig{ + APIKey: "sg:fakeapikey", + }, + }, + commo.ErrConfigConflict, + }, + { + commo.Config{ + Sender: "orchid@example.com", + Testing: false, + SMTP: commo.SMTPConfig{ + Host: "smtp.example.com", + Port: 0, + }, + }, + commo.ErrConfigMissingPort, + }, + { + commo.Config{ + Sender: "orchid@example.com", + Testing: false, + SMTP: commo.SMTPConfig{ + Host: "smtp.example.com", + Port: 527, + }, + }, + commo.ErrConfigPoolSize, + }, + { + commo.Config{ + Sender: "orchid@example.com", + Testing: false, + SMTP: commo.SMTPConfig{ + Host: "smtp.example.com", + Port: 527, + PoolSize: 4, + UseCRAMMD5: true, + }, + }, + commo.ErrConfigCRAMMD5Auth, + }, + { + commo.Config{ + Sender: "peony@example.com", + Testing: false, + SendGrid: commo.SendGridConfig{ + APIKey: "sg:fakeapikey", + }, + Backoff: commo.BackoffConfig{ + InitialInterval: time.Duration(0 * time.Second), + MaxElapsedTime: time.Duration(1 * time.Second), + MaxInterval: time.Duration(1 * time.Second), + Timeout: time.Duration(1 * time.Second), + }, + }, + commo.ErrConfigInitialInterval, + }, + { + commo.Config{ + Sender: "peony@example.com", + Testing: false, + SendGrid: commo.SendGridConfig{ + APIKey: "sg:fakeapikey", + }, + Backoff: commo.BackoffConfig{ + InitialInterval: time.Duration(1 * time.Second), + MaxElapsedTime: time.Duration(0 * time.Second), + MaxInterval: time.Duration(1 * time.Second), + Timeout: time.Duration(1 * time.Second), + }, + }, + commo.ErrConfigMaxElapsedTime, + }, + { + commo.Config{ + Sender: "peony@example.com", + Testing: false, + SendGrid: commo.SendGridConfig{ + APIKey: "sg:fakeapikey", + }, + Backoff: commo.BackoffConfig{ + InitialInterval: time.Duration(1 * time.Second), + MaxElapsedTime: time.Duration(1 * time.Second), + MaxInterval: time.Duration(0 * time.Second), + Timeout: time.Duration(1 * time.Second), + }, + }, + commo.ErrConfigMaxInterval, + }, + { + commo.Config{ + Sender: "peony@example.com", + Testing: false, + SendGrid: commo.SendGridConfig{ + APIKey: "sg:fakeapikey", + }, + Backoff: commo.BackoffConfig{ + InitialInterval: time.Duration(1 * time.Second), + MaxElapsedTime: time.Duration(1 * time.Second), + MaxInterval: time.Duration(1 * time.Second), + Timeout: time.Duration(0 * time.Second), + }, + }, + commo.ErrConfigTimeout, + }, + } + + for i, tc := range testCases { + require.ErrorIs(t, tc.conf.Validate(), tc.err, "test case %d failed", i) + } + }) +} + +func TestSMTPConfig(t *testing.T) { + t.Run("Addr", func(t *testing.T) { + conf := commo.SMTPConfig{ + Host: "smtp.example.com", + Port: 527, + } + require.Equal(t, "smtp.example.com:527", conf.Addr()) + }) +} + +func TestGetSenderName(t *testing.T) { + testCases := []struct { + conf commo.Config + expected string + }{ + { + commo.Config{ + Sender: "Jane Szack ", + }, + "Jane Szack", + }, + { + commo.Config{ + Sender: "jane@example.com", + }, + "", + }, + { + commo.Config{ + Sender: "", + SenderName: "Jane Szack", + }, + "Jane Szack", + }, + { + commo.Config{ + Sender: "", + SenderName: "", + }, + "", + }, + { + commo.Config{ + Sender: "foo", + SenderName: "", + }, + "", + }, + { + commo.Config{ + Sender: "John Doe ", + SenderName: "Jane Szack", + }, + "Jane Szack", + }, + { + commo.Config{ + Sender: "john.doe@example.com", + SenderName: "John Doe", + }, + "John Doe", + }, + } + + for i, tc := range testCases { + require.Equal(t, tc.expected, tc.conf.GetSenderName(), "test case %d failed", i) + } +} + +// Creates a new email config from the current environment. +func config() (conf commo.Config, err error) { + if err = confire.Process("email", &conf); err != nil { + return conf, err + } + return conf, nil +} + +// Returns the current environment for the specified keys, or if no keys are specified +// then it returns the current environment for all keys in the testEnv variable. +func curEnv(keys ...string) map[string]string { + env := make(map[string]string) + if len(keys) > 0 { + for _, key := range keys { + if val, ok := os.LookupEnv(key); ok { + env[key] = val + } + } + } else { + for key := range testEnv { + env[key] = os.Getenv(key) + } + } + + return env +} + +// Sets the environment variables from the testEnv variable. If no keys are specified, +// then this function sets all environment variables from the testEnv. +func setEnv(keys ...string) { + if len(keys) > 0 { + for _, key := range keys { + if val, ok := testEnv[key]; ok { + os.Setenv(key, val) + } + } + } else { + for key, val := range testEnv { + os.Setenv(key, val) + } + } +} + +// Cleanup helper function that can be run when the tests are complete to reset the +// environment back to its previous state before the test was run. +func cleanupEnv(keys ...string) func() { + prevEnv := curEnv(keys...) + return func() { + for key, val := range prevEnv { + if val != "" { + os.Setenv(key, val) + } else { + os.Unsetenv(key) + } + } + } +} diff --git a/commo/email.go b/commo/email.go new file mode 100644 index 0000000..30c449c --- /dev/null +++ b/commo/email.go @@ -0,0 +1,116 @@ +package commo + +import ( + "fmt" + "net/mail" + + "github.com/jordan-wright/email" + + sgmail "github.com/sendgrid/sendgrid-go/helpers/mail" +) + +type Email struct { + Sender string + To []string + Subject string + Template string + Data any +} + +// New creates a new email template with the currently configured sender attached. If +// the sender is not configured, then it is left empty; otherwise if the module has +// been configured and there is no sender, an error is returned. +func New(recipient, subject, template string, data any) (*Email, error) { + msg := &Email{ + To: []string{recipient}, + Subject: subject, + Template: template, + Data: data, + } + + if initialized { + msg.Sender = config.Sender + } + return msg, nil +} + +// Validate that all required data is present to assemble a sendable email. +func (e *Email) Validate() error { + switch { + case e.Subject == "": + return ErrMissingSubject + case e.Sender == "": + return ErrMissingSender + case len(e.To) == 0: + return ErrMissingRecipient + case e.Template == "": + return ErrMissingTemplate + } + + if _, err := mail.ParseAddress(e.Sender); err != nil { + return fmt.Errorf("invalid sender email address %q: %w", e.Sender, ErrIncorrectEmail) + } + + for _, to := range e.To { + if _, err := mail.ParseAddress(to); err != nil { + return fmt.Errorf("invalid recipient email address %q: %w", to, ErrIncorrectEmail) + } + } + + return nil +} + +// Helper method to send an email using the commo.Send package function. +func (e *Email) Send() error { + return Send(e) +} + +// Return an email struct that can be sent via SMTP +func (e *Email) ToSMTP() (msg *email.Email, err error) { + if err = e.Validate(); err != nil { + return nil, err + } + + msg = email.NewEmail() + msg.From = e.Sender + msg.To = e.To + msg.Subject = e.Subject + + if msg.Text, msg.HTML, err = Render(e.Template, e.Data); err != nil { + return nil, err + } + + return msg, nil +} + +// Return an email struct that can be sent via SendGrid +func (e *Email) ToSendGrid() (msg *sgmail.SGMailV3, err error) { + if err = e.Validate(); err != nil { + return nil, err + } + + // See: https://github.com/sendgrid/sendgrid-go/blob/16f25e4d92886b2733473a19977ccf1aa625a89b/helpers/mail/mail_v3.go#L186-L195 + msg = new(sgmail.SGMailV3) + msg.Subject = e.Subject + msg.SetFrom(MustNewSGEmail(e.Sender)) + + p := sgmail.NewPersonalization() + p.AddTos(MustNewSGEmails(e.To)...) + msg.AddPersonalizations(p) + + var ( + text string + html string + ) + + if text, html, err = RenderString(e.Template, e.Data); err != nil { + return nil, err + } + + msg.AddContent( + sgmail.NewContent("text/plain", text), + sgmail.NewContent("text/html", html), + ) + + return msg, nil +} diff --git a/commo/email_test.go b/commo/email_test.go new file mode 100644 index 0000000..7ccfdab --- /dev/null +++ b/commo/email_test.go @@ -0,0 +1,102 @@ +package commo_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.rtnl.ai/commo/commo" +) + +func TestEmailValidate(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + testCases := []*commo.Email{ + { + "admin@server.com", + []string{"test@example.com"}, + "This is a test email", + "test", + nil, + }, + { + "admin@server.com", + []string{"test@example.com"}, + "This is a test email", + "test", + map[string]any{"count": 4}, + }, + } + + for i, email := range testCases { + require.NoError(t, email.Validate(), "test case %d failed", i) + } + }) + + t.Run("Invalid", func(t *testing.T) { + testCases := []struct { + email *commo.Email + err error + }{ + { + &commo.Email{ + Sender: "admin@server.com", + To: []string{"test@example.com"}, + Template: "test", + Data: nil, + }, + commo.ErrMissingSubject, + }, + { + &commo.Email{ + To: []string{"test@example.com"}, + Subject: "This is a test email", + Template: "test", + Data: nil, + }, + commo.ErrMissingSender, + }, + { + &commo.Email{ + Sender: "admin@server.com", + Subject: "This is a test email", + Template: "test", + Data: nil, + }, + commo.ErrMissingRecipient, + }, + { + &commo.Email{ + Sender: "admin@server.com", + To: []string{"test@example.com"}, + Subject: "This is a test email", + Data: nil, + }, + commo.ErrMissingTemplate, + }, + { + &commo.Email{ + Sender: "admin@@server", + To: []string{"test@example.com"}, + Subject: "This is a test email", + Template: "test", + Data: nil, + }, + commo.ErrIncorrectEmail, + }, + { + &commo.Email{ + Sender: "admin@server.com", + To: []string{"@example.com"}, + Subject: "This is a test email", + Template: "test", + Data: nil, + }, + commo.ErrIncorrectEmail, + }, + } + + for i, tc := range testCases { + require.ErrorIs(t, tc.email.Validate(), tc.err, "test case %d failed", i) + } + }) + +} diff --git a/commo/errors.go b/commo/errors.go new file mode 100644 index 0000000..720da23 --- /dev/null +++ b/commo/errors.go @@ -0,0 +1,26 @@ +package commo + +import "errors" + +var ( + ErrIncorrectEmail = errors.New("could not parse email address") + ErrMissingRecipient = errors.New("missing email recipient(s)") + ErrMissingSender = errors.New("missing email sender") + ErrMissingSubject = errors.New("missing email subject") + ErrMissingTemplate = errors.New("missing email template name") + ErrNotInitialized = errors.New("email sending method has not been configured") + ErrTemplatesNotLoaded = errors.New("templates have not been loaded yet") +) + +var ( + ErrConfigConflict = errors.New("invalid configuration: cannot specify configuration for both smtp and sendgrid") + ErrConfigCRAMMD5Auth = errors.New("invalid configuration: smtp cram-md5 requires username and password") + ErrConfigInitialInterval = errors.New("invalid configuration: initial interval must be greater than zero") + ErrConfigInvalidSender = errors.New("invalid configuration: could not parse sender email address") + ErrConfigMaxElapsedTime = errors.New("invalid configuration: max elapsed time must be greater than zero") + ErrConfigMaxInterval = errors.New("invalid configuration: max interval must be greater than zero") + ErrConfigMissingPort = errors.New("invalid configuration: smtp port is required") + ErrConfigMissingSender = errors.New("invalid configuration: sender email is required") + ErrConfigPoolSize = errors.New("invalid configuration: smtp connections pool size must be greater than zero") + ErrConfigTimeout = errors.New("invalid configuration: timeout must be greater than zero") +) diff --git a/commo/render.go b/commo/render.go new file mode 100644 index 0000000..4c086e1 --- /dev/null +++ b/commo/render.go @@ -0,0 +1,53 @@ +package commo + +import ( + "bytes" + "fmt" +) + +// Render returns the text and html executed templates for the specified name +// and data. Ensure that the extension is not supplied to the render method. +func Render(name string, data any) (text, html []byte, err error) { + if text, err = render(name+".txt", data); err != nil { + return nil, nil, err + } + + if html, err = render(name+".html", data); err != nil { + return nil, nil, err + } + + return text, html, nil +} + +// Render returns the text and html executed templates as strings for the +// specified name and data. Ensure that the extension is not supplied to the +// render method. +func RenderString(name string, data any) (text, html string, err error) { + var ( + tb []byte + hb []byte + ) + + if tb, hb, err = Render(name, data); err != nil { + return "", "", nil + } + + return string(tb), string(hb), nil +} + +func render(name string, data any) (_ []byte, err error) { + if templs == nil { + return nil, ErrTemplatesNotLoaded + } + + t, ok := templs[name] + if !ok { + return nil, fmt.Errorf("could not find %q in templates", name) + } + + buf := &bytes.Buffer{} + if err = t.Execute(buf, data); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/commo/render_test.go b/commo/render_test.go new file mode 100644 index 0000000..ffdd097 --- /dev/null +++ b/commo/render_test.go @@ -0,0 +1,45 @@ +package commo_test + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.rtnl.ai/commo/commo" +) + +func TestRender(t *testing.T) { + names := allEmailTemplates(t) + for _, name := range names { + text, html, err := commo.Render(name, nil) + require.NoError(t, err, "could not render %q", name) + require.NotEmpty(t, text, "no text data returned for %q", name) + require.NotEmpty(t, html, "no html data returned for %q", name) + } +} + +func TestRenderUnknown(t *testing.T) { + commo.WithTemplates(loadTestTemplates()) + _, _, err := commo.Render("foo", nil) + require.EqualError(t, err, "could not find \"foo.txt\" in templates", "expected unknown template") +} + +func allEmailTemplates(t *testing.T) []string { + paths := make(map[string]struct{}) + ls, err := filepath.Glob("templates/*.*") + require.NoError(t, err, "could not ls templates directory") + + for _, path := range ls { + base := filepath.Base(path) + base = strings.TrimSuffix(base, filepath.Ext(base)) + paths[base] = struct{}{} + } + + out := make([]string, 0, len(paths)) + for path := range paths { + out = append(out, path) + } + + return out +} diff --git a/commo/sendgrid.go b/commo/sendgrid.go new file mode 100644 index 0000000..37f08d0 --- /dev/null +++ b/commo/sendgrid.go @@ -0,0 +1,43 @@ +package commo + +import ( + "net/mail" + + sgmail "github.com/sendgrid/sendgrid-go/helpers/mail" +) + +func NewSGEmail(email string) (_ *sgmail.Email, err error) { + var parsed *mail.Address + if parsed, err = mail.ParseAddress(email); err != nil { + return nil, err + } + return sgmail.NewEmail(parsed.Name, parsed.Address), nil +} + +func NewSGEmails(emails []string) (out []*sgmail.Email, err error) { + out = make([]*sgmail.Email, 0, len(emails)) + for _, email := range emails { + var addr *sgmail.Email + if addr, err = NewSGEmail(email); err != nil { + return nil, err + } + out = append(out, addr) + } + return out, nil +} + +func MustNewSGEmail(email string) *sgmail.Email { + addr, err := NewSGEmail(email) + if err != nil { + panic(err) + } + return addr +} + +func MustNewSGEmails(emails []string) []*sgmail.Email { + addrs, err := NewSGEmails(emails) + if err != nil { + panic(err) + } + return addrs +} diff --git a/commo/sendgrid_test.go b/commo/sendgrid_test.go new file mode 100644 index 0000000..94c2de1 --- /dev/null +++ b/commo/sendgrid_test.go @@ -0,0 +1,105 @@ +package commo_test + +import ( + "testing" + + sgmail "github.com/sendgrid/sendgrid-go/helpers/mail" + "github.com/stretchr/testify/require" + "go.rtnl.ai/commo/commo" +) + +func TestNewSGEmail(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + testCases := []struct { + email string + expected *sgmail.Email + }{ + { + "jlong@example.com", + &sgmail.Email{Name: "", Address: "jlong@example.com"}, + }, + { + "Jersey Long ", + &sgmail.Email{Name: "Jersey Long", Address: "jlong@example.com"}, + }, + } + + for i, tc := range testCases { + sgm, err := commo.NewSGEmail(tc.email) + require.NoError(t, err, "test case %d errored", i) + require.Equal(t, tc.expected, sgm, "test case %d mismatch", i) + require.Equal(t, tc.expected, commo.MustNewSGEmail(tc.email), "test case %d panic", i) + } + }) + + t.Run("Invalid", func(t *testing.T) { + testCases := []string{ + "foo", + "Lacy Credence ", + "foo@@foo", + } + + for i, email := range testCases { + sgm, err := commo.NewSGEmail(email) + require.Error(t, err, "test case %d did not error", i) + require.Nil(t, sgm, "test case %d message was not nil", i) + require.Panics(t, func() { commo.MustNewSGEmail(email) }, "test case %d did not panic", i) + } + }) +} + +func TestNewSGEmails(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + testCases := []struct { + emails []string + expected []*sgmail.Email + }{ + { + nil, + []*sgmail.Email{}, + }, + { + []string{"jlong@example.com"}, + []*sgmail.Email{{Name: "", Address: "jlong@example.com"}}, + }, + { + []string{"Jersey Long "}, + []*sgmail.Email{{Name: "Jersey Long", Address: "jlong@example.com"}}, + }, + { + []string{"jlong@example.com", "Frieda Short "}, + []*sgmail.Email{{Name: "", Address: "jlong@example.com"}, {Name: "Frieda Short", Address: "fshort@example.com"}}, + }, + } + + for i, tc := range testCases { + sgm, err := commo.NewSGEmails(tc.emails) + require.NoError(t, err, "test case %d errored", i) + require.Equal(t, tc.expected, sgm, "test case %d mismatch", i) + require.Equal(t, tc.expected, commo.MustNewSGEmails(tc.emails), "test case %d panic", i) + } + }) + + t.Run("Invalid", func(t *testing.T) { + testCases := [][]string{ + { + "foo", + }, + { + "Larry Helmand ", "lh@example.com", "bad", + }, + { + "foo", + "Lacy Credence ", + "foo@@foo", + }, + } + + for i, email := range testCases { + sgm, err := commo.NewSGEmails(email) + require.Error(t, err, "test case %d did not error", i) + require.Nil(t, sgm, "test case %d message was not nil", i) + require.Panics(t, func() { commo.MustNewSGEmails(email) }, "test case %d did not panic", i) + } + }) +} diff --git a/commo/testdata/.env.template b/commo/testdata/.env.template new file mode 100644 index 0000000..fcc2fc4 --- /dev/null +++ b/commo/testdata/.env.template @@ -0,0 +1,23 @@ +# Add the following to a .env file in this directory to +# enable live email testing when you run: +# +# go test -run ^TestLiveEmails$ ./commo + +# Set to 0 in a .env file to skip the live email tests +TEST_LIVE_EMAILS=1 + +# Set to the email address you'd like messages sent to +TEST_LIVE_EMAIL_RECIPIENT="Tester " + +# The sender all emails will come from +COMMO_EMAIL_SENDER="Localhost Commo " + +# Configure for SendGrid +COMMO_EMAIL_SENDGRID_API_KEY= + +# Configure for SMTP +COMMO_EMAIL_SMTP_HOST= +COMMO_EMAIL_SMTP_PORT= +COMMO_EMAIL_SMTP_USERNAME= +COMMO_EMAIL_SMTP_PASSWORD= +COMMO_EMAIL_SMTP_USE_CRAMMD5=false diff --git a/commo/testdata/templates/partials/base.html b/commo/testdata/templates/partials/base.html new file mode 100644 index 0000000..c7cad73 --- /dev/null +++ b/commo/testdata/templates/partials/base.html @@ -0,0 +1,125 @@ +{{ define "base" -}} + + + + + + + + + + + + + + + + + {{ block "title" . }}{{ end }} + + {{ template "style" . }} + + + + + + + + + + +{{- end }} diff --git a/commo/testdata/templates/partials/style.html b/commo/testdata/templates/partials/style.html new file mode 100644 index 0000000..0ede651 --- /dev/null +++ b/commo/testdata/templates/partials/style.html @@ -0,0 +1,202 @@ +{{ define "style" }} + + + + + + + +{{ end }} diff --git a/commo/testdata/templates/test_email.html b/commo/testdata/templates/test_email.html new file mode 100644 index 0000000..d93b18b --- /dev/null +++ b/commo/testdata/templates/test_email.html @@ -0,0 +1,30 @@ +{{ template "base" . }} + +{{ define "title" }}Test Email{{ end }} +{{ define "preheader" }}Here is a test email.{{ end }} + +{{ define "content" }} + + + + + + +
+

Hello{{ if .ContactName }} {{ .ContactName }},{{ end }}

+

+ This is a test email from Rotational's commo library. +

+

+ There is nothing else to say. +

+

+ Have a nice day! +

+
+ + +{{- end }} + +{{ define "bottom" }} +{{ end }} diff --git a/commo/testdata/templates/test_email.txt b/commo/testdata/templates/test_email.txt new file mode 100644 index 0000000..c20efb9 --- /dev/null +++ b/commo/testdata/templates/test_email.txt @@ -0,0 +1,7 @@ +Hello{{ if .ContactName }} {{ .ContactName }}{{ end }}, + +This is a test email from Rotational's commo library. + +There is nothing else to say. + +Have a nice day! diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..55bd8fc --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module go.rtnl.ai/commo + +go 1.25.3 + +require ( + github.com/joho/godotenv v1.5.1 + github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible + github.com/rotationalio/confire v1.1.0 + github.com/sendgrid/rest v2.6.9+incompatible + github.com/sendgrid/sendgrid-go v3.16.1+incompatible + github.com/stretchr/testify v1.11.1 + go.rtnl.ai/x v1.9.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/net v0.46.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0bc9331 --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rotationalio/confire v1.1.0 h1:h10RDxiO/XH6UStfxY+oMJOVxt3Elqociilb7fIfANs= +github.com/rotationalio/confire v1.1.0/go.mod h1:ug7pBDiZZl/4JjXJ2Effmj+L+0T2DBbG+Us1qQcRex0= +github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= +github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= +github.com/sendgrid/sendgrid-go v3.16.1+incompatible h1:zWhTmB0Y8XCDzeWIm2/BIt1GjJohAA0p6hVEaDtHWWs= +github.com/sendgrid/sendgrid-go v3.16.1+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.rtnl.ai/x v1.9.0 h1:5M/1fLbVw0mxgnxhB5SyZyv7siyI+Hq2cGRPuMjwnTc= +go.rtnl.ai/x v1.9.0/go.mod h1:ciQ9PaXDtZDznzBrGDBV2yTElKX3aJgtQfi6V8613bo= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=