Implement notifications service with shared messaging and email drivers (#36)

This commit is contained in:
Stephan D committed 2026-09-12 14:41:55 +02:00
1 parent 36277b6c45
commit 5c2d36281b
54 files changed
+3746 -6

No files matched your search

+79
View File
@@ -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
}
+105
View File
@@ -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 &amp; 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 &amp; 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 &amp; 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")
}
}
}
+54
View File
@@ -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"}
}
}
+22
View File
@@ -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})
}
+28
View File
@@ -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)
}