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

+26
View File
@@ -0,0 +1,26 @@
# Source provenance
SendGrid driver source adapted from the local Sendico checkout at commit
`8bb20dcd622dd8eb92d289b6b34411e7eccab083`:
`api/notification/internal/server/notificationimp/mail/internal/sendgrid.go`.
The adaptation retains SendGrid SDK message construction, sender configuration
and personalization. It implements our provider-neutral `email.Sender` contract,
accepts already-rendered subject/text/HTML, uses bounded context-aware HTTP,
classifies delivery outcomes and never logs provider response bodies. Sendico's
builders, dynamic-template dependency, notification events and unused producer
were removed. Resend is a new driver using the same contract.
Shared messaging was selected from that same Sendico revision's
`api/pkg/messaging`: `config.go`, `capability/errors.go`, `reliable/transport.go`,
`reliable/durable_settings.go`, `reliable/durable_settings_behavior.go`,
`internal/natsb/broker.go`, `internal/natsb/config/config.go`, and
`internal/natscap/durable.go`. The selected code lives in
`backend/shared/messaging`, with imports adapted to SXCHNGE. The runtime composes
those adapters and owns connection closure. Unused discovery, best-effort envelope
subscriptions, platform stream registries and parking provisioning were omitted.
The durable adapter was separated into transport, subscription and admin files;
publishing now honors its context and fetch polling respects its bounded wait.
No Sendico module or live Sendico infrastructure is required. Use SXCHNGE-owned
MongoDB and NATS deployments.
+88
View File
@@ -0,0 +1,88 @@
# Notifications backend
Independent internal gRPC service with durable asynchronous email delivery.
The public contract belongs to [interface/proto/v1/interface.proto](interface/proto/v1/interface.proto).
Generated clients are build artifacts, not committed source.
## Architecture
The gRPC transport maps protobuf DTOs to domain requests. The service validates
and renders immutable template revisions, then saves a notification and its
pending-work state atomically in one document through the shared DB wrapper.
The outbox dispatcher publishes opaque IDs using shared messaging's durable
transport, adapted from Sendico. A durable NATS worker claims the notification
with an atomic conditional patch and saves the outcome before acknowledging.
`DriverConfig` selects the email driver: `sendgrid` or `resend`.
Email adapters only map rendered email models to provider wire payloads. SendGrid
uses adapted Sendico SDK construction; Resend uses its email HTTP API. Both support
subject, text and HTML, bounded timeouts and safe error classification.
## Run
Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with
JetStream enabled. Copy `config.example.yml` to an untracked `config.yml` here.
Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL`, a unique token of at least 32 bytes for
each configured caller, and the selected email provider's API key environment
variable. Configure a verified sender address, TLS certificate/key and template
catalog. Paths are relative to the process working directory.
From the root, run `make run-notifications`. The gRPC listener always uses TLS.
Use a private authenticated NATS endpoint; `tls://` URLs and credentials in the URL
are supported by the shared driver. Mongo and messaging both use shared
`DriverConfig` settings; email settings reject unknown keys.
The service provisions `SXCHNGE_NOTIFICATIONS` (file storage, 48-hour retention,
one replica), subject `sxchnge.notifications.email`, and durable consumer `email`.
This initial setup is for a single-replica deployment. Configure production
replication/provisioning policy in code before deploying to a clustered environment.
Multiple service workers can safely compete for jobs through atomic claims.
## Templates and parameters
See `templates.example.json`. Parameters use Go template map syntax such as
`{{.first_name}}`. Each revision declares the exact allowed parameter names and
locale-specific content. Missing/extra parameters, unknown revisions/locales and
invalid content are rejected before acceptance. Empty locale uses the default.
Values are data, not executable templates; HTML is escaped by `html/template`.
The catalog is loaded and compiled at startup. Never edit a published revision;
add a new version. Rendered payloads are persisted so retries do not depend on a
later catalog change. Recipient, body and subject are cleared on terminal state;
remaining status and request fingerprints expire through a seven-day TTL.
## Delivery semantics
- Send persists before returning; it does not wait for NATS or the email provider.
- Outbox scans every five seconds and republishes safely after a bus failure.
- Duplicate RPCs and duplicate broker deliveries do not create extra jobs/sends.
- Provider HTTP 2xx becomes ACCEPTED. No receipt/webhook or read tracking is claimed.
- Explicit rate limiting (429) retries with bounded exponential backoff and
Retry-After, at most five provider attempts, bounded by message expiry.
- Transport errors, 408, 5xx and expired worker leases become UNKNOWN. These may
have sent the email; operators must reconcile before requesting another send.
- Other provider failures are terminal. `provider_authentication_failed` signals
credentials/permissions, `provider_rejected` a nonretryable rejection,
`retry_exhausted` the attempt limit, and `expired` the message deadline.
- Resend receives the notification ID as its provider idempotency key. Its window
is 24 hours. SendGrid has no equivalent guarantee; exactly-once external email
delivery is not promised.
- Changing the configured driver fails queued jobs pinned to the old driver with
`driver_changed`. Drain queued jobs before changing sender credentials/config.
- Callers authorize recipients and preferences. No direct customer-database access,
bulk campaigns, provider failover, SMS, push or Telegram delivery is included.
Structured logs report notification IDs, driver, state and attempt count; message
content, template values, addresses and provider response bodies are excluded.
Monitor pending age, UNKNOWN/FAILED states and repeated outbox/worker warnings.
## Validate and distribute
`make test` and `make lint-backend` generate interfaces and validate all modules.
Notifications tests exercise real disposable MongoDB and NATS containers, concurrent
idempotency and claims, lease recovery, retries, transport authentication, rendering
and both provider payloads through local HTTP servers. No live emails are sent.
`make package-notifications-interface` prepares source and Go-client archives.
See [provenance](PROVENANCE.md) and [contract usage](interface/README.md).
Provider references: [SendGrid Mail Send](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send),
[Resend Send Email](https://resend.com/docs/api-reference/emails/send-email).
+53
View File
@@ -0,0 +1,53 @@
package app
import (
"bytes"
"io"
"os"
"strings"
"git.sendico.io/tech/sxchnge/notifications/email"
db "git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/messaging"
"gopkg.in/yaml.v3"
)
type Config struct {
Messaging messaging.Config `yaml:"messaging"`
Listen string `yaml:"listen"`
TLSCert string `yaml:"tls_cert"`
TLSKey string `yaml:"tls_key"`
Database db.Config `yaml:"database"`
Templates string `yaml:"templates"`
Callers map[string]string `yaml:"callers"` // caller -> token environment variable
Email email.Config `yaml:"email"`
}
func Load(path string) (Config, error) {
var c Config
data, err := os.ReadFile(path)
if err != nil {
return c, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "cannot read configuration")
}
decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)
if err = decoder.Decode(&c); err != nil {
return c, merrors.InvalidArgument("invalid configuration")
}
if err = decoder.Decode(new(any)); err != io.EOF {
return c, merrors.InvalidArgument("unexpected configuration content")
}
if c.Listen == "" || c.TLSCert == "" || c.TLSKey == "" || c.Database.Driver != db.Mongo || c.Messaging.Driver != messaging.BBNats || c.Templates == "" || len(c.Callers) == 0 {
return c, merrors.InvalidArgument("missing notification configuration")
}
seen := map[string]bool{}
for caller, env := range c.Callers {
token := os.Getenv(env)
if strings.TrimSpace(caller) == "" || len(strings.TrimSpace(token)) < 32 || seen[token] {
return c, merrors.InvalidArgument("caller credentials must be unique and at least 32 bytes")
}
seen[token] = true
}
return c, nil
}
+35
View File
@@ -0,0 +1,35 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestExampleConfigAndSecrets(t *testing.T) {
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32))
c, err := Load("../config.example.yml")
if err != nil {
t.Fatal(err)
}
if c.Email.Driver != "resend" || c.Messaging.Driver != "NATS" || c.Database.Driver != "mongodb" {
t.Fatal("wrong driver configuration")
}
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", "")
if _, err = Load("../config.example.yml"); err == nil {
t.Fatal("missing credentials accepted")
}
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32))
data, err := os.ReadFile("../config.example.yml")
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "config.yml")
if err = os.WriteFile(path, append(data, []byte("\n---\nunexpected: document\n")...), 0600); err != nil {
t.Fatal(err)
}
if _, err = Load(path); err == nil {
t.Fatal("extra YAML document accepted")
}
}
+105
View File
@@ -0,0 +1,105 @@
package app
import (
"context"
"crypto/tls"
"net"
"os"
"sync"
"time"
"git.sendico.io/tech/sxchnge/notifications/bus"
"git.sendico.io/tech/sxchnge/notifications/email"
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
"git.sendico.io/tech/sxchnge/notifications/service"
"git.sendico.io/tech/sxchnge/notifications/store"
"git.sendico.io/tech/sxchnge/notifications/templates"
"git.sendico.io/tech/sxchnge/notifications/transport"
db "git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
msgruntime "git.sendico.io/tech/sxchnge/shared/messaging/runtime"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
catalog, err := templates.Load(c.Templates)
if err != nil {
return err
}
sender, err := email.New(c.Email)
if err != nil {
return err
}
cert, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid gRPC TLS certificate")
}
client, err := db.ConnectMongo(logger, &c.Database)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "connect notifications database")
}
defer func() {
cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = client.Disconnect(cleanup)
}()
startup, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
repo, err := store.New(logger, client.Database())
if err != nil {
return err
}
rt, err := msgruntime.New(logger, &c.Messaging)
if err != nil {
return err
}
defer rt.Close()
svc := service.New(repo, catalog, sender, string(c.Email.Driver), logger)
broker, err := bus.New(startup, rt.Durable, rt.DurableAdmin, repo, svc, logger)
if err != nil {
return err
}
defer broker.Close()
tokens := map[string]string{}
for caller, env := range c.Callers {
tokens[caller] = os.Getenv(env)
}
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})), grpc.UnaryInterceptor(transport.Authenticate(tokens)), grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))
pb.RegisterNotificationsServiceServer(server, transport.New(svc))
listener, err := net.Listen("tcp", c.Listen)
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "listen for notifications")
}
defer listener.Close()
runCtx, stop := context.WithCancel(ctx)
defer stop()
var wg sync.WaitGroup
for _, run := range []func(context.Context){broker.RunPublisher, broker.RunWorker} {
wg.Add(1)
go func() { defer wg.Done(); run(runCtx) }()
}
serveErr := make(chan error, 1)
go func() { serveErr <- server.Serve(listener) }()
logger.Info("Notifications service started", zap.String("listen", c.Listen), zap.String("email_driver", string(c.Email.Driver)))
select {
case <-ctx.Done():
case err = <-serveErr:
}
stop()
stopped := make(chan struct{})
go func() { server.GracefulStop(); close(stopped) }()
select {
case <-stopped:
case <-time.After(5 * time.Second):
server.Stop()
}
wg.Wait()
if err != nil {
return merrors.OpaqueWrap(merrors.ErrInternal, err, "notification server stopped unexpectedly")
}
logger.Info("Notifications service stopped")
return nil
}
+108
View File
@@ -0,0 +1,108 @@
// Package bus connects the notification outbox to the shared messaging runtime.
package bus
import (
"context"
"strconv"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
)
const subject = "sxchnge.notifications.email"
type Outbox interface {
Due(context.Context, time.Time) ([]model.Notification, error)
Recover(context.Context, time.Time) error
}
type Handler interface {
Dispatch(context.Context, string) (bool, error)
}
type Bus struct {
transport reliable.Transport
subscription reliable.Subscription
outbox Outbox
handler Handler
logger mlogger.Logger
}
func New(ctx context.Context, transport reliable.Transport, admin reliable.Admin, outbox Outbox, handler Handler, logger mlogger.Logger) (*Bus, error) {
settings := reliable.DurableSettings{Enabled: true, Stream: reliable.DurableStreamSettings{Name: "SXCHNGE_NOTIFICATIONS", Subjects: []string{subject}, Storage: "file", MaxAgeHours: 48, Replicas: 1}, Provisioning: reliable.DurableProvisioningSettings{Mode: reliable.DurableProvisioningApp, Environment: reliable.DurableEnvironmentNonProduction}}
consumer := reliable.DurableConsumerSettings{Durable: "email", Subject: subject, AckWaitSeconds: 60, MaxDeliver: -1, DeliverPolicy: reliable.DurableDeliverAll}
if err := admin.EnsureConsumerResources(ctx, settings, consumer); err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "configure notification messaging")
}
subscription, err := transport.OpenConsumer(ctx, settings, consumer)
if err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "subscribe notification worker")
}
return &Bus{transport, subscription, outbox, handler, mlogger.Named(logger, "notifications", "bus")}, nil
}
func (b *Bus) Close() error { return b.subscription.Close() }
func (b *Bus) PublishDue(ctx context.Context) error {
if err := b.outbox.Recover(ctx, time.Now().UTC()); err != nil {
return err
}
jobs, err := b.outbox.Due(ctx, time.Now().UTC())
if err != nil {
return err
}
for _, job := range jobs {
err = b.transport.Publish(ctx, reliable.Record{Subject: subject, Payload: []byte(job.ID.Hex()), MessageID: job.ID.Hex() + "-" + strconv.FormatUint(uint64(job.Attempts), 10)})
if err != nil {
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "publish notification job")
}
}
return nil
}
func (b *Bus) RunPublisher(ctx context.Context) {
for ctx.Err() == nil {
op, cancel := context.WithTimeout(ctx, 10*time.Second)
err := b.PublishDue(op)
cancel()
if err != nil && ctx.Err() == nil {
b.logger.Warn("Notification outbox publication failed")
}
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
}
}
}
func (b *Bus) RunWorker(ctx context.Context) {
for ctx.Err() == nil {
deliveries, err := b.subscription.Fetch(ctx, 1, time.Second)
if err != nil {
if ctx.Err() == nil {
b.logger.Warn("Notification job fetch failed")
}
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
continue
}
for _, delivery := range deliveries {
payload := delivery.Record().Payload
if len(payload) == 24 {
if _, err = b.handler.Dispatch(ctx, string(payload)); err != nil {
b.logger.Warn("Notification job processing failed")
continue
}
} else {
b.logger.Warn("Malformed notification job discarded")
}
ackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
err = delivery.Ack(ackCtx)
cancel()
if err != nil {
b.logger.Warn("Notification acknowledgement failed")
}
}
}
}
@@ -0,0 +1,55 @@
package bus
import (
"context"
"testing"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
"git.sendico.io/tech/sxchnge/shared/database/record"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
"go.mongodb.org/mongo-driver/v2/bson"
)
type outbox struct{ jobs []model.Notification }
func (o *outbox) Due(context.Context, time.Time) ([]model.Notification, error) { return o.jobs, nil }
func (o *outbox) Recover(context.Context, time.Time) error { return nil }
type publisher struct {
reliable.Transport
fail bool
records []reliable.Record
}
func (p *publisher) Publish(_ context.Context, r reliable.Record) error {
if p.fail {
return merrors.ServiceUnavailable("offline")
}
p.records = append(p.records, r)
return nil
}
func TestOutboxSurvivesPublishFailure(t *testing.T) {
id := bson.NewObjectID()
out := &outbox{jobs: []model.Notification{{Base: storable.Base{ID: id}, Email: model.Email{Address: "private@example.com", Text: "private"}}}}
transport := &publisher{fail: true}
b := &Bus{outbox: out, transport: transport}
if err := b.PublishDue(context.Background()); err == nil {
t.Fatal("publication failure hidden")
}
transport.fail = false
if err := b.PublishDue(context.Background()); err != nil {
t.Fatal(err)
}
if len(transport.records) != 1 || string(transport.records[0].Payload) != id.Hex() || transport.records[0].MessageID != id.Hex()+"-0" {
t.Fatal(transport.records)
}
out.jobs[0].Attempts = 1
if err := b.PublishDue(context.Background()); err != nil {
t.Fatal(err)
}
if transport.records[1].MessageID == transport.records[0].MessageID {
t.Fatal("retry shares transport deduplication identity")
}
}
+24
View File
@@ -0,0 +1,24 @@
listen: "127.0.0.1:8083"
tls_cert: "server.crt"
tls_key: "server.key"
database:
driver: mongodb
settings:
uri_env: SXCHNGE_MONGO_URI
database: sxchnge_notifications
messaging:
driver: NATS
settings:
url_env: SXCHNGE_NATS_URL
broker_name: sxchnge-notifications
max_reconnects: -1
reconnect_wait: 2
templates: "templates.example.json"
callers:
product: "SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN"
email:
driver: resend # sendgrid or resend
settings:
api_key_env: "RESEND_API_KEY" # SENDGRID_API_KEY when driver is sendgrid
sender_address: "notifications@example.com"
sender_name: "SXCHNGE"
+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)
}
+84
View File
@@ -0,0 +1,84 @@
module git.sendico.io/tech/sxchnge/notifications
go 1.27.0
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.55.0 // indirect
github.com/moby/moby/client v0.5.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/nats-io/nats.go v1.53.1 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/stretchr/testify v1.12.1 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.56.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
)
replace git.sendico.io/tech/sxchnge/shared => ../shared
require (
git.sendico.io/tech/sxchnge/notifications/interface v0.0.0
git.sendico.io/tech/sxchnge/shared v0.0.0
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/sendgrid/sendgrid-go v3.16.1+incompatible
github.com/testcontainers/testcontainers-go v0.44.0
github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0
go.mongodb.org/mongo-driver/v2 v2.9.0
go.uber.org/zap v1.28.0
google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.12
gopkg.in/yaml.v3 v3.0.1
)
replace git.sendico.io/tech/sxchnge/notifications/interface => ./interface
+203
View File
@@ -0,0 +1,203 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/nats-io/nats.go v1.53.1 h1:Otsq3uLc/kLdjmkNHkXH0jBqwUquwdKFoe3fq6/3/Xo=
github.com/nats-io/nats.go v1.53.1/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sendgrid/sendgrid-go v3.16.1+incompatible h1:zWhTmB0Y8XCDzeWIm2/BIt1GjJohAA0p6hVEaDtHWWs=
github.com/sendgrid/sendgrid-go v3.16.1+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0 h1:VSPDFiumAtt0CkZEVbmAkEmYVRvsJpKJy9oF3exRKYg=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0/go.mod h1:kHfzrY1cYP/zr9H4TdqAxbP836A1C2fyUojlHidhFGI=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/v2 v2.9.0 h1:e2mQdOmbkiYz+dj3faM7lVDwl7WdnRD+g5VicafMhL0=
go.mongodb.org/mongo-driver/v2 v2.9.0/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
+41
View File
@@ -0,0 +1,41 @@
# Notifications service contract
This independent module owns the public internal-service contract. The canonical
source is `proto/v1/interface.proto`; it defines `Send`, `Get`, requests, statuses
and email/SMS/push/Telegram targets. The executable currently implements email.
From the repository root, `make generate-notifications` produces Go/gRPC client
bindings in `notifications/v1`. Generated `.pb.go` files are ignored and must not
be committed. Both backend validation targets generate bindings before compiling.
The generators are pinned in the Makefile; install `protoc` separately.
`make package-notifications-interface` creates a distributable source archive and
a standalone Go client-module archive under `dist/`. Client consumers can generate
bindings for their own language from the source archive. The Go archive contains
the generated bindings and its own go.mod/go.sum, with no executable or shared
infrastructure dependency. Archive generation does not upload to a registry.
Internal Go consumers use the module
`git.sendico.io/tech/sxchnge/notifications/interface` and import
`git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1`. Within this
checkout, use a local replace to this directory and generate before building.
Calls require TLS and `authorization: Bearer <service-token>` metadata. The service
maps tokens to configured caller identities. Callers authorize user/destination
ownership and preferences before sending; this contract is not a public user API.
Parameters are string values keyed by template parameter name. The entire request
is validated before persistence. An identical idempotency key/request returns the
original resource, even after completion or expiry. A changed request conflicts.
Keys are scoped to the authenticated caller and retained at least seven days;
Mongo TTL deletion is asynchronous. Expiry defaults to 24 hours and cannot exceed
24 hours. One email target is accepted; other declared channels return UNIMPLEMENTED.
ACCEPTED means provider acceptance, not delivery or reading. UNKNOWN means the
provider may have sent the message and automatic replay is unsafe. DELIVERED is
reserved for a future receipt implementation. Get never exposes recipient addresses,
rendered content, parameters, provider credentials or raw provider responses.
Keep published template revisions and field numbers stable. Reserve removed
protobuf field/enum names and numbers. Compatible additions belong in v1;
incompatible changes require a new version.
+15
View File
@@ -0,0 +1,15 @@
module git.sendico.io/tech/sxchnge/notifications/interface
go 1.27.0
require (
google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.12
)
require (
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
)
+38
View File
@@ -0,0 +1,38 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
@@ -0,0 +1,73 @@
syntax = "proto3";
package sxchnge.notifications.v1;
option go_package = "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1;notificationsv1";
import "google/protobuf/timestamp.proto";
// Internal service: authenticate callers over TLS. Send durably queues work.
service NotificationsService {
rpc Send(SendRequest) returns (SendResponse);
rpc Get(GetRequest) returns (GetResponse);
}
message SendRequest {
string idempotency_key = 1; // Required; scoped to authenticated caller, retained seven days.
string user_id = 2; // Caller resolves and authorizes recipient ownership.
string template_id = 3;
string template_version = 4;
map<string, string> parameters = 5;
string locale = 6;
repeated Target targets = 7; // V1 accepts exactly one email target.
google.protobuf.Timestamp expires_at = 8; // Default/max 24h from acceptance.
}
message Target {
oneof destination {
EmailTarget email = 1;
SmsTarget sms = 2;
PushTarget push = 3;
TelegramTarget telegram = 4;
}
}
message EmailTarget { string address = 1; }
message SmsTarget { string phone_number = 1; }
message PushTarget { string endpoint_id = 1; }
message TelegramTarget { string chat_id = 1; }
message SendResponse { Notification notification = 1; }
message GetRequest { string id = 1; }
message GetResponse { Notification notification = 1; }
message Notification {
string id = 1;
string user_id = 2;
string template_id = 3;
string template_version = 4;
repeated Delivery deliveries = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
google.protobuf.Timestamp expires_at = 8;
}
message Delivery {
string id = 1;
Channel channel = 2;
DeliveryState state = 3;
uint32 attempt_count = 4;
Failure failure = 5;
google.protobuf.Timestamp next_attempt_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
message Failure { string code = 1; string message = 2; }
enum Channel {
CHANNEL_UNSPECIFIED = 0;
CHANNEL_EMAIL = 1;
CHANNEL_SMS = 2;
CHANNEL_PUSH = 3;
CHANNEL_TELEGRAM = 4;
}
enum DeliveryState {
DELIVERY_STATE_UNSPECIFIED = 0;
DELIVERY_STATE_QUEUED = 1;
DELIVERY_STATE_SENDING = 2;
DELIVERY_STATE_RETRY_WAIT = 3;
DELIVERY_STATE_ACCEPTED = 4;
DELIVERY_STATE_DELIVERED = 5; // Reserved for future verified receipts.
DELIVERY_STATE_FAILED = 6;
DELIVERY_STATE_EXPIRED = 7;
DELIVERY_STATE_UNKNOWN = 8; // May have sent; no automatic replay.
}
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"context"
"flag"
"os"
"os/signal"
"syscall"
"git.sendico.io/tech/sxchnge/notifications/app"
"go.uber.org/zap"
)
func main() {
path := flag.String("config.file", "config.yml", "notification configuration")
flag.Parse()
logger, err := zap.NewProduction()
if err != nil {
os.Exit(1)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
config, err := app.Load(*path)
if err == nil {
err = app.Run(ctx, config, logger)
}
if err != nil {
logger.Error("Notifications service terminated", zap.Error(err))
_ = logger.Sync()
os.Exit(1)
}
_ = logger.Sync()
}
+58
View File
@@ -0,0 +1,58 @@
package model
import (
"git.sendico.io/tech/sxchnge/shared/database/record"
"time"
)
type State string
const (
Queued State = "queued"
Sending State = "sending"
RetryWait State = "retry_wait"
Accepted State = "accepted"
Failed State = "failed"
Expired State = "expired"
Unknown State = "unknown"
)
type Request struct {
Key string
UserID string
TemplateID string
TemplateVersion string
Parameters map[string]string
Locale string
Address string
ExpiresAt time.Time
}
// Email is a rendered immutable snapshot, independent of provider wire formats.
type Email struct {
Address string
Subject string
HTML string
Text string
}
type Notification struct {
storable.Base `bson:",inline"`
Caller string `bson:"caller"`
Key string `bson:"key"`
Fingerprint string `bson:"fingerprint"`
UserID string `bson:"user_id"`
TemplateID string `bson:"template_id"`
TemplateVersion string `bson:"template_version"`
Email Email `bson:"email"`
Driver string `bson:"driver"`
State State `bson:"state"`
Attempts uint32 `bson:"attempts"`
FailureCode string `bson:"failure_code"`
ExpiresAt time.Time `bson:"expires_at"`
NextAttemptAt time.Time `bson:"next_attempt_at"`
LeaseUntil time.Time `bson:"lease_until"`
PurgeAt time.Time `bson:"purge_at"`
}
func (*Notification) Collection() string { return "notifications" }
@@ -0,0 +1,209 @@
package service_test
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"git.sendico.io/tech/sxchnge/notifications/bus"
"git.sendico.io/tech/sxchnge/notifications/email"
"git.sendico.io/tech/sxchnge/notifications/model"
"git.sendico.io/tech/sxchnge/notifications/service"
"git.sendico.io/tech/sxchnge/notifications/store"
"git.sendico.io/tech/sxchnge/notifications/templates"
db "git.sendico.io/tech/sxchnge/shared/database"
"git.sendico.io/tech/sxchnge/shared/database/repository"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/messaging"
msgruntime "git.sendico.io/tech/sxchnge/shared/messaging/runtime"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.uber.org/zap"
)
type sender struct {
calls atomic.Int32
state model.State
}
func (s *sender) Send(context.Context, string, model.Email) email.Outcome {
s.calls.Add(1)
return email.Outcome{State: s.state, Code: "test", RetryAfter: time.Millisecond}
}
func TestDurableDelivery(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:8.0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = container.Terminate(context.Background()) }()
uri, err := container.ConnectionString(ctx)
if err != nil {
t.Fatal(err)
}
conn, err := db.ConnectMongo(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: map[string]any{"uri": uri, "database": "notifications_test"}})
if err != nil {
t.Fatal(err)
}
defer conn.Disconnect(context.Background())
repo, err := store.New(zap.NewNop(), conn.Database())
if err != nil {
t.Fatal(err)
}
catalog, err := templates.New([]templates.Definition{{ID: "test", Version: "1", DefaultLocale: "en", Parameters: []string{"name"}, Locales: map[string]templates.Content{"en": {Subject: "Hello", HTML: "<p>{{.name}}</p>"}}}})
if err != nil {
t.Fatal(err)
}
driver := &sender{state: model.Accepted}
svc := service.New(repo, catalog, driver, "resend", zap.NewNop())
request := model.Request{Key: "duplicate", UserID: "u", TemplateID: "test", TemplateVersion: "1", Parameters: map[string]string{"name": "A & B"}, Address: "user@example.com"}
var wg sync.WaitGroup
ids := make(chan string, 10)
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
n, e := svc.Send(ctx, "product", request)
if e != nil {
t.Error(e)
return
}
ids <- n.ID.Hex()
}()
}
wg.Wait()
close(ids)
id := ""
for value := range ids {
if id != "" && value != id {
t.Error("idempotency race")
}
id = value
}
if id == "" {
t.Fatal("no notification")
}
request.Parameters = map[string]string{"name": "Changed"}
if _, err = svc.Send(ctx, "product", request); !errors.Is(err, merrors.ErrDataConflict) {
t.Fatal("missing conflict", err)
}
if _, err = svc.Get(ctx, "other", id); !errors.Is(err, merrors.ErrNoData) {
t.Fatal("caller isolation failed", err)
}
stored, err := svc.Get(ctx, "product", id)
if err != nil || stored.Email.HTML != "<p>A &amp; B</p>" {
t.Fatal(stored, err)
}
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
if _, e := svc.Dispatch(ctx, id); e != nil {
t.Error(e)
}
}()
}
wg.Wait()
if driver.calls.Load() != 1 {
t.Fatalf("sent %d times", driver.calls.Load())
}
stored, err = svc.Get(ctx, "product", id)
if err != nil || stored.State != model.Accepted || stored.Email.Address != "" {
t.Fatal("completion/scrubbing failed", stored, err)
}
// A stale sending lease is never automatically resent.
request.Key = "stale"
stale, err := svc.Send(ctx, "product", request)
if err != nil {
t.Fatal(err)
}
old := time.Now().Add(-2 * time.Minute)
if err = repo.DBImp.Patch(ctx, stale.ID, repository.Patch().Set(repository.Field("next_attempt_at"), old)); err != nil {
t.Fatal(err)
}
claimed, err := repo.Claim(ctx, stale.ID.Hex(), old)
if err != nil {
t.Fatal(err)
}
if err = repo.Recover(ctx, time.Now()); err != nil {
t.Fatal(err)
}
claimed.State = model.Accepted
if err = repo.Finish(ctx, claimed); !errors.Is(err, merrors.ErrDataConflict) {
t.Fatal("stale worker overwrote recovery", err)
}
stale, err = svc.Get(ctx, "product", stale.ID.Hex())
if err != nil || stale.State != model.Unknown {
t.Fatal(stale, err)
}
// Retry state persists, and a duplicate dispatch cannot bypass the due time.
request.Key = "retry"
retry, err := svc.Send(ctx, "product", request)
if err != nil {
t.Fatal(err)
}
retryDriver := &sender{state: model.RetryWait}
retrySvc := service.New(repo, catalog, retryDriver, "resend", zap.NewNop())
if _, err = retrySvc.Dispatch(ctx, retry.ID.Hex()); err != nil {
t.Fatal(err)
}
if _, err = retrySvc.Dispatch(ctx, retry.ID.Hex()); err != nil {
t.Fatal(err)
}
retry, err = svc.Get(ctx, "product", retry.ID.Hex())
if err != nil || retry.State != model.RetryWait || retryDriver.calls.Load() != 1 {
t.Fatal(retry, err)
}
// Verify the real JetStream publisher/consumer path with an isolated broker.
ncContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: testcontainers.ContainerRequest{Image: "nats:2.11-alpine", ExposedPorts: []string{"4222/tcp"}, Cmd: []string{"-js"}, WaitingFor: wait.ForListeningPort("4222/tcp")}, Started: true})
if err != nil {
t.Fatal(err)
}
defer ncContainer.Terminate(context.Background())
endpoint, err := ncContainer.Endpoint(ctx, "")
if err != nil {
t.Fatal(err)
}
t.Setenv("TEST_NOTIFICATIONS_NATS", "nats://"+endpoint)
rt, err := msgruntime.New(zap.NewNop(), &messaging.Config{Driver: messaging.BBNats, Settings: map[string]any{"url_env": "TEST_NOTIFICATIONS_NATS", "broker_name": "test", "max_reconnects": -1, "reconnect_wait": 1}})
if err != nil {
t.Fatal(err)
}
defer rt.Close()
broker, err := bus.New(ctx, rt.Durable, rt.DurableAdmin, repo, svc, zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer broker.Close()
request.Key = "jetstream"
job, err := svc.Send(ctx, "product", request)
if err != nil {
t.Fatal(err)
}
if err = broker.PublishDue(ctx); err != nil {
t.Fatal(err)
}
workerCtx, stop := context.WithCancel(ctx)
done := make(chan struct{})
go func() { broker.RunWorker(workerCtx); close(done) }()
defer func() { stop(); <-done }()
deadline := time.Now().Add(10 * time.Second)
for {
job, err = svc.Get(ctx, "product", job.ID.Hex())
if err != nil {
t.Fatal(err)
}
if job.State == model.Accepted {
break
}
if time.Now().After(deadline) {
t.Fatal("JetStream job did not complete", job.State)
}
time.Sleep(20 * time.Millisecond)
}
}
+109
View File
@@ -0,0 +1,109 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
"git.sendico.io/tech/sxchnge/notifications/email"
"git.sendico.io/tech/sxchnge/notifications/model"
"git.sendico.io/tech/sxchnge/notifications/templates"
"git.sendico.io/tech/sxchnge/shared/database/record"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type Repository interface {
Find(context.Context, string, string) (*model.Notification, error)
Get(context.Context, string, string) (*model.Notification, error)
Create(context.Context, *model.Notification) (*model.Notification, error)
Claim(context.Context, string, time.Time) (*model.Notification, error)
Finish(context.Context, *model.Notification) error
Recover(context.Context, time.Time) error
}
type Service struct {
repo Repository
catalog *templates.Catalog
sender email.Sender
driver string
logger mlogger.Logger
}
func New(repo Repository, catalog *templates.Catalog, sender email.Sender, driver string, logger mlogger.Logger) *Service {
return &Service{repo, catalog, sender, driver, mlogger.Named(logger, "notifications")}
}
func (s *Service) Send(ctx context.Context, caller string, r model.Request) (*model.Notification, error) {
if caller == "" {
return nil, merrors.Unauthorized("missing caller")
}
if r.Key == "" || len(r.Key) > 128 || r.UserID == "" || len(r.UserID) > 128 || !email.ValidAddress(r.Address) || len(r.TemplateID) > 128 || len(r.TemplateVersion) > 64 || len(r.Locale) > 64 || strings.ContainsRune(r.TemplateID+r.TemplateVersion, 0) {
return nil, merrors.InvalidArgument("invalid notification request")
}
if len(r.Parameters) > 64 {
return nil, merrors.InvalidArgument("too many parameters")
}
for k, v := range r.Parameters {
if k == "" || len(k) > 64 || len(v) > 4096 {
return nil, merrors.InvalidArgument("invalid template parameter")
}
}
payload, err := json.Marshal(r)
if err != nil {
return nil, merrors.InvalidArgument("invalid request")
}
if len(payload) > 64<<10 {
return nil, merrors.InvalidArgument("request too large")
}
sum := sha256.Sum256(payload)
fingerprint := hex.EncodeToString(sum[:])
existing, err := s.repo.Find(ctx, caller, r.Key)
if err == nil {
return same(existing, fingerprint)
}
if !errors.Is(err, merrors.ErrNoData) {
return nil, err
}
now := time.Now().UTC()
expiry := r.ExpiresAt
if expiry.IsZero() {
expiry = now.Add(24 * time.Hour)
}
if !expiry.After(now) || expiry.After(now.Add(24*time.Hour)) {
return nil, merrors.InvalidArgument("expiry must be within the next 24 hours")
}
rendered, err := s.catalog.Render(r)
if err != nil {
return nil, err
}
n := &model.Notification{Base: storable.Base{ID: bson.NewObjectID(), CreatedAt: now, UpdatedAt: now}, Caller: caller, Key: r.Key, Fingerprint: fingerprint, UserID: r.UserID, TemplateID: r.TemplateID, TemplateVersion: r.TemplateVersion, Email: rendered, Driver: s.driver, State: model.Queued, ExpiresAt: expiry, NextAttemptAt: now, PurgeAt: now.Add(7 * 24 * time.Hour)}
stored, err := s.repo.Create(ctx, n)
if err != nil {
return nil, err
}
result, err := same(stored, fingerprint)
if err == nil {
s.logger.Info("Notification accepted", zap.String("notification_id", stored.ID.Hex()), zap.String("caller", caller))
}
return result, err
}
func same(n *model.Notification, fingerprint string) (*model.Notification, error) {
if n.Fingerprint != fingerprint {
return nil, merrors.DataConflict("idempotency key already used with a different request")
}
return n, nil
}
func (s *Service) Get(ctx context.Context, caller, id string) (*model.Notification, error) {
if caller == "" {
return nil, merrors.Unauthorized("missing caller")
}
if _, err := bson.ObjectIDFromHex(id); err != nil {
return nil, merrors.InvalidArgument("invalid notification ID")
}
return s.repo.Get(ctx, caller, id)
}
+75
View File
@@ -0,0 +1,75 @@
package service
import (
"context"
"errors"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.uber.org/zap"
)
// Dispatch consumes an opaque job ID from JetStream. Duplicate messages are safe.
func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
now := time.Now().UTC()
if err := s.repo.Recover(ctx, now); err != nil {
return false, err
}
n, err := s.repo.Claim(ctx, id, now)
if errors.Is(err, merrors.ErrNoData) {
return false, nil
}
if err != nil {
return false, err
}
switch {
case !n.ExpiresAt.After(now):
n.State = model.Expired
n.FailureCode = "expired"
case n.Driver != s.driver:
n.State = model.Failed
n.FailureCode = "driver_changed"
default:
deadline := now.Add(20 * time.Second)
if n.ExpiresAt.Before(deadline) {
deadline = n.ExpiresAt
}
sendCtx, cancel := context.WithDeadline(ctx, deadline)
outcome := s.sender.Send(sendCtx, n.ID.Hex(), n.Email)
cancel()
n.State = outcome.State
n.FailureCode = outcome.Code
if n.State == model.RetryWait {
delay := time.Duration(1<<min(n.Attempts, 8)) * time.Second
if outcome.RetryAfter > delay {
delay = outcome.RetryAfter
}
n.NextAttemptAt = time.Now().UTC().Add(delay)
if n.Attempts >= 5 {
n.State = model.Failed
n.FailureCode = "retry_exhausted"
} else if !n.NextAttemptAt.Before(n.ExpiresAt) {
n.State = model.Expired
n.FailureCode = "expired"
}
} else if n.State != model.Accepted && n.State != model.Failed && n.State != model.Unknown {
n.State = model.Unknown
n.FailureCode = "invalid_driver_outcome"
}
}
n.UpdatedAt = time.Now().UTC()
// Persist the result even when shutdown canceled the provider request.
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
if err = s.repo.Finish(finishCtx, n); err != nil {
return true, err
}
fields := []zap.Field{zap.String("notification_id", n.ID.Hex()), zap.String("driver", n.Driver), zap.String("state", string(n.State)), zap.Uint32("attempts", n.Attempts), zap.String("failure_code", n.FailureCode)}
if n.State == model.Accepted {
s.logger.Info("Email accepted by provider", fields...)
} else {
s.logger.Warn("Email delivery attempt finished", fields...)
}
return true, nil
}
@@ -0,0 +1,64 @@
package service
import (
"context"
"testing"
"time"
"git.sendico.io/tech/sxchnge/notifications/email"
"git.sendico.io/tech/sxchnge/notifications/model"
"go.uber.org/zap"
)
type workerRepo struct {
Repository
job model.Notification
saved *model.Notification
}
func (r *workerRepo) Recover(context.Context, time.Time) error { return nil }
func (r *workerRepo) Claim(context.Context, string, time.Time) (*model.Notification, error) {
n := r.job
return &n, nil
}
func (r *workerRepo) Finish(_ context.Context, n *model.Notification) error { r.saved = n; return nil }
type workerSender struct {
calls int
outcome email.Outcome
}
func (s *workerSender) Send(context.Context, string, model.Email) email.Outcome {
s.calls++
return s.outcome
}
func TestWorkerTerminalAndRetryOutcomes(t *testing.T) {
for _, tc := range []struct {
name string
expiry time.Time
driver string
attempts uint32
outcome model.State
want model.State
calls int
}{
{"expired", time.Now().Add(-time.Minute), "resend", 1, model.Accepted, model.Expired, 0},
{"driver changed", time.Now().Add(time.Hour), "sendgrid", 1, model.Accepted, model.Failed, 0},
{"retry exhausted", time.Now().Add(time.Hour), "resend", 5, model.RetryWait, model.Failed, 1},
{"retry expiry", time.Now().Add(time.Second), "resend", 1, model.RetryWait, model.Expired, 1},
{"ambiguous", time.Now().Add(time.Hour), "resend", 1, model.Unknown, model.Unknown, 1},
{"invalid driver outcome", time.Now().Add(time.Hour), "resend", 1, model.Queued, model.Unknown, 1},
} {
t.Run(tc.name, func(t *testing.T) {
repo := &workerRepo{job: model.Notification{Driver: tc.driver, ExpiresAt: tc.expiry, Attempts: tc.attempts}}
sender := &workerSender{outcome: email.Outcome{State: tc.outcome}}
svc := New(repo, nil, sender, "resend", zap.NewNop())
if _, err := svc.Dispatch(context.Background(), "id"); err != nil {
t.Fatal(err)
}
if repo.saved == nil || repo.saved.State != tc.want || sender.calls != tc.calls {
t.Fatal(repo.saved, sender.calls)
}
})
}
}
+118
View File
@@ -0,0 +1,118 @@
package store
import (
"context"
"errors"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/template"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
type Store struct {
template.DBImp[*model.Notification]
}
func New(logger mlogger.Logger, db *mongo.Database) (*Store, error) {
s := &Store{*template.Create[*model.Notification](mlogger.Named(logger, "store"), "notifications", db)}
ttl := int32(0)
for _, index := range []*ri.Definition{
{Name: "unique_caller_key", Keys: []ri.Key{{Field: "caller", Sort: ri.Asc}, {Field: "key", Sort: ri.Asc}}, Unique: true},
{Name: "pending_dispatch", Keys: []ri.Key{{Field: "state", Sort: ri.Asc}, {Field: "next_attempt_at", Sort: ri.Asc}}},
{Name: "expired_lease", Keys: []ri.Key{{Field: "state", Sort: ri.Asc}, {Field: "lease_until", Sort: ri.Asc}}},
{Name: "purge_notifications", Keys: []ri.Key{{Field: "purge_at", Sort: ri.Asc}}, TTL: &ttl},
} {
if err := s.Repository.CreateIndex(index); err != nil {
return nil, wrap(err)
}
}
return s, nil
}
func wrap(err error) error {
if err == nil {
return nil
}
if errors.Is(err, merrors.ErrNoData) || errors.Is(err, merrors.ErrDataConflict) {
return err
}
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "notification storage unavailable")
}
func (s *Store) Find(ctx context.Context, caller, key string) (*model.Notification, error) {
var n model.Notification
err := s.FindOne(ctx, repository.Query().And(repository.Filter("caller", caller), repository.Filter("key", key)), &n)
return &n, wrap(err)
}
func (s *Store) Get(ctx context.Context, caller, id string) (*model.Notification, error) {
oid, err := bson.ObjectIDFromHex(id)
if err != nil {
return nil, merrors.InvalidArgument("invalid notification ID")
}
var n model.Notification
err = s.FindOne(ctx, repository.Query().And(repository.Filter("caller", caller), repository.IDFilter(oid)), &n)
return &n, wrap(err)
}
func (s *Store) Create(ctx context.Context, n *model.Notification) (*model.Notification, error) {
err := s.DBImp.Create(ctx, n)
if errors.Is(err, merrors.ErrDataConflict) {
return s.Find(ctx, n.Caller, n.Key)
}
return n, wrap(err)
}
func pending(now time.Time) builder.Query {
return repository.Query().And(repository.Query().In(repository.Field("state"), model.Queued, model.RetryWait), repository.Query().Comparison(repository.Field("next_attempt_at"), builder.Lte, now))
}
// A lost worker may already have sent its message. Do not automatically replay it.
func (s *Store) Recover(ctx context.Context, now time.Time) error {
query := repository.Query().And(repository.Filter("state", model.Sending), repository.Query().Comparison(repository.Field("lease_until"), builder.Lte, now))
patch := repository.Patch().Set(repository.Field("state"), model.Unknown).Set(repository.Field("failure_code"), "worker_outcome_unknown").Set(repository.Field("updatedAt"), now).Set(repository.Field("email"), model.Email{})
_, err := s.PatchMany(ctx, query, patch)
return wrap(err)
}
func (s *Store) Claim(ctx context.Context, id string, now time.Time) (*model.Notification, error) {
oid, err := bson.ObjectIDFromHex(id)
if err != nil {
return nil, merrors.NoData("invalid job ID")
}
var n model.Notification
query := repository.Query().And(repository.IDFilter(oid), pending(now))
patch := repository.Patch().Set(repository.Field("state"), model.Sending).Set(repository.Field("updatedAt"), now).Set(repository.Field("lease_until"), now.Add(time.Minute)).Inc(repository.Field("attempts"), 1)
matched, err := s.Repository.PatchOneByFilter(ctx, query, patch, &n)
if err != nil {
return nil, wrap(err)
}
if !matched {
return nil, merrors.NoData("job is not pending")
}
return &n, nil
}
func (s *Store) Finish(ctx context.Context, n *model.Notification) error {
query := repository.Query().And(repository.IDFilter(n.ID), repository.Filter("state", model.Sending), repository.Filter("attempts", n.Attempts), repository.Filter("lease_until", n.LeaseUntil))
patch := repository.Patch().Set(repository.Field("state"), n.State).Set(repository.Field("failure_code"), n.FailureCode).Set(repository.Field("updatedAt"), n.UpdatedAt).Set(repository.Field("next_attempt_at"), n.NextAttemptAt)
if n.State != model.RetryWait {
patch.Set(repository.Field("email"), model.Email{})
}
count, err := s.PatchMany(ctx, query, patch)
if err != nil {
return wrap(err)
}
if count != 1 {
return merrors.DataConflict("delivery lease lost")
}
return nil
}
// Acceptance and outbox work are one document, so no dual-write transaction is needed.
func (s *Store) Due(ctx context.Context, now time.Time) ([]model.Notification, error) {
limit := int64(100)
var jobs []model.Notification
err := s.Repository.FindAllByFilter(ctx, pending(now).Sort(repository.Field("next_attempt_at"), true).Limit(&limit), &jobs)
return jobs, wrap(err)
}
@@ -0,0 +1,15 @@
[
{
"id": "exchange.completed",
"version": "1",
"default_locale": "en",
"parameters": ["first_name", "amount", "currency"],
"locales": {
"en": {
"subject": "Your exchange is complete",
"html": "<p>Hi {{.first_name}}, your exchange of {{.amount}} {{.currency}} is complete.</p>",
"text": "Hi {{.first_name}}, your exchange of {{.amount}} {{.currency}} is complete."
}
}
}
]
+146
View File
@@ -0,0 +1,146 @@
package templates
import (
"bytes"
"encoding/json"
html "html/template"
"io"
"os"
"strings"
text "text/template"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
)
type Content struct {
Subject string `json:"subject"`
HTML string `json:"html"`
Text string `json:"text"`
}
type Definition struct {
ID string `json:"id"`
Version string `json:"version"`
DefaultLocale string `json:"default_locale"`
Parameters []string `json:"parameters"`
Locales map[string]Content `json:"locales"`
}
type compiled struct {
definition Definition
locales map[string]renderers
}
type renderers struct {
subject, text *text.Template
html *html.Template
}
type Catalog struct{ entries map[string]compiled }
func Load(path string) (*Catalog, error) {
f, err := os.Open(path)
if err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "cannot open template catalog")
}
defer f.Close()
var definitions []Definition
dec := json.NewDecoder(io.LimitReader(f, 4<<20))
dec.DisallowUnknownFields()
if err = dec.Decode(&definitions); err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid template catalog")
}
if err = dec.Decode(new(any)); err != io.EOF {
return nil, merrors.InvalidArgument("unexpected template catalog content")
}
return New(definitions)
}
func New(definitions []Definition) (*Catalog, error) {
c := &Catalog{entries: map[string]compiled{}}
if len(definitions) == 0 {
return nil, merrors.InvalidArgument("empty template catalog")
}
for _, d := range definitions {
key := d.ID + "\x00" + d.Version
if d.ID == "" || d.Version == "" || strings.ContainsRune(d.ID+d.Version, 0) {
return nil, merrors.InvalidArgument("invalid template identity")
}
if _, ok := c.entries[key]; ok {
return nil, merrors.InvalidArgument("duplicate template revision")
}
if _, ok := d.Locales[d.DefaultLocale]; !ok || d.DefaultLocale == "" {
return nil, merrors.InvalidArgument("missing default locale")
}
params := map[string]string{}
for _, p := range d.Parameters {
if p == "" {
return nil, merrors.InvalidArgument("empty parameter")
}
if _, ok := params[p]; ok {
return nil, merrors.InvalidArgument("duplicate parameter")
}
params[p] = "sample"
}
entry := compiled{definition: d, locales: map[string]renderers{}}
for locale, content := range d.Locales {
s, err := text.New("subject").Option("missingkey=error").Parse(content.Subject)
if err != nil {
return nil, merrors.InvalidArgument("invalid subject template")
}
t, err := text.New("text").Option("missingkey=error").Parse(content.Text)
if err != nil {
return nil, merrors.InvalidArgument("invalid text template")
}
h, err := html.New("html").Option("missingkey=error").Parse(content.HTML)
if err != nil {
return nil, merrors.InvalidArgument("invalid HTML template")
}
entry.locales[locale] = renderers{s, t, h}
}
c.entries[key] = entry
for locale := range d.Locales {
if _, err := c.Render(model.Request{TemplateID: d.ID, TemplateVersion: d.Version, Locale: locale, Parameters: params}); err != nil {
return nil, err
}
}
}
return c, nil
}
type boundedBuffer struct{ bytes.Buffer }
func (b *boundedBuffer) Write(p []byte) (int, error) {
if b.Len()+len(p) > 256<<10 {
return 0, merrors.InvalidArgument("rendered content too large")
}
return b.Buffer.Write(p)
}
func (c *Catalog) Render(r model.Request) (model.Email, error) {
entry, ok := c.entries[r.TemplateID+"\x00"+r.TemplateVersion]
if !ok {
return model.Email{}, merrors.NoData("template revision not found")
}
if len(r.Parameters) != len(entry.definition.Parameters) {
return model.Email{}, merrors.InvalidArgument("template parameters do not match")
}
for _, p := range entry.definition.Parameters {
if _, ok := r.Parameters[p]; !ok {
return model.Email{}, merrors.InvalidArgument("missing template parameter")
}
}
locale := r.Locale
if locale == "" {
locale = entry.definition.DefaultLocale
}
render, ok := entry.locales[locale]
if !ok {
return model.Email{}, merrors.InvalidArgument("unsupported template locale")
}
var subject, body, plain boundedBuffer
for _, run := range []func() error{func() error { return render.subject.Execute(&subject, r.Parameters) }, func() error { return render.html.Execute(&body, r.Parameters) }, func() error { return render.text.Execute(&plain, r.Parameters) }} {
if err := run(); err != nil {
return model.Email{}, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "template rendering failed")
}
}
if subject.Len() == 0 || subject.Len() > 998 || strings.ContainsAny(subject.String(), "\r\n") || (body.Len() == 0 && plain.Len() == 0) {
return model.Email{}, merrors.InvalidArgument("invalid rendered email")
}
return model.Email{Address: r.Address, Subject: subject.String(), HTML: body.String(), Text: plain.String()}, nil
}
@@ -0,0 +1,42 @@
package templates
import (
"strings"
"testing"
"git.sendico.io/tech/sxchnge/notifications/model"
)
func TestRenderAndValidate(t *testing.T) {
c, err := New([]Definition{{ID: "test", Version: "1", DefaultLocale: "en", Parameters: []string{"name"}, Locales: map[string]Content{"en": {Subject: "Hello {{.name}}", HTML: "<p>{{.name}}</p>", Text: "{{.name}}"}}}})
if err != nil {
t.Fatal(err)
}
r := model.Request{TemplateID: "test", TemplateVersion: "1", Parameters: map[string]string{"name": "<script>{{.other}}</script>"}}
result, err := c.Render(r)
if err != nil {
t.Fatal(err)
}
if strings.Contains(result.HTML, "<script>") || !strings.Contains(result.HTML, "{{.other}}") || result.Text != r.Parameters["name"] {
t.Fatalf("unsafe substitution: %+v", result)
}
for _, params := range []map[string]string{{}, {"name": "a", "extra": "b"}, {"wrong": "a"}, {"name": "bad\r\nsubject"}} {
r.Parameters = params
if _, err := c.Render(r); err == nil {
t.Fatal("invalid parameters accepted")
}
}
r.Parameters = map[string]string{"name": "valid"}
r.Locale = "unknown"
if _, err := c.Render(r); err == nil {
t.Fatal("unknown locale accepted")
}
}
func TestRejectBrokenCatalog(t *testing.T) {
for _, content := range []Content{{Subject: "Hello", HTML: "{{.undeclared}}"}, {Subject: "Hello", HTML: "{{"}, {HTML: "body"}} {
_, err := New([]Definition{{ID: "test", Version: "1", DefaultLocale: "en", Locales: map[string]Content{"en": content}}})
if err == nil {
t.Fatal("invalid template accepted")
}
}
}
+46
View File
@@ -0,0 +1,46 @@
package transport
import (
"time"
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"google.golang.org/protobuf/types/known/timestamppb"
)
func request(r *pb.SendRequest) (model.Request, error) {
if r == nil || len(r.Targets) != 1 {
return model.Request{}, merrors.InvalidArgument("exactly one email target is required")
}
target := r.Targets[0]
if target == nil || target.Destination == nil {
return model.Request{}, merrors.InvalidArgument("missing destination")
}
email, ok := target.Destination.(*pb.Target_Email)
if !ok {
return model.Request{}, merrors.NotImplemented("channel is not implemented")
}
if email.Email == nil {
return model.Request{}, merrors.InvalidArgument("missing email target")
}
var expiry time.Time
if r.ExpiresAt != nil {
if err := r.ExpiresAt.CheckValid(); err != nil {
return model.Request{}, merrors.InvalidArgument("invalid expiry")
}
expiry = r.ExpiresAt.AsTime()
}
return model.Request{Key: r.IdempotencyKey, UserID: r.UserId, TemplateID: r.TemplateId, TemplateVersion: r.TemplateVersion, Parameters: r.Parameters, Locale: r.Locale, Address: email.Email.Address, ExpiresAt: expiry}, nil
}
func response(n *model.Notification) *pb.Notification {
states := map[model.State]pb.DeliveryState{model.Queued: pb.DeliveryState_DELIVERY_STATE_QUEUED, model.Sending: pb.DeliveryState_DELIVERY_STATE_SENDING, model.RetryWait: pb.DeliveryState_DELIVERY_STATE_RETRY_WAIT, model.Accepted: pb.DeliveryState_DELIVERY_STATE_ACCEPTED, model.Failed: pb.DeliveryState_DELIVERY_STATE_FAILED, model.Expired: pb.DeliveryState_DELIVERY_STATE_EXPIRED, model.Unknown: pb.DeliveryState_DELIVERY_STATE_UNKNOWN}
d := &pb.Delivery{Id: n.ID.Hex() + "-email", Channel: pb.Channel_CHANNEL_EMAIL, State: states[n.State], AttemptCount: n.Attempts, UpdatedAt: timestamppb.New(n.UpdatedAt)}
if n.FailureCode != "" {
d.Failure = &pb.Failure{Code: n.FailureCode, Message: "See the documented delivery failure code."}
}
if n.State == model.Queued || n.State == model.RetryWait {
d.NextAttemptAt = timestamppb.New(n.NextAttemptAt)
}
return &pb.Notification{Id: n.ID.Hex(), UserId: n.UserID, TemplateId: n.TemplateID, TemplateVersion: n.TemplateVersion, Deliveries: []*pb.Delivery{d}, CreatedAt: timestamppb.New(n.CreatedAt), UpdatedAt: timestamppb.New(n.UpdatedAt), ExpiresAt: timestamppb.New(n.ExpiresAt)}
}
+92
View File
@@ -0,0 +1,92 @@
package transport
import (
"context"
"crypto/sha256"
"crypto/subtle"
"errors"
"strings"
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
type callerKey struct{}
type API interface {
Send(context.Context, string, model.Request) (*model.Notification, error)
Get(context.Context, string, string) (*model.Notification, error)
}
type Server struct {
pb.UnimplementedNotificationsServiceServer
service API
}
func New(service API) *Server { return &Server{service: service} }
func (s *Server) Send(ctx context.Context, r *pb.SendRequest) (*pb.SendResponse, error) {
input, err := request(r)
if err != nil {
return nil, rpcError(err)
}
caller, _ := ctx.Value(callerKey{}).(string)
n, err := s.service.Send(ctx, caller, input)
if err != nil {
return nil, rpcError(err)
}
return &pb.SendResponse{Notification: response(n)}, nil
}
func (s *Server) Get(ctx context.Context, r *pb.GetRequest) (*pb.GetResponse, error) {
if r == nil {
return nil, status.Error(codes.InvalidArgument, "missing request")
}
caller, _ := ctx.Value(callerKey{}).(string)
n, err := s.service.Get(ctx, caller, r.Id)
if err != nil {
return nil, rpcError(err)
}
return &pb.GetResponse{Notification: response(n)}, nil
}
// Tokens are mapped to stable server-configured caller identities, never supplied IDs.
func Authenticate(tokens map[string]string) grpc.UnaryServerInterceptor {
hashes := map[string][32]byte{}
for caller, token := range tokens {
hashes[caller] = sha256.Sum256([]byte(token))
}
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
md, _ := metadata.FromIncomingContext(ctx)
values := md.Get("authorization")
if len(values) != 1 || !strings.HasPrefix(values[0], "Bearer ") {
return nil, status.Error(codes.Unauthenticated, "invalid service credentials")
}
hash := sha256.Sum256([]byte(strings.TrimPrefix(values[0], "Bearer ")))
identity := ""
for caller, expected := range hashes {
if subtle.ConstantTimeCompare(hash[:], expected[:]) == 1 {
identity = caller
}
}
if identity == "" {
return nil, status.Error(codes.Unauthenticated, "invalid service credentials")
}
return handler(context.WithValue(ctx, callerKey{}, identity), req)
}
}
func rpcError(err error) error {
for _, pair := range []struct {
err error
code codes.Code
message string
}{
{merrors.ErrInvalidArg, codes.InvalidArgument, "invalid notification request"}, {merrors.ErrNoData, codes.NotFound, "notification or template not found"}, {merrors.ErrDataConflict, codes.AlreadyExists, "idempotency key already used"}, {merrors.ErrUnauthorized, codes.Unauthenticated, "missing service credentials"}, {merrors.ErrAccessDenied, codes.PermissionDenied, "access denied"}, {merrors.ErrNotImplemented, codes.Unimplemented, "channel is not implemented"}, {merrors.ErrServiceUnavailable, codes.Unavailable, "notification storage unavailable"},
} {
if errors.Is(err, pair.err) {
return status.Error(pair.code, pair.message)
}
}
return status.Error(codes.Internal, "notification operation failed")
}
@@ -0,0 +1,66 @@
package transport
import (
"context"
"net"
"strings"
"testing"
"time"
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
"git.sendico.io/tech/sxchnge/notifications/model"
"git.sendico.io/tech/sxchnge/shared/database/record"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
)
type fakeAPI struct{}
func (fakeAPI) Send(_ context.Context, caller string, r model.Request) (*model.Notification, error) {
if caller != "product" {
return nil, merrors.Unauthorized("missing caller")
}
return &model.Notification{Base: storable.Base{ID: bson.NewObjectID(), CreatedAt: time.Now(), UpdatedAt: time.Now()}, UserID: r.UserID, State: model.Queued, Email: model.Email{Address: r.Address, Text: "sensitive"}, ExpiresAt: time.Now().Add(time.Hour)}, nil
}
func (fakeAPI) Get(context.Context, string, string) (*model.Notification, error) {
return nil, merrors.NoData("private details")
}
func TestGRPCAuthenticationAndMapping(t *testing.T) {
listener := bufconn.Listen(1 << 20)
token := strings.Repeat("a", 32)
server := grpc.NewServer(grpc.UnaryInterceptor(Authenticate(map[string]string{"product": token})))
pb.RegisterNotificationsServiceServer(server, New(fakeAPI{}))
go func() { _ = server.Serve(listener) }()
defer server.Stop()
conn, err := grpc.NewClient("passthrough:///buf", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
client := pb.NewNotificationsServiceClient(conn)
req := &pb.SendRequest{UserId: "u", Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}}
if _, err = client.Send(context.Background(), req); status.Code(err) != codes.Unauthenticated {
t.Fatal(err)
}
ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("authorization", "Bearer "+token))
result, err := client.Send(ctx, req)
if err != nil {
t.Fatal(err)
}
if result.Notification.UserId != "u" || strings.Contains(result.String(), "sensitive") || strings.Contains(result.String(), "user@example.com") {
t.Fatal(result)
}
req.Targets[0].Destination = &pb.Target_Telegram{Telegram: &pb.TelegramTarget{ChatId: "123"}}
if _, err = client.Send(ctx, req); status.Code(err) != codes.Unimplemented {
t.Fatal(err)
}
if _, err = client.Get(ctx, &pb.GetRequest{Id: "id"}); status.Code(err) != codes.NotFound {
t.Fatal(err)
}
}