feat: rate limit emails to two per address per hour
This commit is contained in:
		
							parent
							
								
									5cb3faa92b
								
							
						
					
					
						commit
						ff8d53814d
					
				
					 6 changed files with 189 additions and 49 deletions
				
			
		|  | @ -66,6 +66,7 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) | |||
|     public DbSet<Application> Applications { get; init; } = null!; | ||||
|     public DbSet<TemporaryKey> TemporaryKeys { get; init; } = null!; | ||||
|     public DbSet<DataExport> DataExports { get; init; } = null!; | ||||
|     public DbSet<SentEmail> SentEmails { get; init; } = null!; | ||||
| 
 | ||||
|     public DbSet<PrideFlag> PrideFlags { get; init; } = null!; | ||||
|     public DbSet<UserFlag> UserFlags { get; init; } = null!; | ||||
|  | @ -84,6 +85,10 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) | |||
|         modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique(); | ||||
|         modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique(); | ||||
|         modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique(); | ||||
|         modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique(); | ||||
|         modelBuilder.Entity<SentEmail>().HasIndex(e => new { e.Email, e.SentAt }); | ||||
| 
 | ||||
|         // Two indexes on auth_methods, one for fediverse auth and one for all other types. | ||||
|         modelBuilder | ||||
|             .Entity<AuthMethod>() | ||||
|             .HasIndex(m => new | ||||
|  | @ -94,7 +99,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) | |||
|             }) | ||||
|             .HasFilter("fediverse_application_id IS NOT NULL") | ||||
|             .IsUnique(); | ||||
|         modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique(); | ||||
| 
 | ||||
|         modelBuilder | ||||
|             .Entity<AuthMethod>() | ||||
|  |  | |||
|  | @ -0,0 +1,53 @@ | |||
| using Microsoft.EntityFrameworkCore.Infrastructure; | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
| using NodaTime; | ||||
| using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||
| 
 | ||||
| #nullable disable | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Database.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     [DbContext(typeof(DatabaseContext))] | ||||
|     [Migration("20241211193653_AddSentEmailCache")] | ||||
|     public partial class AddSentEmailCache : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.CreateTable( | ||||
|                 name: "sent_emails", | ||||
|                 columns: table => new | ||||
|                 { | ||||
|                     id = table | ||||
|                         .Column<int>(type: "integer", nullable: false) | ||||
|                         .Annotation( | ||||
|                             "Npgsql:ValueGenerationStrategy", | ||||
|                             NpgsqlValueGenerationStrategy.IdentityByDefaultColumn | ||||
|                         ), | ||||
|                     email = table.Column<string>(type: "text", nullable: false), | ||||
|                     sent_at = table.Column<Instant>( | ||||
|                         type: "timestamp with time zone", | ||||
|                         nullable: false | ||||
|                     ), | ||||
|                 }, | ||||
|                 constraints: table => | ||||
|                 { | ||||
|                     table.PrimaryKey("pk_sent_emails", x => x.id); | ||||
|                 } | ||||
|             ); | ||||
| 
 | ||||
|             migrationBuilder.CreateIndex( | ||||
|                 name: "ix_sent_emails_email_sent_at", | ||||
|                 table: "sent_emails", | ||||
|                 columns: new[] { "email", "sent_at" } | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropTable(name: "sent_emails"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -1,5 +1,4 @@ | |||
| // <auto-generated /> | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
|  | @ -20,7 +19,7 @@ namespace Foxnouns.Backend.Database.Migrations | |||
|         { | ||||
| #pragma warning disable 612, 618 | ||||
|             modelBuilder | ||||
|                 .HasAnnotation("ProductVersion", "8.0.7") | ||||
|                 .HasAnnotation("ProductVersion", "9.0.0") | ||||
|                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||
| 
 | ||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||
|  | @ -46,12 +45,12 @@ namespace Foxnouns.Backend.Database.Migrations | |||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("name"); | ||||
| 
 | ||||
|                     b.Property<string[]>("RedirectUris") | ||||
|                     b.PrimitiveCollection<string[]>("RedirectUris") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text[]") | ||||
|                         .HasColumnName("redirect_uris"); | ||||
| 
 | ||||
|                     b.Property<string[]>("Scopes") | ||||
|                     b.PrimitiveCollection<string[]>("Scopes") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text[]") | ||||
|                         .HasColumnName("scopes"); | ||||
|  | @ -193,7 +192,7 @@ namespace Foxnouns.Backend.Database.Migrations | |||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("fields"); | ||||
| 
 | ||||
|                     b.Property<string[]>("Links") | ||||
|                     b.PrimitiveCollection<string[]>("Links") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text[]") | ||||
|                         .HasColumnName("links"); | ||||
|  | @ -303,6 +302,33 @@ namespace Foxnouns.Backend.Database.Migrations | |||
|                     b.ToTable("pride_flags", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.SentEmail", b => | ||||
|                 { | ||||
|                     b.Property<int>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | ||||
| 
 | ||||
|                     b.Property<string>("Email") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("email"); | ||||
| 
 | ||||
|                     b.Property<Instant>("SentAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("sent_at"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_sent_emails"); | ||||
| 
 | ||||
|                     b.HasIndex("Email", "SentAt") | ||||
|                         .HasDatabaseName("ix_sent_emails_email_sent_at"); | ||||
| 
 | ||||
|                     b.ToTable("sent_emails", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|  | @ -359,7 +385,7 @@ namespace Foxnouns.Backend.Database.Migrations | |||
|                         .HasColumnType("boolean") | ||||
|                         .HasColumnName("manually_expired"); | ||||
| 
 | ||||
|                     b.Property<string[]>("Scopes") | ||||
|                     b.PrimitiveCollection<string[]>("Scopes") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text[]") | ||||
|                         .HasColumnName("scopes"); | ||||
|  | @ -428,7 +454,7 @@ namespace Foxnouns.Backend.Database.Migrations | |||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("last_sid_reroll"); | ||||
| 
 | ||||
|                     b.Property<string[]>("Links") | ||||
|                     b.PrimitiveCollection<string[]>("Links") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text[]") | ||||
|                         .HasColumnName("links"); | ||||
|  |  | |||
							
								
								
									
										13
									
								
								Foxnouns.Backend/Database/Models/SentEmail.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								Foxnouns.Backend/Database/Models/SentEmail.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,13 @@ | |||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using NodaTime; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Database.Models; | ||||
| 
 | ||||
| public class SentEmail | ||||
| { | ||||
|     [DatabaseGenerated(DatabaseGeneratedOption.Identity)] | ||||
|     public int Id { get; init; } | ||||
| 
 | ||||
|     public required string Email { get; init; } | ||||
|     public required Instant SentAt { get; init; } | ||||
| } | ||||
|  | @ -33,13 +33,24 @@ public class DataCleanupService( | |||
| 
 | ||||
|     public async Task InvokeAsync(CancellationToken ct = default) | ||||
|     { | ||||
|         _logger.Information("Cleaning up expired users"); | ||||
|         _logger.Debug("Cleaning up sent email cache"); | ||||
|         await CleanEmailsAsync(ct); | ||||
| 
 | ||||
|         _logger.Debug("Cleaning up expired users"); | ||||
|         await CleanUsersAsync(ct); | ||||
| 
 | ||||
|         _logger.Information("Cleaning up expired data exports"); | ||||
|         _logger.Debug("Cleaning up expired data exports"); | ||||
|         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; | ||||
|  |  | |||
|  | @ -12,13 +12,25 @@ | |||
| // | ||||
| // You should have received a copy of the GNU Affero General Public License | ||||
| // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
| 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, Config config) | ||||
| public class MailService( | ||||
|     ILogger logger, | ||||
|     IMailer mailer, | ||||
|     IQueue queue, | ||||
|     IClock clock, | ||||
|     Config config, | ||||
|     IServiceProvider serviceProvider | ||||
| ) | ||||
| { | ||||
|     private readonly ILogger _logger = logger.ForContext<MailService>(); | ||||
| 
 | ||||
|  | @ -26,10 +38,8 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co | |||
|     { | ||||
|         queue.QueueAsyncTask(async () => | ||||
|         { | ||||
|             _logger.Debug("Sending account creation email to {ToEmail}", to); | ||||
|             try | ||||
|             { | ||||
|                 await mailer.SendAsync( | ||||
|             await SendEmailAsync( | ||||
|                 to, | ||||
|                 new AccountCreationMailable( | ||||
|                     config, | ||||
|                     new AccountCreationMailableView | ||||
|  | @ -40,11 +50,6 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co | |||
|                     } | ||||
|                 ) | ||||
|             ); | ||||
|             } | ||||
|             catch (Exception exc) | ||||
|             { | ||||
|                 _logger.Error(exc, "Sending account creation email"); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|  | @ -53,9 +58,8 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co | |||
|         _logger.Debug("Sending add email address email to {ToEmail}", to); | ||||
|         queue.QueueAsyncTask(async () => | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 await mailer.SendAsync( | ||||
|             await SendEmailAsync( | ||||
|                 to, | ||||
|                 new AddEmailMailable( | ||||
|                     config, | ||||
|                     new AddEmailMailableView | ||||
|  | @ -67,11 +71,40 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co | |||
|                     } | ||||
|                 ) | ||||
|             ); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     private async Task SendEmailAsync<T>(string to, Mailable<T> mailable) | ||||
|     { | ||||
|         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) | ||||
|         { | ||||
|                 _logger.Error(exc, "Sending add email address email"); | ||||
|             _logger.Error(exc, "Sending email"); | ||||
|         } | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue