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

How to set up and validate locally

  1. 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)
  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.

  3. After the second email, further comments no longer enqueue Notify emails; the ticket receives one internal note stating that notifications were not sent.

  4. Disable the flag and confirm emails flow again unchanged.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist.

Related to #627183

Edited by Mario Celi

Merge request reports

Loading
Loading