diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index a5dede8..7407c5b 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -66,6 +66,7 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) public DbSet Applications { get; init; } = null!; public DbSet TemporaryKeys { get; init; } = null!; public DbSet DataExports { get; init; } = null!; + public DbSet SentEmails { get; init; } = null!; public DbSet PrideFlags { get; init; } = null!; public DbSet UserFlags { get; init; } = null!; @@ -84,6 +85,10 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) modelBuilder.Entity().HasIndex(m => new { m.UserId, m.Name }).IsUnique(); modelBuilder.Entity().HasIndex(m => m.Sid).IsUnique(); modelBuilder.Entity().HasIndex(k => k.Key).IsUnique(); + modelBuilder.Entity().HasIndex(d => d.Filename).IsUnique(); + modelBuilder.Entity().HasIndex(e => new { e.Email, e.SentAt }); + + // Two indexes on auth_methods, one for fediverse auth and one for all other types. modelBuilder .Entity() .HasIndex(m => new @@ -94,7 +99,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) }) .HasFilter("fediverse_application_id IS NOT NULL") .IsUnique(); - modelBuilder.Entity().HasIndex(d => d.Filename).IsUnique(); modelBuilder .Entity() diff --git a/Foxnouns.Backend/Database/Migrations/20241211193653_AddSentEmailCache.cs b/Foxnouns.Backend/Database/Migrations/20241211193653_AddSentEmailCache.cs new file mode 100644 index 0000000..e0fe00d --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20241211193653_AddSentEmailCache.cs @@ -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 +{ + /// + [DbContext(typeof(DatabaseContext))] + [Migration("20241211193653_AddSentEmailCache")] + public partial class AddSentEmailCache : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "sent_emails", + columns: table => new + { + id = table + .Column(type: "integer", nullable: false) + .Annotation( + "Npgsql:ValueGenerationStrategy", + NpgsqlValueGenerationStrategy.IdentityByDefaultColumn + ), + email = table.Column(type: "text", nullable: false), + sent_at = table.Column( + 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" } + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "sent_emails"); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index 4bd1ede..a9f59e6 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -1,5 +1,4 @@ // -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("RedirectUris") + b.PrimitiveCollection("RedirectUris") .IsRequired() .HasColumnType("text[]") .HasColumnName("redirect_uris"); - b.Property("Scopes") + b.PrimitiveCollection("Scopes") .IsRequired() .HasColumnType("text[]") .HasColumnName("scopes"); @@ -193,7 +192,7 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("jsonb") .HasColumnName("fields"); - b.Property("Links") + b.PrimitiveCollection("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("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("Id") @@ -359,7 +385,7 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("boolean") .HasColumnName("manually_expired"); - b.Property("Scopes") + b.PrimitiveCollection("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("Links") + b.PrimitiveCollection("Links") .IsRequired() .HasColumnType("text[]") .HasColumnName("links"); diff --git a/Foxnouns.Backend/Database/Models/SentEmail.cs b/Foxnouns.Backend/Database/Models/SentEmail.cs new file mode 100644 index 0000000..09f03d9 --- /dev/null +++ b/Foxnouns.Backend/Database/Models/SentEmail.cs @@ -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; } +} diff --git a/Foxnouns.Backend/Services/DataCleanupService.cs b/Foxnouns.Backend/Services/DataCleanupService.cs index 2b42f86..5e40c08 100644 --- a/Foxnouns.Backend/Services/DataCleanupService.cs +++ b/Foxnouns.Backend/Services/DataCleanupService.cs @@ -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; diff --git a/Foxnouns.Backend/Services/MailService.cs b/Foxnouns.Backend/Services/MailService.cs index 9ae61bd..e162b33 100644 --- a/Foxnouns.Backend/Services/MailService.cs +++ b/Foxnouns.Backend/Services/MailService.cs @@ -12,13 +12,25 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +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(); @@ -26,25 +38,18 @@ 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( - new AccountCreationMailable( - config, - new AccountCreationMailableView - { - BaseUrl = config.BaseUrl, - To = to, - Code = code, - } - ) - ); - } - catch (Exception exc) - { - _logger.Error(exc, "Sending account creation email"); - } + await SendEmailAsync( + to, + new AccountCreationMailable( + config, + new AccountCreationMailableView + { + BaseUrl = config.BaseUrl, + To = to, + Code = code, + } + ) + ); }); } @@ -53,25 +58,53 @@ 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( - new AddEmailMailable( - config, - new AddEmailMailableView - { - BaseUrl = config.BaseUrl, - To = to, - Code = code, - Username = username, - } - ) - ); - } - catch (Exception exc) - { - _logger.Error(exc, "Sending add email address email"); - } + await SendEmailAsync( + to, + new AddEmailMailable( + config, + new AddEmailMailableView + { + BaseUrl = config.BaseUrl, + To = to, + Code = code, + Username = username, + } + ) + ); }); } + + private async Task SendEmailAsync(string to, Mailable mailable) + { + try + { + // ReSharper disable SuggestVarOrType_SimpleTypes + await using var scope = serviceProvider.CreateAsyncScope(); + await using var db = scope.ServiceProvider.GetRequiredService(); + // 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 email"); + } + } }