Implement notifications service with shared messaging and email drivers (#36)
This commit is contained in:
1 parent
36277b6c45
commit
5c2d36281b
54 files changed
+3746
-6
No files matched your search
@@ -0,0 +1,79 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
type Driver string
|
||||
|
||||
const (
|
||||
SendGrid Driver = "sendgrid"
|
||||
Resend Driver = "resend"
|
||||
)
|
||||
|
||||
type Config = sharedmodel.DriverConfig[Driver]
|
||||
type Settings struct {
|
||||
APIKeyEnv string `mapstructure:"api_key_env"`
|
||||
SenderAddress string `mapstructure:"sender_address"`
|
||||
SenderName string `mapstructure:"sender_name"`
|
||||
}
|
||||
|
||||
// Outcome describes provider acceptance, never recipient delivery or reading.
|
||||
type Outcome struct {
|
||||
State model.State
|
||||
Code string
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
type Sender interface {
|
||||
Send(context.Context, string, model.Email) Outcome
|
||||
}
|
||||
|
||||
type client struct {
|
||||
http *http.Client
|
||||
key string
|
||||
settings Settings
|
||||
endpoint string
|
||||
}
|
||||
|
||||
func New(config Config) (Sender, error) {
|
||||
var s Settings
|
||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "create driver settings decoder")
|
||||
}
|
||||
if err = decoder.Decode(config.Settings); err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid email driver settings")
|
||||
}
|
||||
if !ValidAddress(s.SenderAddress) || strings.ContainsAny(s.SenderName, "\r\n") {
|
||||
return nil, merrors.InvalidArgument("invalid sender")
|
||||
}
|
||||
key := os.Getenv(s.APIKeyEnv)
|
||||
if s.APIKeyEnv == "" || strings.TrimSpace(key) == "" || strings.ContainsAny(key, "\r\n") {
|
||||
return nil, merrors.FailedPrecondition("email API key environment variable is missing")
|
||||
}
|
||||
c := client{http: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, key: key, settings: s}
|
||||
switch config.Driver {
|
||||
case SendGrid:
|
||||
c.endpoint = "https://api.sendgrid.com/v3/mail/send"
|
||||
return &sendgrid{c}, nil
|
||||
case Resend:
|
||||
c.endpoint = "https://api.resend.com/emails"
|
||||
return &resend{c}, nil
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("unsupported email driver")
|
||||
}
|
||||
}
|
||||
func ValidAddress(s string) bool {
|
||||
a, err := mail.ParseAddress(s)
|
||||
return err == nil && a.Address == s && !strings.ContainsAny(s, "\r\n") && len(s) <= 254
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
)
|
||||
|
||||
func TestDriversRenderWirePayload(t *testing.T) {
|
||||
for _, driver := range []Driver{SendGrid, Resend} {
|
||||
t.Run(string(driver), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer test-secret" {
|
||||
t.Error("missing authorization")
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if body["subject"] != "Completed" {
|
||||
t.Errorf("subject: %v", body)
|
||||
}
|
||||
if driver == SendGrid {
|
||||
if body["template_id"] != nil {
|
||||
t.Error("provider-managed templates must not be used")
|
||||
}
|
||||
p := body["personalizations"].([]any)[0].(map[string]any)
|
||||
if p["to"].([]any)[0].(map[string]any)["email"] != "user@example.com" {
|
||||
t.Error("wrong recipient")
|
||||
}
|
||||
content := body["content"].([]any)
|
||||
if len(content) != 2 || content[1].(map[string]any)["value"] != "<p>A & B</p>" {
|
||||
t.Error("wrong body")
|
||||
}
|
||||
} else {
|
||||
if r.Header.Get("Idempotency-Key") != "delivery-id" {
|
||||
t.Error("missing idempotency key")
|
||||
}
|
||||
if body["html"] != "<p>A & B</p>" || body["text"] != "A & B" {
|
||||
t.Error("wrong body")
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer server.Close()
|
||||
c := client{http: server.Client(), key: "test-secret", endpoint: server.URL, settings: Settings{SenderAddress: "sender@example.com", SenderName: "SXCHNGE"}}
|
||||
var sender Sender = &sendgrid{c}
|
||||
if driver == Resend {
|
||||
sender = &resend{c}
|
||||
}
|
||||
outcome := sender.Send(context.Background(), "delivery-id", model.Email{Address: "user@example.com", Subject: "Completed", HTML: "<p>A & B</p>", Text: "A & B"})
|
||||
if outcome.State != model.Accepted {
|
||||
t.Fatal(outcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestProviderFailures(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
code int
|
||||
state model.State
|
||||
}{{429, model.RetryWait}, {400, model.Failed}, {401, model.Failed}, {403, model.Failed}, {500, model.Unknown}, {408, model.Unknown}, {302, model.Failed}} {
|
||||
t.Run(http.StatusText(tc.code), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Retry-After", "120")
|
||||
w.WriteHeader(tc.code)
|
||||
_, _ = w.Write([]byte("secret provider response"))
|
||||
}))
|
||||
defer server.Close()
|
||||
c := client{http: server.Client(), endpoint: server.URL}
|
||||
out := c.post(context.Background(), "", struct{}{})
|
||||
if out.State != tc.state || strings.Contains(out.Code, "secret") {
|
||||
t.Fatal(out)
|
||||
}
|
||||
if tc.code == 429 && out.RetryAfter != 2*time.Minute {
|
||||
t.Fatal(out)
|
||||
}
|
||||
})
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
c := client{http: http.DefaultClient, endpoint: "http://127.0.0.1:1"}
|
||||
if out := c.post(ctx, "", struct{}{}); out.State != model.Unknown {
|
||||
t.Fatal(out)
|
||||
}
|
||||
}
|
||||
func TestDriverConfig(t *testing.T) {
|
||||
t.Setenv("EMAIL_TEST_KEY", "secret")
|
||||
for _, driver := range []Driver{SendGrid, Resend} {
|
||||
if _, err := New(Config{Driver: driver, Settings: map[string]any{"api_key_env": "EMAIL_TEST_KEY", "sender_address": "sender@example.com"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, settings := range []map[string]any{{"api_key_env": "MISSING_KEY", "sender_address": "sender@example.com"}, {"api_key_env": "EMAIL_TEST_KEY", "sender_address": "bad"}, {"api_key_env": "EMAIL_TEST_KEY", "sender_address": "sender@example.com", "typo": true}} {
|
||||
if _, err := New(Config{Driver: Resend, Settings: settings}); err == nil {
|
||||
t.Fatal("accepted invalid driver config")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
)
|
||||
|
||||
func (c *client) post(ctx context.Context, key string, payload any) Outcome {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return Outcome{State: model.Failed, Code: "invalid_payload"}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Outcome{State: model.Failed, Code: "invalid_provider_config"}
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.key)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if key != "" {
|
||||
req.Header.Set("Idempotency-Key", key)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown"}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Discard a bounded response without logging provider-controlled content.
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
|
||||
switch {
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
return Outcome{State: model.Accepted}
|
||||
case resp.StatusCode == 429:
|
||||
delay := time.Minute
|
||||
if n, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil && n > 0 && n <= 86400 {
|
||||
delay = time.Duration(n) * time.Second
|
||||
} else if date, e := http.ParseTime(resp.Header.Get("Retry-After")); e == nil && time.Until(date) > 0 {
|
||||
delay = time.Until(date)
|
||||
}
|
||||
return Outcome{State: model.RetryWait, Code: "provider_rate_limited", RetryAfter: delay}
|
||||
case resp.StatusCode >= 500 || resp.StatusCode == 408:
|
||||
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown"}
|
||||
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||
return Outcome{State: model.Failed, Code: "provider_authentication_failed"}
|
||||
default:
|
||||
return Outcome{State: model.Failed, Code: "provider_rejected"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/mail"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
)
|
||||
|
||||
type resend struct{ client }
|
||||
type resendRequest struct {
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
HTML string `json:"html,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
func (s *resend) Send(ctx context.Context, key string, email model.Email) Outcome {
|
||||
from := (&mail.Address{Name: s.settings.SenderName, Address: s.settings.SenderAddress}).String()
|
||||
return s.post(ctx, key, resendRequest{From: from, To: []string{email.Address}, Subject: email.Subject, HTML: email.HTML, Text: email.Text})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package email
|
||||
|
||||
// Adapted from Sendico's SendGrid notifier (see PROVENANCE.md). The SDK mail
|
||||
// construction is retained; templates, retries and logging belong to our service.
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
"github.com/sendgrid/sendgrid-go/helpers/mail"
|
||||
)
|
||||
|
||||
type sendgrid struct{ client }
|
||||
|
||||
func (s *sendgrid) Send(ctx context.Context, _ string, email model.Email) Outcome {
|
||||
m := mail.NewV3Mail()
|
||||
m.SetFrom(mail.NewEmail(s.settings.SenderName, s.settings.SenderAddress))
|
||||
m.Subject = email.Subject
|
||||
p := mail.NewPersonalization()
|
||||
p.AddTos(mail.NewEmail("", email.Address))
|
||||
m.AddPersonalizations(p)
|
||||
if email.Text != "" {
|
||||
m.AddContent(mail.NewContent("text/plain", email.Text))
|
||||
}
|
||||
if email.HTML != "" {
|
||||
m.AddContent(mail.NewContent("text/html", email.HTML))
|
||||
}
|
||||
return s.post(ctx, "", m)
|
||||
}
|
||||
Reference in new issue
Block a user