Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -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.]

<!-- Fixes SC-XXXXX -->

### 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.
65 changes: 65 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
@@ -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 ./...
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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 <test@example.com>", "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.
146 changes: 146 additions & 0 deletions commo/commo.go
Original file line number Diff line number Diff line change
@@ -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")
}
113 changes: 113 additions & 0 deletions commo/commo_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading