Mail rate limit (defaults, errors, log events)

Feiten over de Microsoft Graph mail rate limiter: defaults, override-velden, Redis-keys, errors en log-events.

Voor begrip en ontwerp, zie de explanation.

Defaults

Bron: apps/api/src/common/services/mail-provider-service/rate-limit/mailRateLimitConfig.ts.

Naam Default Toelichting
perMinute 25 Maximaal aantal sendMail-calls per minuut
recipientsPerDay 9000 Maximaal aantal ontvangers per 24 uur

De defaults staan iets onder de Microsoft Graph-limieten (~30/min en 10.000/dag) als veiligheidsmarge.

Override-velden per systeem-mailbox

Velden op het SystemMailbox-record (zie apps/api/src/api/system-mailbox/SystemMailbox.interface.ts):

Veld Type Default Toelichting
rateLimitPerMinute number leeg Override op perMinute. Positief geheel getal.
rateLimitRecipientsPerDay number leeg Override op recipientsPerDay. Positief geheel getal.

Een leeg of niet-positief veld valt terug op de default. Wijzigen via PUT /admin/system-mailboxes/:key invalideert de factory-cache, dus geen pod-restart nodig.

Tenant-Graph-mailers (provider: 'microsoft-graph' in de tenant-mail-setting) hebben vandaag geen override en draaien altijd op de defaults.

Welke mailers worden gewrapt

apps/api/src/common/services/mail-provider-service/MailerFactory.ts:

Pad Wordt gewrapt door rate limiter?
Tenant-mailer, provider: 'microsoft-graph' Ja
Tenant-mailer, provider: 'sendgrid' of default Nee
Systeem-mailbox (altijd via Microsoft Graph) Ja

mailboxKey in de limiter is het volledige mailadres, genormaliseerd via trim().toLowerCase().

Redis-keys

Bron: apps/api/src/common/services/mail-provider-service/rate-limit/MailRateLimiter.ts.

Patroon Bevat TTL
mail:rate:min:<mailboxKey>:<minuteBucket> Aantal sendMail-calls 120 seconden
mail:rate:day:<mailboxKey>:<dayBucket> Aantal ontvangers 26 uur

minuteBucket = floor(epochSeconds / 60), dayBucket = floor(epochSeconds / 86400). Buckets rollen vanzelf over.

Reservering en release gebeuren via twee server-side Lua-scripts (mailRateCheckAndReserve, mailRateRelease). Beide worden bij eerste gebruik per Redis-verbinding geregistreerd via defineCommand.

Reservering-flow

RateLimitedGraphMailer.send (apps/api/src/common/services/mail-provider-service/rate-limit/RateLimitedGraphMailer.ts):

  1. Tel recipientCount uit message.recipients.length.
  2. Roep limiter.checkAndReserve({ mailboxKey, recipientCount, limits }) aan.
  3. Geweigerd → throw MailRateLimitExceededError(mailboxKey, reason, retryAfterMs).
  4. Toegestaan → roep inner.send(message) aan.
  5. Inner gooit → limiter.release(...) om de reservering terug te draaien.
  6. Inner slaagt → reservering blijft staan.

Reschedule-flow

MailProviderService.send catch-pad voor MailRateLimitExceededError (apps/api/src/common/services/mail-provider-service/MailProviderService.ts):

const spreadMs = computeSpreadMs(message.id);            // 0..29999
const newScheduledOn = new Date(Date.now() + error.retryAfterMs + spreadMs);
await messageService.update(message.id, {
  status: MessageStatusEnum.SCHEDULED,
  scheduledOn: newScheduledOn,
});

computeSpreadMs is FNV-1a hash van message.id modulo 30.000 ms. Deterministisch per id.

De BullMQ-worker send-scheduled-messages pakt de message vanzelf opnieuw op zodra scheduledOn voorbij is. Geen aparte retry-queue.

Errors

MailRateLimitExceededError (apps/api/src/common/services/mail-provider-service/rate-limit/MailRateLimitExceededError.ts):

Property Type Toelichting
name 'MailRateLimitExceededError' Vaste naam
mailboxKey string Het mailbox-adres dat de limiet raakte
reason 'minute' \| 'day' Welke limiet werd geraakt
retryAfterMs number Geschatte ms tot het bucket-window opnieuw vrij is
message string 'Mail rate limit exceeded for X (reason); retry in Yms'

retryAfterMs is gebaseerd op het resterende deel van het minuut- of dag-window, niet op een server-side advies. Het is dus een minimum, niet een belofte.

Log-events

Event Niveau Velden Wanneer
mail.rateLimit.deferred info mailboxKey, messageUuid, reason, retryAfterMs, newScheduledOn Message wordt opnieuw ingepland na een rate-limit-deny
mail.rateLimit.redisError warn mailboxKey, err, op? ('release' als release faalt) Redis niet bereikbaar of Lua-call gooit. Limiter gaat fail-open.

Configuratie en singleton

getMailRateLimiter() (mailRateLimiter.singleton.ts) geeft één gedeelde instance per pod, gebaseerd op getRedisConnectionHelper(false). Geen aparte env-vars: er wordt dezelfde Redis-verbinding gebruikt als de rest van de api.

Voor tests: __setMailRateLimiterForTests(custom) injecteert een eigen instance.

Validatie op de admin-API

PUT /admin/system-mailboxes/:key valideert (apps/api/src/api/system-mailbox/systemMailboxRouter.ts):

Veld Regel
rateLimitPerMinute Optioneel. Indien gezet: positief geheel getal, anders 400.
rateLimitRecipientsPerDay Optioneel. Indien gezet: positief geheel getal, anders 400.

Foutboodschappen:

  • rateLimitPerMinute moet een positief geheel getal zijn
  • rateLimitRecipientsPerDay moet een positief geheel getal zijn

Een veld weglaten in de PUT-body laat de bestaande waarde staan.

Bron-bestanden

  • Limiter: apps/api/src/common/services/mail-provider-service/rate-limit/MailRateLimiter.ts
  • Decorator: apps/api/src/common/services/mail-provider-service/rate-limit/RateLimitedGraphMailer.ts
  • Singleton: apps/api/src/common/services/mail-provider-service/rate-limit/mailRateLimiter.singleton.ts
  • Config en resolveLimits: apps/api/src/common/services/mail-provider-service/rate-limit/mailRateLimitConfig.ts
  • Error: apps/api/src/common/services/mail-provider-service/rate-limit/MailRateLimitExceededError.ts
  • Wrapping in factory: apps/api/src/common/services/mail-provider-service/MailerFactory.ts
  • Reschedule-flow: apps/api/src/common/services/mail-provider-service/MailProviderService.ts
  • Validatie: apps/api/src/api/system-mailbox/systemMailboxRouter.ts