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

+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)
}
}