// Package sendemailcodeabuse implements ports.SendEmailCodeAbuseProtector with // one Redis TTL key per normalized e-mail address. package sendemailcodeabuse import ( "context" "encoding/base64" "errors" "fmt" "strings" "time" "galaxy/authsession/internal/domain/challenge" "galaxy/authsession/internal/domain/common" "galaxy/authsession/internal/ports" "github.com/redis/go-redis/v9" ) // Config configures one Redis-backed send-email-code abuse protector. The // protector does not own its Redis client; the runtime supplies a shared // client constructed via `pkg/redisconn`. type Config struct { // KeyPrefix is the namespace prefix applied to every resend-throttle key. KeyPrefix string // OperationTimeout bounds each Redis round trip performed by the adapter. OperationTimeout time.Duration } // Protector applies the fixed resend cooldown with one Redis key per // normalized e-mail address. type Protector struct { client *redis.Client keyPrefix string operationTimeout time.Duration } // New constructs a Redis-backed resend-throttle protector that uses client // and applies the namespace and timeout settings from cfg. func New(client *redis.Client, cfg Config) (*Protector, error) { switch { case client == nil: return nil, errors.New("new redis send email code abuse protector: nil redis client") case strings.TrimSpace(cfg.KeyPrefix) == "": return nil, errors.New("new redis send email code abuse protector: redis key prefix must not be empty") case cfg.OperationTimeout <= 0: return nil, errors.New("new redis send email code abuse protector: operation timeout must be positive") } return &Protector{ client: client, keyPrefix: cfg.KeyPrefix, operationTimeout: cfg.OperationTimeout, }, nil } // CheckAndReserve applies the fixed resend cooldown using one TTL key per // normalized e-mail address. func (p *Protector) CheckAndReserve(ctx context.Context, input ports.SendEmailCodeAbuseInput) (ports.SendEmailCodeAbuseResult, error) { if err := input.Validate(); err != nil { return ports.SendEmailCodeAbuseResult{}, fmt.Errorf("check and reserve send email code abuse: %w", err) } operationCtx, cancel, err := p.operationContext(ctx, "check and reserve send email code abuse") if err != nil { return ports.SendEmailCodeAbuseResult{}, err } defer cancel() key := p.lookupKey(input.Email) value := input.Now.UTC().Add(challenge.ResendThrottleCooldown).Format(time.RFC3339Nano) created, err := p.client.SetNX(operationCtx, key, value, challenge.ResendThrottleCooldown).Result() if err != nil { return ports.SendEmailCodeAbuseResult{}, fmt.Errorf("check and reserve send email code abuse for %q: %w", input.Email, err) } if created { return ports.SendEmailCodeAbuseResult{Outcome: ports.SendEmailCodeAbuseOutcomeAllowed}, nil } return ports.SendEmailCodeAbuseResult{Outcome: ports.SendEmailCodeAbuseOutcomeThrottled}, nil } func (p *Protector) operationContext(ctx context.Context, operation string) (context.Context, context.CancelFunc, error) { if p == nil || p.client == nil { return nil, nil, fmt.Errorf("%s: nil protector", operation) } if ctx == nil { return nil, nil, fmt.Errorf("%s: nil context", operation) } operationCtx, cancel := context.WithTimeout(ctx, p.operationTimeout) return operationCtx, cancel, nil } func (p *Protector) lookupKey(email common.Email) string { return p.keyPrefix + base64.RawURLEncoding.EncodeToString([]byte(email.String())) } var _ ports.SendEmailCodeAbuseProtector = (*Protector)(nil)