Both methods short-circuit the authz gate when cx.principal == None.
An attacker hitting any public route could trigger unbounded outbound
email per call to arbitrary or attacker-supplied addresses — exhausting
SMTP-relay quota, getting the relay blacklisted, or burning provider
credits.
Add an in-memory EmailRateLimiter on UsersServiceImpl with two scopes:
- Per-(app, recipient): 5 calls per rolling 1h window
- Per-app daily cap: 200 calls per rolling 24h window
Both windows reset lazily. Token-bucket counts increment only after
both checks pass — a denied recipient doesn't consume the app counter.
Wired:
- send_verification_email: returns UsersError::EmailRateLimited (→ 429
on the admin HTTP surface).
- request_password_reset: returns Ok(()) silently on rate-limit (would
otherwise enable an existence-leak side channel).
UsersError::EmailRateLimited variant added; AppUsersApiError gains the
same variant + 429 mapping. NoopUsersError trait stub unaffected.
AUDIT.md anchor: F-S-002.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>