package notification import ( "context" "errors" "fmt" "time" ) type Dispatcher struct { Store Store Channels map[string]ChannelSender Limiter *RateLimiter } func (dispatcher Dispatcher) DispatchDue(ctx context.Context, now time.Time, limit int) (int, error) { if dispatcher.Store == nil { return 0, ErrUnavailable } items, err := dispatcher.Store.ClaimDue(ctx, now.UTC(), limit) if err != nil { return 0, err } delivered := 0 for _, item := range items { sender, ok := dispatcher.Channels[item.ChannelID] if !ok || sender == nil { _, completeErr := dispatcher.Store.Complete(ctx, item.ID, item.Attempts, false, errors.New("notification channel sender unavailable"), now) if completeErr != nil { return delivered, completeErr } continue } delivery := Delivery{ID: item.ID, EventType: item.EventType, Subject: item.Subject, Body: item.Body, IdempotencyKey: item.IdempotencyKey, Attempt: item.Attempts} sendErr := sender.Send(ctx, delivery) if _, completeErr := dispatcher.Store.Complete(ctx, item.ID, item.Attempts, sendErr == nil, sendErr, now); completeErr != nil { return delivered, completeErr } if sendErr == nil { delivered++ } } return delivered, nil } // Test sends a bounded, in-memory test delivery. It deliberately does not // enqueue or mutate an outbox record, and it uses the actor/channel pair as a // rate-limit key so test actions cannot become a delivery amplification path. func (dispatcher Dispatcher) Test(ctx context.Context, actor, channelID string, delivery Delivery, now time.Time) error { if actor == "" || len(actor) > 160 || channelID == "" { return fmt.Errorf("%w: test identity is invalid", ErrInvalid) } if dispatcher.Limiter == nil { return ErrUnavailable } if !dispatcher.Limiter.Allow(actor+":"+channelID, now.UTC()) { return fmt.Errorf("%w: notification test rate limit exceeded", ErrConflict) } sender, ok := dispatcher.Channels[channelID] if !ok || sender == nil { return ErrNotFound } if err := delivery.Validate(); err != nil { return err } return sender.Send(ctx, delivery) }