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
6 changes: 3 additions & 3 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 21 additions & 8 deletions cmd/emailer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,17 @@ 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")

const (
defaultPort = "5555"
// Providers Listed below
providerBrevo = "brevo"
providerResend = "resend"
providerBrevo = "brevo"
providerResend = "resend"
providerSendgrid = "sendgrid"
)

func main() {
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
143 changes: 143 additions & 0 deletions sendgrid/client.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading