feat: use a FixedWindowRateLimiter keyed by IP to rate limit emails
we don't talk about the sent_emails table :)
This commit is contained in:
parent
1ce4f9d278
commit
51e335f090
8 changed files with 75 additions and 87 deletions
|
@ -33,9 +33,6 @@ public class DataCleanupService(
|
|||
|
||||
public async Task InvokeAsync(CancellationToken ct = default)
|
||||
{
|
||||
_logger.Debug("Cleaning up sent email cache");
|
||||
await CleanEmailsAsync(ct);
|
||||
|
||||
_logger.Debug("Cleaning up expired users");
|
||||
await CleanUsersAsync(ct);
|
||||
|
||||
|
@ -43,14 +40,6 @@ public class DataCleanupService(
|
|||
await CleanExportsAsync(ct);
|
||||
}
|
||||
|
||||
private async Task CleanEmailsAsync(CancellationToken ct = default)
|
||||
{
|
||||
Instant expiry = clock.GetCurrentInstant() - Duration.FromHours(2);
|
||||
int count = await db.SentEmails.Where(e => e.SentAt < expiry).ExecuteDeleteAsync(ct);
|
||||
if (count != 0)
|
||||
_logger.Information("Deleted {Count} entries from the sent email cache", expiry);
|
||||
}
|
||||
|
||||
private async Task CleanUsersAsync(CancellationToken ct = default)
|
||||
{
|
||||
Instant selfDeleteExpires = clock.GetCurrentInstant() - User.DeleteAfter;
|
||||
|
|
36
Foxnouns.Backend/Services/EmailRateLimiter.cs
Normal file
36
Foxnouns.Backend/Services/EmailRateLimiter.cs
Normal file
|
@ -0,0 +1,36 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Threading.RateLimiting;
|
||||
using NodaTime;
|
||||
using NodaTime.Extensions;
|
||||
|
||||
namespace Foxnouns.Backend.Services;
|
||||
|
||||
public class EmailRateLimiter
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, RateLimiter> _limiters = new();
|
||||
|
||||
private readonly FixedWindowRateLimiterOptions _limiterOptions =
|
||||
new() { Window = TimeSpan.FromHours(2), PermitLimit = 3 };
|
||||
|
||||
private RateLimiter GetLimiter(string bucket) =>
|
||||
_limiters.GetOrAdd(bucket, _ => new FixedWindowRateLimiter(_limiterOptions));
|
||||
|
||||
public bool IsLimited(string bucket, out Duration retryAfter)
|
||||
{
|
||||
RateLimiter limiter = GetLimiter(bucket);
|
||||
RateLimitLease lease = limiter.AttemptAcquire();
|
||||
|
||||
if (!lease.IsAcquired)
|
||||
{
|
||||
retryAfter = lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan timeSpan)
|
||||
? timeSpan.ToDuration()
|
||||
: default;
|
||||
}
|
||||
else
|
||||
{
|
||||
retryAfter = Duration.Zero;
|
||||
}
|
||||
|
||||
return !lease.IsAcquired;
|
||||
}
|
||||
}
|
|
@ -15,22 +15,11 @@
|
|||
using Coravel.Mailer.Mail;
|
||||
using Coravel.Mailer.Mail.Interfaces;
|
||||
using Coravel.Queuing.Interfaces;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Mailables;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace Foxnouns.Backend.Services;
|
||||
|
||||
public class MailService(
|
||||
ILogger logger,
|
||||
IMailer mailer,
|
||||
IQueue queue,
|
||||
IClock clock,
|
||||
Config config,
|
||||
IServiceProvider serviceProvider
|
||||
)
|
||||
public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config config)
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<MailService>();
|
||||
|
||||
|
@ -78,29 +67,7 @@ public class MailService(
|
|||
{
|
||||
try
|
||||
{
|
||||
// ReSharper disable SuggestVarOrType_SimpleTypes
|
||||
await using var scope = serviceProvider.CreateAsyncScope();
|
||||
await using var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
||||
// ReSharper restore SuggestVarOrType_SimpleTypes
|
||||
|
||||
Instant now = clock.GetCurrentInstant();
|
||||
|
||||
int count = await db.SentEmails.CountAsync(e =>
|
||||
e.Email == to && e.SentAt > (now - Duration.FromHours(1))
|
||||
);
|
||||
if (count >= 2)
|
||||
{
|
||||
_logger.Information(
|
||||
"Have already sent 2 or more emails to {ToAddress} in the past hour, not sending new email",
|
||||
to
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await mailer.SendAsync(mailable);
|
||||
|
||||
db.SentEmails.Add(new SentEmail { Email = to, SentAt = now });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception exc)
|
||||
{
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue