package notification import ( "context" "errors" "fmt" "strings" "time" "github.com/google/uuid" "github.com/twilio/twilio-go" openapi "github.com/twilio/twilio-go/rest/api/v2010" ) // TwilioConfig configures the SMS sender. AccountSID + AuthToken pair // authenticate the REST client; FromNumber is the verified Twilio number. type TwilioConfig struct { AccountSID string AuthToken string FromNumber string MaxAttempts int } // TwilioSender implements notification.Sender for ChannelSMS. Retries on // transient errors with exponential backoff (1s, 5s, 30s, 5m, 30m). Twilio // surfaces a numeric ErrorCode for permanent failures (e.g. 21610 // unsubscribed, 21408 disabled region) — those return immediately. type TwilioSender struct { client *twilio.RestClient from string maxAttempt int } func NewTwilioSender(cfg TwilioConfig) (*TwilioSender, error) { if cfg.AccountSID == "" || cfg.AuthToken == "" || cfg.FromNumber == "" { return nil, errors.New("twilio: AccountSID / AuthToken / FromNumber are required") } cli := twilio.NewRestClientWithParams(twilio.ClientParams{ Username: cfg.AccountSID, Password: cfg.AuthToken, }) max := cfg.MaxAttempts if max <= 0 { max = 5 } return &TwilioSender{client: cli, from: cfg.FromNumber, maxAttempt: max}, nil } func (t *TwilioSender) Send(ctx context.Context, msg OutboundMessage) (string, error) { if msg.GuestID == uuid.Nil { return "", errors.New("missing guest id") } if msg.Channel != ChannelSMS { return "", fmt.Errorf("TwilioSender does not handle channel %q", msg.Channel) } to, _ := msg.Metadata["phone"].(string) if to == "" { return "", errors.New("sms recipient missing from metadata.phone") } body := msg.Body if body == "" { body = msg.Subject } if body == "" { return "", errors.New("sms body is empty") } params := &openapi.CreateMessageParams{} params.SetTo(to) params.SetFrom(t.from) params.SetBody(body) backoff := []time.Duration{0, time.Second, 5 * time.Second, 30 * time.Second, 5 * time.Minute, 30 * time.Minute} var lastErr error for attempt := 0; attempt < t.maxAttempt; attempt++ { if attempt < len(backoff) && backoff[attempt] > 0 { select { case <-ctx.Done(): return "", ctx.Err() case <-time.After(backoff[attempt]): } } resp, err := t.client.Api.CreateMessage(params) if err == nil && resp != nil && resp.Sid != nil { return *resp.Sid, nil } lastErr = err if !isTwilioRetryable(err) { return "", fmt.Errorf("twilio: send: %w", err) } } return "", fmt.Errorf("twilio: send (after %d attempts): %w", t.maxAttempt, lastErr) } // isTwilioRetryable returns true for transient failures (network, 5xx). // Twilio's permanent error codes (21xxx range) are not retried. func isTwilioRetryable(err error) bool { if err == nil { return false } msg := err.Error() // Cheap heuristic: permanent codes are in the 21xxx range; everything // else (timeouts, 503s, DNS hiccups) is fair game. if strings.Contains(msg, "21610") || strings.Contains(msg, "21408") || strings.Contains(msg, "21211") { return false } return true }