Rate limit outgoing Service Desk emails per root namespace
What does this MR do and why?
Adds hourly and daily rate limits on outgoing Service Desk emails, counted per root namespace. The per-plan thresholds shipped with !253087 (merged) (plan_limits columns + seeds + Admin API exposure); this MR adds the application layer that enforces them.
Related issue: https://gitlab.com/gitlab-org/gitlab/-/work_items/627183
All three Service Desk email types are covered at their send sites:
- thank_you email on ticket creation (Gitlab::Email::Handler::ServiceDeskHandler)
- new_note email fan-out to issue email participants (NotificationService#send_service_desk_notification) - covers both UI comments and inbound email replies
- new_participant email (IssueEmailParticipants::CreateService, the /add_email quick action and CC ingestion)
Domain writes are never affected - tickets are still created, notes still saved, participant records still persisted. Only the mail sending is suppressed when a namespace exceeds its limit.
How it works
- New ServiceDesk::EmailRateLimiter, closely following the Gitlab::WebHooks::RateLimiter pattern: thresholds read live from plan_limits via actual_limits.limit_for, 0 = unlimited. The default plan is unseeded, so Self-Managed instances are unaffected (no limiting, no Redis traffic).
- Counting unit is one per email: a comment notifying 10 participants consumes 10, matching the column names (service_desk_outbound_emails_per_hour/_per_day). The batch count reflects emails actually sent (skipped recipients are excluded before the limiter is charged).
- Fixed hourly AND daily windows - sending is blocked when either is exhausted, and both keep counting even when one already is.
- All-or-none per batch: when a fan-out would cross the limit, no recipient receives mail, and the blocked batch still consumes budget.
- Fail-visible: when mail is suppressed, one internal note per work item per hour informs project members. The note is authored by the support bot via Notes::CreateService with internal: true, so it is not visible to external participants and does not itself trigger Service Desk email. It contains no recipient addresses.
Seeded thresholds
| Plan | Hourly | Daily |
|---|---|---|
free, ultimate_trial, premium_trial |
100 | 700 |
opensource |
1500 | 10000 |
premium |
5000 | 50000 |
ultimate, ultimate_trial_paid_customer |
0 | 0 |
0 means unlimited, so Ultimate and paid-trial-customer namespaces
are not limited today. The default plan is left unseeded and stays
at 0, so GitLab Self-Managed instances are never limited.
Rollout
Everything is behind the wip feature flag service_desk_email_rate_limit (default off, actor-scoped on the root namespace, flag ON = enforce). With the flag off, behavior is unchanged and the rate limiter is never consulted (no Redis traffic).
Note for reviewers: plan_limits value 0 is already the permanent runtime per-plan disable; the feature flag exists for gradual rollout (percentage by root namespace) and instant global disable during the ramp. It will be removed after rollout completes.
References
- Epic: https://gitlab.com/groups/gitlab-org/-/work_items/23455
- DB MR: !253087 (merged)
- Precedent for the limiter shape: lib/gitlab/web_hooks/rate_limiter.rb
How to set up and validate locally
-
Enable the flag and set a low limit for the default plan:
Feature.enable(:service_desk_email_rate_limit) Plan.default.actual_limits.update!( service_desk_outbound_emails_per_hour: 2) -
Set up Service Desk locally and ingest an email ($3612230), then create a Service Desk ticket or convert and existing issue into a ticket and comment on it a few times with a public comment.
-
After the second email, further comments no longer enqueue Notify emails; the ticket receives one internal note stating that notifications were not sent.
-
Disable the flag and confirm emails flow again unchanged.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist.
Related to #627183