diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c917f94..28a7c64 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -14,11 +14,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: - go-version: '1.24.x' + go-version: '1.25.x' - name: Setup KO uses: ko-build/setup-ko@v0.9 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2379d8b..20636db 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -21,7 +21,7 @@ jobs: - name: Lint uses: golangci/golangci-lint-action@v8 with: - version: v2.4.0 + version: v2.5.0 - name: Test run: go test -race -covermode atomic -coverprofile=coverage.txt -v ./... diff --git a/README.md b/README.md index 70b1f5e..5b64425 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ as HTTP endpoint so the other microservices that belong to you can make email re - [ ] Mailtrap - [ ] Mailjet - [ ] Mailgun -- [ ] Sendgrid (avoid them if you can) +- [X] Sendgrid Note: Anything that only uses oauth2 like zoho does will not be implemented here for foreseeable future diff --git a/cmd/emailer/main.go b/cmd/emailer/main.go index 4dfa50f..6e9bf01 100644 --- a/cmd/emailer/main.go +++ b/cmd/emailer/main.go @@ -18,6 +18,7 @@ import ( "github.com/mrwormhole/emailer" "github.com/mrwormhole/emailer/brevo" "github.com/mrwormhole/emailer/resend" + "github.com/mrwormhole/emailer/sendgrid" ) var debugEnabled = flag.Bool("debug", false, "in debug environment") @@ -25,8 +26,9 @@ var debugEnabled = flag.Bool("debug", false, "in debug environment") const ( defaultPort = "5555" // Providers Listed below - providerBrevo = "brevo" - providerResend = "resend" + providerBrevo = "brevo" + providerResend = "resend" + providerSendgrid = "sendgrid" ) func main() { @@ -69,20 +71,31 @@ func main() { httpClient := c.StandardClient() httpClient.Timeout = 10 * time.Second + ctx := context.Background() var sender emailer.Sender + cfg := emailer.Config{Key: key, Client: *httpClient} switch { case strings.EqualFold(provider, providerBrevo): - slog.LogAttrs(context.Background(), slog.LevelDebug, "brevo.New()") - sender, err = brevo.New(emailer.Config{Key: key, Client: *httpClient}) + slog.LogAttrs(ctx, slog.LevelDebug, "brevo.New()") + sender, err = brevo.New(cfg) if err != nil { - slog.LogAttrs(context.Background(), slog.LevelError, "brevo.New()", slog.String("err", err.Error())) + slog.LogAttrs(ctx, slog.LevelError, "brevo.New()", slog.String("err", err.Error())) } case strings.EqualFold(provider, providerResend): - slog.LogAttrs(context.Background(), slog.LevelDebug, "resend.New()") - sender, err = resend.New(emailer.Config{Key: key, Client: *httpClient}) + slog.LogAttrs(ctx, slog.LevelDebug, "resend.New()") + sender, err = resend.New(cfg) if err != nil { - slog.LogAttrs(context.Background(), slog.LevelError, "resend.New()", slog.String("err", err.Error())) + slog.LogAttrs(ctx, slog.LevelError, "resend.New()", slog.String("err", err.Error())) } + case strings.EqualFold(provider, providerSendgrid): + slog.LogAttrs(ctx, slog.LevelDebug, "sendgrid.New()") + sender, err = sendgrid.New(cfg) + if err != nil { + slog.LogAttrs(ctx, slog.LevelError, "sendgrid.New()", slog.String("err", err.Error())) + } + default: + slog.LogAttrs(ctx, slog.LevelError, "unknown provider", slog.String("provider", provider)) + os.Exit(1) } mux := http.NewServeMux() diff --git a/go.mod b/go.mod index 4c25cf8..7e73b77 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ module github.com/mrwormhole/emailer -go 1.22 +go 1.25 require ( github.com/google/go-cmp v0.6.0 - github.com/hashicorp/go-retryablehttp v0.7.7 + github.com/hashicorp/go-retryablehttp v0.7.8 ) require github.com/hashicorp/go-cleanhttp v0.5.2 // indirect diff --git a/go.sum b/go.sum index 568c4bb..36e86d5 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= -github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/sendgrid/client.go b/sendgrid/client.go new file mode 100644 index 0000000..cccee84 --- /dev/null +++ b/sendgrid/client.go @@ -0,0 +1,143 @@ +// Package sendgrid makes it easy to send emails via sendgrid provider. This package follows [sendgrid spec] strictly. +// +// Example usage: +// +// email := emailer.Email{ +// From: "skywalker@jedi.com", +// To: []string{"vindu@sith.com"}, +// Subject: "peace", +// TextContent: "peace was never an option", +// } +// c, err := New(emailer.Config{key: "api-key"}) +// if err != nil { +// //check err +// } +// c.Send(ctx, email) +// +// [sendgrid spec]: https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send +package sendgrid + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httputil" + "slices" + "strings" + + "github.com/mrwormhole/emailer" +) + +const endpoint = "https://api.sendgrid.com/v3/mail/send" + +// EmailClient is sendgrid email client to interact with emails +type EmailClient struct { + key string + client http.Client +} + +// New creates a new sendgrid email client with given API key and http.Client +func New(c emailer.Config) (*EmailClient, error) { + if strings.TrimSpace(c.Key) == "" { + return nil, errors.New("sendgrid API key is blank") + } + e := &EmailClient{ + key: c.Key, + client: c.Client, + } + return e, nil +} + +type emailObject struct { + Email string `json:"email"` +} + +type personalization struct { + To []emailObject `json:"to"` + BCC []emailObject `json:"bcc,omitempty"` + CC []emailObject `json:"cc,omitempty"` +} + +type content struct { + // Type is MIME Type (e.g., text/plain or text/html) + Type string `json:"type"` + // Value is the actual content + Value string `json:"value"` +} + +// payload is a request that sendgrid uses to send email +type payload struct { + Personalizations []personalization `json:"personalizations"` + From emailObject `json:"from"` + Subject string `json:"subject"` + Content []content `json:"content,omitempty"` +} + +type errorMessage struct { + Errors []struct { + Message string `json:"message"` + Field string `json:"field"` + Help any `json:"help"` + } `json:"errors"` +} + +// Send sends a given email +func (c *EmailClient) Send(ctx context.Context, email emailer.Email) error { + var p payload + p.From.Email = email.From + + pers := personalization{} + for _, e := range email.To { + pers.To = append(pers.To, emailObject{Email: e}) + } + for _, e := range email.BCC { + pers.BCC = append(pers.BCC, emailObject{Email: e}) + } + for _, e := range email.CC { + pers.CC = append(pers.CC, emailObject{Email: e}) + } + p.Personalizations = append(p.Personalizations, pers) + p.Subject = email.Subject + + if email.TextContent != "" { + p.Content = append(p.Content, content{Type: "text/plain", Value: email.TextContent}) + } + if email.HTMLContent != "" { + p.Content = append(p.Content, content{Type: "text/html", Value: email.HTMLContent}) + } + + raw, err := json.Marshal(p) + if err != nil { + return fmt.Errorf("json.Marshal(%v): %v", p, err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(raw)) + if err != nil { + return fmt.Errorf("http.NewRequestWithContext(): %v", err) + } + req.Header.Add("Authorization", "Bearer "+c.key) + req.Header.Add("accept", "application/json") + req.Header.Add("content-type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("client.Do(%v): %v", req, err) + } + defer func() { + _ = resp.Body.Close() + }() + + if slices.Contains([]int{http.StatusAccepted, http.StatusCreated, http.StatusOK}, resp.StatusCode) { + return nil + } + + var m errorMessage + if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { + dump, _ := httputil.DumpResponse(resp, true) + return fmt.Errorf("json.NewDecoder(%v).Decode(): %v", string(dump), err) + } + + return fmt.Errorf("unsuccessful response with status code(%d): %v", resp.StatusCode, m) +} diff --git a/sendgrid/client_test.go b/sendgrid/client_test.go new file mode 100644 index 0000000..8629a5d --- /dev/null +++ b/sendgrid/client_test.go @@ -0,0 +1,331 @@ +package sendgrid + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/mrwormhole/emailer" + "github.com/mrwormhole/emailer/emailtest" +) + +func TestNew(t *testing.T) { + _, err := New(emailer.Config{}) + want := errors.New("sendgrid API key is blank") + if !cmp.Equal(want.Error(), err.Error()) { + t.Errorf("New(): got=%q want=%q", err, want) + } +} + +func TestSend_Success(t *testing.T) { + tripper := func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: http.StatusAccepted, + } + } + client, err := New(emailtest.NewConfig(tripper)) + if err != nil { + t.Fatalf("New(): %v", err) + } + + email := emailer.Email{ + From: "a@a.com", + To: []string{"b@b.com"}, + BCC: []string{"bcc@bcc.com"}, + CC: []string{"cc@cc.com"}, + Subject: "sub", + HTMLContent: "html", + TextContent: "text", + } + if err := client.Send(context.Background(), email); err != nil { + t.Errorf("Send(): %v", err) + } +} + +func TestSend_FaultyClient(t *testing.T) { + slog.SetLogLoggerLevel(slog.Level(100)) + tripper := func(req *http.Request) *http.Response { + return nil + } + client, err := New(emailtest.NewFaultyClientConfig(tripper)) + if err != nil { + t.Fatalf("New(): %v", err) + } + + email := emailer.Email{ + From: "a@a.com", + To: []string{"b@b.com"}, + Subject: "sub", + HTMLContent: "html", + } + if err := client.Send(context.Background(), email); err == nil { + t.Error("Send(): expected error, got nil") + } +} + +func TestSend_TeapotClient(t *testing.T) { + slog.SetLogLoggerLevel(slog.Level(100)) + tripper := func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: http.StatusTeapot, + } + } + client, err := New(emailtest.NewConfig(tripper)) + if err != nil { + t.Fatalf("New(): %v", err) + } + + email := emailer.Email{ + From: "a@a.com", + To: []string{"b@b.com"}, + Subject: "sub", + HTMLContent: "html", + } + if err := client.Send(context.Background(), email); err == nil { + t.Error("Send(): expected error, got nil") + } +} + +func TestSend_ProviderCodeMessage(t *testing.T) { + slog.SetLogLoggerLevel(slog.Level(100)) + tripper := func(req *http.Request) *http.Response { + cm := struct { + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + }{ + Errors: []struct { + Message string `json:"message"` + }{{Message: "sendgrid don't like that"}}, + } + raw, err := json.Marshal(cm) + if err != nil { + t.Fatalf("json.Marshal(%v): %v", cm, err) + } + + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(bytes.NewBuffer(raw)), + } + } + client, err := New(emailtest.NewConfig(tripper)) + if err != nil { + t.Fatalf("New(): %v", err) + } + + email := emailer.Email{ + From: "a@a.com", + To: []string{"b@b.com"}, + Subject: "sub", + HTMLContent: "html", + } + if err := client.Send(context.Background(), email); err == nil { + t.Error("Send(): expected error, got nil") + } +} + +func TestEmailHandler_BrokenRequest(t *testing.T) { + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/email", bytes.NewBuffer([]byte{'h', 'e', 'l', 'l', 'o'})) + rr := httptest.NewRecorder() + handler := emailer.HandlerFunc(nil) + handler.ServeHTTP(rr, req) + + if diff := cmp.Diff(http.StatusBadRequest, rr.Code); diff != "" { + t.Errorf("HandlerFunc(): HTTP code diff=%v", diff) + } + + if diff := cmp.Diff("Failed to decode request: invalid character 'h' looking for beginning of value\n", rr.Body.String()); diff != "" { + t.Errorf("HandlerFunc(): HTTP body diff=%v", diff) + } +} + +func TestEmailHandler_FailedValidation(t *testing.T) { + email := emailer.Email{ + From: "a@a.com", + } + raw, err := json.Marshal(email) + if err != nil { + t.Fatalf("json.Marshal(%v): %v", email, err) + } + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/email", bytes.NewBuffer(raw)) + rr := httptest.NewRecorder() + handler := emailer.HandlerFunc(nil) + handler.ServeHTTP(rr, req) + + if diff := cmp.Diff(http.StatusBadRequest, rr.Code); diff != "" { + t.Errorf("HandlerFunc(): HTTP code diff=%v", diff) + } + + if diff := cmp.Diff("Failed to validate: to field must not be blank\n", rr.Body.String()); diff != "" { + t.Errorf("HandlerFunc(): HTTP body diff=%v", diff) + } +} + +func TestEmailHandler_Success(t *testing.T) { + tripper := func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + } + } + client, err := New(emailtest.NewConfig(tripper)) + if err != nil { + t.Fatalf("New(): %v", err) + } + + email := emailer.Email{ + From: "a@a.com", + To: []string{"b@b.com"}, + Subject: "sub", + HTMLContent: "html", + } + raw, err := json.Marshal(email) + if err != nil { + t.Fatalf("json.Marshal(%v): %v", email, err) + } + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/send", bytes.NewBuffer(raw)) + rr := httptest.NewRecorder() + handler := emailer.HandlerFunc(client) + handler.ServeHTTP(rr, req) + + if diff := cmp.Diff(http.StatusOK, rr.Code); diff != "" { + t.Errorf("HandlerFunc(): HTTP code diff=%v", diff) + } + + if diff := cmp.Diff("Email successfully sent", rr.Body.String()); diff != "" { + t.Errorf("HandlerFunc(): HTTP body diff=%v", diff) + } +} + +func TestEmailHandler_FaultyClient(t *testing.T) { + slog.SetLogLoggerLevel(slog.Level(100)) + tripper := func(req *http.Request) *http.Response { + return nil + } + client, err := New(emailtest.NewFaultyClientConfig(tripper)) + if err != nil { + t.Fatalf("New(): %v", err) + } + + email := emailer.Email{ + From: "a@a.com", + To: []string{"b@b.com"}, + Subject: "sub", + HTMLContent: "html", + } + raw, err := json.Marshal(email) + if err != nil { + t.Fatalf("json.Marshal(%v): %v", email, err) + } + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/send", bytes.NewBuffer(raw)) + rr := httptest.NewRecorder() + handler := emailer.HandlerFunc(client) + handler.ServeHTTP(rr, req) + + if diff := cmp.Diff(http.StatusInternalServerError, rr.Code); diff != "" { + t.Errorf("HandlerFunc(): HTTP code diff=%v", diff) + } + + if diff := cmp.Diff("Failed to send email\n", rr.Body.String()); diff != "" { + t.Errorf("HandlerFunc(): HTTP body diff=%v", diff) + } +} + +func TestEmailHandler_TeapotClient(t *testing.T) { + slog.SetLogLoggerLevel(slog.Level(100)) + tripper := func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: http.StatusTeapot, + } + } + client, err := New(emailtest.NewConfig(tripper)) + if err != nil { + t.Fatalf("New(): %v", err) + } + + email := emailer.Email{ + From: "a@a.com", + To: []string{"b@b.com"}, + Subject: "sub", + HTMLContent: "html", + } + raw, err := json.Marshal(email) + if err != nil { + t.Fatalf("json.Marshal(%v): %v", email, err) + } + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/send", bytes.NewBuffer(raw)) + rr := httptest.NewRecorder() + handler := emailer.HandlerFunc(client) + handler.ServeHTTP(rr, req) + + if diff := cmp.Diff(http.StatusInternalServerError, rr.Code); diff != "" { + t.Errorf("HandlerFunc(): HTTP code diff=%v", diff) + } + + if diff := cmp.Diff("Failed to send email\n", rr.Body.String()); diff != "" { + t.Errorf("HandlerFunc(): HTTP body diff=%v", diff) + } +} + +func TestEmailHandler_ProviderCodeMessage(t *testing.T) { + slog.SetLogLoggerLevel(slog.Level(100)) + tripper := func(req *http.Request) *http.Response { + cm := struct { + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + }{ + Errors: []struct { + Message string `json:"message"` + }{{Message: "sendgrid don't like that"}}, + } + raw, err := json.Marshal(cm) + if err != nil { + t.Fatalf("json.Marshal(%v): %v", cm, err) + } + + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(bytes.NewBuffer(raw)), + } + } + client, err := New(emailtest.NewConfig(tripper)) + if err != nil { + t.Fatalf("New(): %v", err) + } + + email := emailer.Email{ + From: "a@a.com", + To: []string{"b@b.com"}, + Subject: "sub", + HTMLContent: "html", + } + raw, err := json.Marshal(email) + if err != nil { + t.Fatalf("json.Marshal(%v): %v", email, err) + } + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/send", bytes.NewBuffer(raw)) + rr := httptest.NewRecorder() + handler := emailer.HandlerFunc(client) + handler.ServeHTTP(rr, req) + + if diff := cmp.Diff(http.StatusInternalServerError, rr.Code); diff != "" { + t.Errorf("HandlerFunc(): HTTP code diff=%v", diff) + } + + if diff := cmp.Diff("Failed to send email\n", rr.Body.String()); diff != "" { + t.Errorf("HandlerFunc(): HTTP body diff=%v", diff) + } +}