diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e877821 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +################################################################################ +# This .gitignore file was automatically created by Microsoft(R) Visual Studio. +################################################################################ + +/EmailDispatcherAPI/bin +/EmailDispatcherAPI/.vs/EmailDispatcherAPI +/EmailDispatcherAPI/obj +/EmailWorker/bin +/EmailWorker/obj +/EmailDispatcherAPI/appsettings.json +/EmailDispatcherAPI/appsettings.Development.json +/EmailWorker/appsettings.json +/EmailWorker/appsettings.Development.json +/EmailWorker/appsettings.json +/EmailWorker/appsettings.Development.json +/EmailRetryScheduler/appsettings.json +/EmailRetryScheduler/appsettings.Development.json +/EmailRetryScheduler/bin +/EmailRetryScheduler/obj +/EmailRetryScheduler/obj +/EmailRetryScheduler/obj/Debug +/EmailRetryScheduler/obj/Debug/net9.0 +/EmailRetryScheduler/appsettings.json +/EmailRetryScheduler/appsettings.Development.json diff --git a/Dev_Notes.md b/Dev_Notes.md new file mode 100644 index 0000000..ce3d9f5 --- /dev/null +++ b/Dev_Notes.md @@ -0,0 +1,120 @@ +## Stack +- RabbitMQ +- .NET Worker Service +- Database (SQL Server) +- SMTP / Email Provider + +## .Net Technical terms used : +- WEB API +- Entity Framework +- Minimal Api +- Global exception handling and developer exception page +- DB First approach +- Repository pattern and DI +- Asynchronous Initialization via Hosted Service + +## RabbitMQ docker setup command +# latest RabbitMQ 4.x +- docker run -it --rm --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:4-management + +## RabbitMQ client +- Should have single connection for whole application(singleton) +- Can create multiple channel as required + +## Connection: Physical link to broker + +- Channel: Lightweight AMQP session + +- Exchange: Routes messages + +- Routing key: Address label + +- Queue: Stores messages + +- Binding: Routing rule + +- Consumer: Processes messages + + +## 📨 RabbitMQ Publish Flow + ## 1️⃣ Connection + + CreateConnectionAsync() opens a TCP connection + + Heavy operation + + Should be reused, not created per message + + Thread-safe + + ## 2️⃣ Channel + + CreateChannelAsync() creates an AMQP channel + + Lightweight + + Used for publish/consume operations + + Multiple channels can share one connection + + ## 3️⃣ Queue Declaration + + QueueDeclareAsync() ensures queue exists + + Idempotent (safe to call multiple times) + + Fails if queue exists with different settings + + Flags + + durable → survives broker restart + + exclusive → only one connection can use it + + autoDelete → deleted when last consumer disconnects + + ## 4️⃣ Message Serialization + + Messages are sent as byte[] + + Common format: JSON → UTF-8 bytes + + ## 5️⃣ Publishing Message + + BasicPublishAsync() sends message to exchange + + Default exchange ("") routes by queue name + + Publish ≠ delivered + + Publish ≠ persisted + +## Dependecy Issue for windows services + +While it seems intuitive to make everything a Singleton because a Windows Service runs continuously, you are running into a common dependency injection issue called a Scoped Leak. + +The Problem: Capturing a Scoped Service +In Entity Framework Core, a DbContext is registered as Scoped by default. This means it is designed to be created and destroyed within a short lifetime (like a single HTTP request or a single loop of a worker). + +If your EmailRepository is a Singleton, it will be created once when the service starts. Because it depends on AppDBContext, it will "capture" that context and hold onto it forever. + +This leads to several issues: + +Memory Leaks: The DbContext keeps track of every entity it ever loads. Over days or weeks, your memory usage will climb indefinitely. + +Concurrency Crashes: A DbContext is not thread-safe. If your worker tries to do two things at once using the same singleton context, it will throw an exception. + +Stale Data: You won't see updates made to the database by other processes because the singleton context keeps its own internal cache. + +The Solution: Use a Service Scope +The correct pattern for a continuous Worker (Windows Service) is to create a Scope manually inside your background loop. This ensures the DbContext is fresh for every "pulse" of work. + + +## C# Rules to remeber +- All interface members are implicitly public This is a hard C# rule. +- A public Classes cannot expose a less-accessible type like internal classes. So when you inject internal into public services means it show error. + + +## RabbitMq + +When Send message Quename and Routing key should be same \ No newline at end of file diff --git a/EmailDispatcherAPI/.vs/ProjectEvaluation/emaildispatcherapi.metadata.v9.bin b/EmailDispatcherAPI/.vs/ProjectEvaluation/emaildispatcherapi.metadata.v9.bin new file mode 100644 index 0000000..b863a6c Binary files /dev/null and b/EmailDispatcherAPI/.vs/ProjectEvaluation/emaildispatcherapi.metadata.v9.bin differ diff --git a/EmailDispatcherAPI/.vs/ProjectEvaluation/emaildispatcherapi.projects.v9.bin b/EmailDispatcherAPI/.vs/ProjectEvaluation/emaildispatcherapi.projects.v9.bin new file mode 100644 index 0000000..19f81c8 Binary files /dev/null and b/EmailDispatcherAPI/.vs/ProjectEvaluation/emaildispatcherapi.projects.v9.bin differ diff --git a/EmailDispatcherAPI/.vs/ProjectEvaluation/emaildispatcherapi.strings.v9.bin b/EmailDispatcherAPI/.vs/ProjectEvaluation/emaildispatcherapi.strings.v9.bin new file mode 100644 index 0000000..8acb99d Binary files /dev/null and b/EmailDispatcherAPI/.vs/ProjectEvaluation/emaildispatcherapi.strings.v9.bin differ diff --git a/EmailDispatcherAPI/Constant/AppConstant.cs b/EmailDispatcherAPI/Constant/AppConstant.cs new file mode 100644 index 0000000..8f4bc10 --- /dev/null +++ b/EmailDispatcherAPI/Constant/AppConstant.cs @@ -0,0 +1,7 @@ +namespace EmailDispatcherAPI.Constant +{ + internal class AppConstant + { + public const string QueueName = "email.dispatcher.send"; + } +} diff --git a/EmailDispatcherAPI/Constant/Enum/EmailStatus.cs b/EmailDispatcherAPI/Constant/Enum/EmailStatus.cs new file mode 100644 index 0000000..fcd86ce --- /dev/null +++ b/EmailDispatcherAPI/Constant/Enum/EmailStatus.cs @@ -0,0 +1,12 @@ +namespace EmailDispatcherAPI.Constant.Enum +{ + public enum EmailStatus + { + Pending = 1, + Scheduled = 2, + Sent = 3, + Failed = 4, + RetryQueued = 5, + Dead = 6 + } +} diff --git a/EmailDispatcherAPI/Contract/IEmailRepository.cs b/EmailDispatcherAPI/Contract/IEmailRepository.cs new file mode 100644 index 0000000..3bda6d1 --- /dev/null +++ b/EmailDispatcherAPI/Contract/IEmailRepository.cs @@ -0,0 +1,12 @@ +using EmailDispatcherAPI.Modal; + +namespace EmailDispatcherAPI.Contract +{ + public interface IEmailRepository + { + Task GetEmailIdempotencyAsync(string idempotencyKey); + Task CreateEmailLog(EmailLog emailLog); + Task CreateEmailIdempotency(EmailIdempotency emailIdempotency); + Task MarkEmailIdempotencyAsPublishedAsync(int id); + } +} diff --git a/EmailDispatcherAPI/Contract/IEmailService.cs b/EmailDispatcherAPI/Contract/IEmailService.cs new file mode 100644 index 0000000..1082de1 --- /dev/null +++ b/EmailDispatcherAPI/Contract/IEmailService.cs @@ -0,0 +1,8 @@ +namespace EmailDispatcherAPI.Contract +{ + public interface IEmailService + { + Task IsValidEmail(string email); + Task SendEmail(string mailTo,int entityId); + } +} diff --git a/EmailDispatcherAPI/Contract/IRabbitMqConnection.cs b/EmailDispatcherAPI/Contract/IRabbitMqConnection.cs new file mode 100644 index 0000000..beda0e4 --- /dev/null +++ b/EmailDispatcherAPI/Contract/IRabbitMqConnection.cs @@ -0,0 +1,10 @@ +using RabbitMQ.Client; + +namespace EmailDispatcherAPI.Contract +{ + public interface IRabbitMqConnection + { + IConnection Connection { get; } + } + +} diff --git a/EmailDispatcherAPI/Data/AppDBContext.cs b/EmailDispatcherAPI/Data/AppDBContext.cs new file mode 100644 index 0000000..e596191 --- /dev/null +++ b/EmailDispatcherAPI/Data/AppDBContext.cs @@ -0,0 +1,33 @@ +using EmailDispatcherAPI.Modal; +using Microsoft.EntityFrameworkCore; + +namespace EmailDispatcherAPI.Data +{ + public class AppDBContext : DbContext + { + protected readonly IConfiguration Configuration; + public AppDBContext(IConfiguration configuration) + { + Configuration = configuration; + } + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")); + } + public DbSet EmailLog { get; set; } + public DbSet EmailIdempotency { get; set; } + public DbSet EmailStatus { get; set; } + public DbSet EmailActionLog { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasIndex(e => e.MessageKey) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(e => new { e.EmailStatusId, e.NextAttemptAt }); + } + + } +} \ No newline at end of file diff --git a/EmailDispatcherAPI/Dto/RabbitMQConfig.cs b/EmailDispatcherAPI/Dto/RabbitMQConfig.cs new file mode 100644 index 0000000..c9fda31 --- /dev/null +++ b/EmailDispatcherAPI/Dto/RabbitMQConfig.cs @@ -0,0 +1,11 @@ +namespace EmailDispatcherAPI.Dto +{ + public class RabbitMQConfig + { + public string HostName { get; set; } = "localhost"; + public int Port { get; set; } = 5672; + public string UserName { get; set; } = "guest"; + public string Password { get; set; } = "guest"; + } +} + diff --git a/EmailDispatcherAPI/EmailDispatcherAPI.csproj b/EmailDispatcherAPI/EmailDispatcherAPI.csproj new file mode 100644 index 0000000..a970c5d --- /dev/null +++ b/EmailDispatcherAPI/EmailDispatcherAPI.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/EmailDispatcherAPI/EmailDispatcherAPI.csproj.user b/EmailDispatcherAPI/EmailDispatcherAPI.csproj.user new file mode 100644 index 0000000..9ff5820 --- /dev/null +++ b/EmailDispatcherAPI/EmailDispatcherAPI.csproj.user @@ -0,0 +1,6 @@ + + + + https + + \ No newline at end of file diff --git a/EmailDispatcherAPI/EmailDispatcherAPI.http b/EmailDispatcherAPI/EmailDispatcherAPI.http new file mode 100644 index 0000000..5f6dc5e --- /dev/null +++ b/EmailDispatcherAPI/EmailDispatcherAPI.http @@ -0,0 +1,6 @@ +@EmailDispatcherAPI_HostAddress = http://localhost:5213 + +GET {{EmailDispatcherAPI_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/EmailDispatcherAPI/EmailDispatcherAPI.sln b/EmailDispatcherAPI/EmailDispatcherAPI.sln new file mode 100644 index 0000000..dd3227d --- /dev/null +++ b/EmailDispatcherAPI/EmailDispatcherAPI.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.6.33801.468 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailDispatcherAPI", "EmailDispatcherAPI.csproj", "{46D2A6BB-80B6-41F1-AD62-599EFB8799EE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailWorker", "..\EmailWorker\EmailWorker.csproj", "{F70C08CC-2CDD-4EC4-BDDA-C99C994D2D40}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailRetryScheduler", "..\EmailRetryScheduler\EmailRetryScheduler.csproj", "{C666BCBF-D1C1-470A-BE3F-EA19BAEC5370}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {46D2A6BB-80B6-41F1-AD62-599EFB8799EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {46D2A6BB-80B6-41F1-AD62-599EFB8799EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {46D2A6BB-80B6-41F1-AD62-599EFB8799EE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {46D2A6BB-80B6-41F1-AD62-599EFB8799EE}.Release|Any CPU.Build.0 = Release|Any CPU + {F70C08CC-2CDD-4EC4-BDDA-C99C994D2D40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F70C08CC-2CDD-4EC4-BDDA-C99C994D2D40}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F70C08CC-2CDD-4EC4-BDDA-C99C994D2D40}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F70C08CC-2CDD-4EC4-BDDA-C99C994D2D40}.Release|Any CPU.Build.0 = Release|Any CPU + {C666BCBF-D1C1-470A-BE3F-EA19BAEC5370}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C666BCBF-D1C1-470A-BE3F-EA19BAEC5370}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C666BCBF-D1C1-470A-BE3F-EA19BAEC5370}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C666BCBF-D1C1-470A-BE3F-EA19BAEC5370}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {6CD7770A-E955-4903-AB51-0582BCF4B728} + EndGlobalSection +EndGlobal diff --git a/EmailDispatcherAPI/EmailDispatcherAPI.slnLaunch.user b/EmailDispatcherAPI/EmailDispatcherAPI.slnLaunch.user new file mode 100644 index 0000000..208b034 --- /dev/null +++ b/EmailDispatcherAPI/EmailDispatcherAPI.slnLaunch.user @@ -0,0 +1,19 @@ +[ + { + "Name": "New Profile", + "Projects": [ + { + "Path": "EmailDispatcherAPI.csproj", + "Action": "Start" + }, + { + "Path": "..\\EmailWorker\\EmailWorker.csproj", + "Action": "Start" + }, + { + "Path": "..\\EmailRetryScheduler\\EmailRetryScheduler.csproj", + "Action": "Start" + } + ] + } +] \ No newline at end of file diff --git a/EmailDispatcherAPI/Exception/CustomException.cs b/EmailDispatcherAPI/Exception/CustomException.cs new file mode 100644 index 0000000..25c71fa --- /dev/null +++ b/EmailDispatcherAPI/Exception/CustomException.cs @@ -0,0 +1,9 @@ +namespace EmailDispatcherAPI.Exception +{ + public sealed class ResourceAlreadyExistsException : System.Exception + { + public ResourceAlreadyExistsException(string message) : base(message) {} + } +} + + diff --git a/EmailDispatcherAPI/Exception/GlobalExceptionHandler.cs b/EmailDispatcherAPI/Exception/GlobalExceptionHandler.cs new file mode 100644 index 0000000..572ef28 --- /dev/null +++ b/EmailDispatcherAPI/Exception/GlobalExceptionHandler.cs @@ -0,0 +1,51 @@ +using EmailDispatcherAPI.Exception; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +public sealed class GlobalExceptionHandler : IExceptionHandler +{ + private readonly ILogger _logger; + + public GlobalExceptionHandler(ILogger logger) + { + _logger = logger; + } + + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + _logger.LogError(exception, "Unhandled exception"); + + var problemDetails = exception switch + { + ResourceAlreadyExistsException ex => new ProblemDetails + { + Status = StatusCodes.Status409Conflict, + Title = "Resource already exists", + Detail = ex.Message + }, + + ArgumentException ex => new ProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Title = "Bad request", + Detail = ex.Message + }, + + _ => new ProblemDetails + { + Status = StatusCodes.Status500InternalServerError, + Title = "Internal Server Error", + Detail = "An unexpected error occurred" + } + }; + + httpContext.Response.StatusCode = problemDetails.Status!.Value; + httpContext.Response.ContentType = "application/problem+json"; + + await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + return true; + } +} diff --git a/EmailDispatcherAPI/Migrations/20260103103459_BaseSetup.Designer.cs b/EmailDispatcherAPI/Migrations/20260103103459_BaseSetup.Designer.cs new file mode 100644 index 0000000..98dd99b --- /dev/null +++ b/EmailDispatcherAPI/Migrations/20260103103459_BaseSetup.Designer.cs @@ -0,0 +1,122 @@ +// +using System; +using EmailDispatcherAPI.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EmailDispatcherAPI.Migrations +{ + [DbContext(typeof(AppDBContext))] + [Migration("20260103103459_BaseSetup")] + partial class BaseSetup + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailIdempotency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailId") + .HasColumnType("uniqueidentifier"); + + b.Property("MessageKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("MessageKey") + .IsUnique(); + + b.ToTable("EmailIdempotency"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttemptCount") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailStatusId") + .HasColumnType("int"); + + b.Property("LastError") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("MessageKey") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("EmailStatusId"); + + b.ToTable("EmailLog"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("EmailStatus"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailLog", b => + { + b.HasOne("EmailDispatcherAPI.Modal.EmailStatus", "EmailStatus") + .WithMany() + .HasForeignKey("EmailStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmailStatus"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EmailDispatcherAPI/Migrations/20260103103459_BaseSetup.cs b/EmailDispatcherAPI/Migrations/20260103103459_BaseSetup.cs new file mode 100644 index 0000000..841510a --- /dev/null +++ b/EmailDispatcherAPI/Migrations/20260103103459_BaseSetup.cs @@ -0,0 +1,106 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EmailDispatcherAPI.Migrations +{ + /// + public partial class BaseSetup : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "EmailIdempotency", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + MessageKey = table.Column(type: "nvarchar(450)", nullable: false), + EmailId = table.Column(type: "uniqueidentifier", nullable: false), + CompletedAt = table.Column(type: "datetime2", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EmailIdempotency", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "EmailStatus", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Status = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EmailStatus", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "EmailLog", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + AttemptCount = table.Column(type: "int", nullable: false), + MessageKey = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + SentAt = table.Column(type: "datetime2", nullable: false), + LastError = table.Column(type: "nvarchar(max)", nullable: false), + EmailStatusId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EmailLog", x => x.Id); + table.ForeignKey( + name: "FK_EmailLog_EmailStatus_EmailStatusId", + column: x => x.EmailStatusId, + principalTable: "EmailStatus", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_EmailIdempotency_MessageKey", + table: "EmailIdempotency", + column: "MessageKey", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_EmailLog_EmailStatusId", + table: "EmailLog", + column: "EmailStatusId"); + + migrationBuilder.Sql(@" + IF NOT EXISTS (SELECT 1 FROM EmailStatus) + BEGIN + INSERT INTO EmailStatus (Status) + VALUES + ('Pending'), + ('Scheduled'), + ('Sent'), + ('Failed'), + ('RetryQueued'), + ('Dead'); + END + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "EmailIdempotency"); + + migrationBuilder.DropTable( + name: "EmailLog"); + + migrationBuilder.DropTable( + name: "EmailStatus"); + } + } +} diff --git a/EmailDispatcherAPI/Migrations/20260103105511_FieldUpdate.Designer.cs b/EmailDispatcherAPI/Migrations/20260103105511_FieldUpdate.Designer.cs new file mode 100644 index 0000000..21b4389 --- /dev/null +++ b/EmailDispatcherAPI/Migrations/20260103105511_FieldUpdate.Designer.cs @@ -0,0 +1,124 @@ +// +using System; +using EmailDispatcherAPI.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EmailDispatcherAPI.Migrations +{ + [DbContext(typeof(AppDBContext))] + [Migration("20260103105511_FieldUpdate")] + partial class FieldUpdate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailIdempotency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailId") + .HasColumnType("uniqueidentifier"); + + b.Property("MessageKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("MessageKey") + .IsUnique(); + + b.ToTable("EmailIdempotency"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttemptCount") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailStatusId") + .HasColumnType("int"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("LockedUntil") + .HasColumnType("datetime2"); + + b.Property("MessageKey") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("EmailStatusId"); + + b.ToTable("EmailLog"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("EmailStatus"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailLog", b => + { + b.HasOne("EmailDispatcherAPI.Modal.EmailStatus", "EmailStatus") + .WithMany() + .HasForeignKey("EmailStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmailStatus"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EmailDispatcherAPI/Migrations/20260103105511_FieldUpdate.cs b/EmailDispatcherAPI/Migrations/20260103105511_FieldUpdate.cs new file mode 100644 index 0000000..b1cfb2d --- /dev/null +++ b/EmailDispatcherAPI/Migrations/20260103105511_FieldUpdate.cs @@ -0,0 +1,65 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EmailDispatcherAPI.Migrations +{ + /// + public partial class FieldUpdate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "SentAt", + table: "EmailLog", + type: "datetime2", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "datetime2"); + + migrationBuilder.AlterColumn( + name: "LastError", + table: "EmailLog", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + + migrationBuilder.AddColumn( + name: "LockedUntil", + table: "EmailLog", + type: "datetime2", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LockedUntil", + table: "EmailLog"); + + migrationBuilder.AlterColumn( + name: "SentAt", + table: "EmailLog", + type: "datetime2", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + oldClrType: typeof(DateTime), + oldType: "datetime2", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "LastError", + table: "EmailLog", + type: "nvarchar(max)", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldNullable: true); + } + } +} diff --git a/EmailDispatcherAPI/Migrations/20260103110103_ModalUpdate.Designer.cs b/EmailDispatcherAPI/Migrations/20260103110103_ModalUpdate.Designer.cs new file mode 100644 index 0000000..540f390 --- /dev/null +++ b/EmailDispatcherAPI/Migrations/20260103110103_ModalUpdate.Designer.cs @@ -0,0 +1,145 @@ +// +using System; +using EmailDispatcherAPI.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EmailDispatcherAPI.Migrations +{ + [DbContext(typeof(AppDBContext))] + [Migration("20260103110103_ModalUpdate")] + partial class ModalUpdate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailIdempotency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailId") + .HasColumnType("uniqueidentifier"); + + b.Property("MessageKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("MessageKey") + .IsUnique(); + + b.ToTable("EmailIdempotency"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttemptCount") + .HasColumnType("int"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailIdempotencyId") + .HasColumnType("int"); + + b.Property("EmailStatusId") + .HasColumnType("int"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("LockedUntil") + .HasColumnType("datetime2"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ToAddress") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmailIdempotencyId"); + + b.HasIndex("EmailStatusId"); + + b.ToTable("EmailLog"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("EmailStatus"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailLog", b => + { + b.HasOne("EmailDispatcherAPI.Modal.EmailIdempotency", "EmailIdempotency") + .WithMany() + .HasForeignKey("EmailIdempotencyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EmailDispatcherAPI.Modal.EmailStatus", "EmailStatus") + .WithMany() + .HasForeignKey("EmailStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmailIdempotency"); + + b.Navigation("EmailStatus"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EmailDispatcherAPI/Migrations/20260103110103_ModalUpdate.cs b/EmailDispatcherAPI/Migrations/20260103110103_ModalUpdate.cs new file mode 100644 index 0000000..fdef855 --- /dev/null +++ b/EmailDispatcherAPI/Migrations/20260103110103_ModalUpdate.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EmailDispatcherAPI.Migrations +{ + /// + public partial class ModalUpdate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "MessageKey", + table: "EmailLog", + newName: "ToAddress"); + + migrationBuilder.AddColumn( + name: "Body", + table: "EmailLog", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "EmailIdempotencyId", + table: "EmailLog", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "Subject", + table: "EmailLog", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateIndex( + name: "IX_EmailLog_EmailIdempotencyId", + table: "EmailLog", + column: "EmailIdempotencyId"); + + migrationBuilder.AddForeignKey( + name: "FK_EmailLog_EmailIdempotency_EmailIdempotencyId", + table: "EmailLog", + column: "EmailIdempotencyId", + principalTable: "EmailIdempotency", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_EmailLog_EmailIdempotency_EmailIdempotencyId", + table: "EmailLog"); + + migrationBuilder.DropIndex( + name: "IX_EmailLog_EmailIdempotencyId", + table: "EmailLog"); + + migrationBuilder.DropColumn( + name: "Body", + table: "EmailLog"); + + migrationBuilder.DropColumn( + name: "EmailIdempotencyId", + table: "EmailLog"); + + migrationBuilder.DropColumn( + name: "Subject", + table: "EmailLog"); + + migrationBuilder.RenameColumn( + name: "ToAddress", + table: "EmailLog", + newName: "MessageKey"); + } + } +} diff --git a/EmailDispatcherAPI/Migrations/20260106091529_EmailIdempotency publish state inclusion.Designer.cs b/EmailDispatcherAPI/Migrations/20260106091529_EmailIdempotency publish state inclusion.Designer.cs new file mode 100644 index 0000000..2691b3d --- /dev/null +++ b/EmailDispatcherAPI/Migrations/20260106091529_EmailIdempotency publish state inclusion.Designer.cs @@ -0,0 +1,148 @@ +// +using System; +using EmailDispatcherAPI.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EmailDispatcherAPI.Migrations +{ + [DbContext(typeof(AppDBContext))] + [Migration("20260106091529_EmailIdempotency publish state inclusion")] + partial class EmailIdempotencypublishstateinclusion + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailIdempotency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("MessageKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("MessageKey") + .IsUnique(); + + b.ToTable("EmailIdempotency"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttemptCount") + .HasColumnType("int"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailIdempotencyId") + .HasColumnType("int"); + + b.Property("EmailStatusId") + .HasColumnType("int"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("LockedUntil") + .HasColumnType("datetime2"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ToAddress") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmailIdempotencyId"); + + b.HasIndex("EmailStatusId"); + + b.ToTable("EmailLog"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("EmailStatus"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailLog", b => + { + b.HasOne("EmailDispatcherAPI.Modal.EmailIdempotency", "EmailIdempotency") + .WithMany() + .HasForeignKey("EmailIdempotencyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EmailDispatcherAPI.Modal.EmailStatus", "EmailStatus") + .WithMany() + .HasForeignKey("EmailStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmailIdempotency"); + + b.Navigation("EmailStatus"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EmailDispatcherAPI/Migrations/20260106091529_EmailIdempotency publish state inclusion.cs b/EmailDispatcherAPI/Migrations/20260106091529_EmailIdempotency publish state inclusion.cs new file mode 100644 index 0000000..5bb1f80 --- /dev/null +++ b/EmailDispatcherAPI/Migrations/20260106091529_EmailIdempotency publish state inclusion.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EmailDispatcherAPI.Migrations +{ + /// + public partial class EmailIdempotencypublishstateinclusion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsPublished", + table: "EmailIdempotency", + type: "bit", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsPublished", + table: "EmailIdempotency"); + } + } +} diff --git a/EmailDispatcherAPI/Migrations/20260107192708_Added Action log.Designer.cs b/EmailDispatcherAPI/Migrations/20260107192708_Added Action log.Designer.cs new file mode 100644 index 0000000..a68e784 --- /dev/null +++ b/EmailDispatcherAPI/Migrations/20260107192708_Added Action log.Designer.cs @@ -0,0 +1,155 @@ +// +using System; +using EmailDispatcherAPI.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EmailDispatcherAPI.Migrations +{ + [DbContext(typeof(AppDBContext))] + [Migration("20260107192708_Added Action log")] + partial class AddedActionlog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailIdempotency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("MessageKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("MessageKey") + .IsUnique(); + + b.ToTable("EmailIdempotency"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttemptCount") + .HasColumnType("int"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailIdempotencyId") + .HasColumnType("int"); + + b.Property("EmailStatusId") + .HasColumnType("int"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("LockedUntil") + .HasColumnType("datetime2"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ToAddress") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmailIdempotencyId") + .IsUnique(); + + b.HasIndex("EmailStatusId"); + + b.ToTable("EmailLog"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("EmailStatus"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailLog", b => + { + b.HasOne("EmailDispatcherAPI.Modal.EmailIdempotency", "EmailIdempotency") + .WithOne("EmailLog") + .HasForeignKey("EmailDispatcherAPI.Modal.EmailLog", "EmailIdempotencyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EmailDispatcherAPI.Modal.EmailStatus", "EmailStatus") + .WithMany() + .HasForeignKey("EmailStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmailIdempotency"); + + b.Navigation("EmailStatus"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailIdempotency", b => + { + b.Navigation("EmailLog") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EmailDispatcherAPI/Migrations/20260107192823_Added Action log.cs b/EmailDispatcherAPI/Migrations/20260107192823_Added Action log.cs new file mode 100644 index 0000000..1337ae5 --- /dev/null +++ b/EmailDispatcherAPI/Migrations/20260107192823_Added Action log.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EmailDispatcherAPI.Migrations +{ + /// + public partial class AddedActionlog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "EmailActionLog", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + EmailId = table.Column(type: "uniqueidentifier", nullable: false), + Message = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EmailActionLog", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "EmailActionLog"); + } + } +} diff --git a/EmailDispatcherAPI/Migrations/20260108103440_Added Next Attempt.Designer.cs b/EmailDispatcherAPI/Migrations/20260108103440_Added Next Attempt.Designer.cs new file mode 100644 index 0000000..a64a53a --- /dev/null +++ b/EmailDispatcherAPI/Migrations/20260108103440_Added Next Attempt.Designer.cs @@ -0,0 +1,181 @@ +// +using System; +using EmailDispatcherAPI.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EmailDispatcherAPI.Migrations +{ + [DbContext(typeof(AppDBContext))] + [Migration("20260108103440_Added Next Attempt")] + partial class AddedNextAttempt + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailActionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailId") + .HasColumnType("uniqueidentifier"); + + b.Property("Message") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("EmailActionLog"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailIdempotency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("MessageKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("MessageKey") + .IsUnique(); + + b.ToTable("EmailIdempotency"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttemptCount") + .HasColumnType("int"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailIdempotencyId") + .HasColumnType("int"); + + b.Property("EmailStatusId") + .HasColumnType("int"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("LockedUntil") + .HasColumnType("datetime2"); + + b.Property("NextAttemptAt") + .HasColumnType("datetime2"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ToAddress") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmailIdempotencyId") + .IsUnique(); + + b.HasIndex("EmailStatusId"); + + b.ToTable("EmailLog"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("EmailStatus"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailLog", b => + { + b.HasOne("EmailDispatcherAPI.Modal.EmailIdempotency", "EmailIdempotency") + .WithOne("EmailLog") + .HasForeignKey("EmailDispatcherAPI.Modal.EmailLog", "EmailIdempotencyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EmailDispatcherAPI.Modal.EmailStatus", "EmailStatus") + .WithMany() + .HasForeignKey("EmailStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmailIdempotency"); + + b.Navigation("EmailStatus"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailIdempotency", b => + { + b.Navigation("EmailLog") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EmailDispatcherAPI/Migrations/20260108103440_Added Next Attempt.cs b/EmailDispatcherAPI/Migrations/20260108103440_Added Next Attempt.cs new file mode 100644 index 0000000..18a4598 --- /dev/null +++ b/EmailDispatcherAPI/Migrations/20260108103440_Added Next Attempt.cs @@ -0,0 +1,29 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EmailDispatcherAPI.Migrations +{ + /// + public partial class AddedNextAttempt : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "NextAttemptAt", + table: "EmailLog", + type: "datetime2", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "NextAttemptAt", + table: "EmailLog"); + } + } +} diff --git a/EmailDispatcherAPI/Migrations/AppDBContextModelSnapshot.cs b/EmailDispatcherAPI/Migrations/AppDBContextModelSnapshot.cs new file mode 100644 index 0000000..c2e3b2b --- /dev/null +++ b/EmailDispatcherAPI/Migrations/AppDBContextModelSnapshot.cs @@ -0,0 +1,178 @@ +// +using System; +using EmailDispatcherAPI.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EmailDispatcherAPI.Migrations +{ + [DbContext(typeof(AppDBContext))] + partial class AppDBContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailActionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailId") + .HasColumnType("uniqueidentifier"); + + b.Property("Message") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("EmailActionLog"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailIdempotency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("MessageKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("MessageKey") + .IsUnique(); + + b.ToTable("EmailIdempotency"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttemptCount") + .HasColumnType("int"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailIdempotencyId") + .HasColumnType("int"); + + b.Property("EmailStatusId") + .HasColumnType("int"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("LockedUntil") + .HasColumnType("datetime2"); + + b.Property("NextAttemptAt") + .HasColumnType("datetime2"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ToAddress") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmailIdempotencyId") + .IsUnique(); + + b.HasIndex("EmailStatusId"); + + b.ToTable("EmailLog"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("EmailStatus"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailLog", b => + { + b.HasOne("EmailDispatcherAPI.Modal.EmailIdempotency", "EmailIdempotency") + .WithOne("EmailLog") + .HasForeignKey("EmailDispatcherAPI.Modal.EmailLog", "EmailIdempotencyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EmailDispatcherAPI.Modal.EmailStatus", "EmailStatus") + .WithMany() + .HasForeignKey("EmailStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmailIdempotency"); + + b.Navigation("EmailStatus"); + }); + + modelBuilder.Entity("EmailDispatcherAPI.Modal.EmailIdempotency", b => + { + b.Navigation("EmailLog") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EmailDispatcherAPI/Modal/EmailActionLog.cs b/EmailDispatcherAPI/Modal/EmailActionLog.cs new file mode 100644 index 0000000..eee5482 --- /dev/null +++ b/EmailDispatcherAPI/Modal/EmailActionLog.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace EmailDispatcherAPI.Modal +{ + public class EmailActionLog + { + [Key] + public int Id { get; set; } + public Guid EmailId { get; set; } + public string Message { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/EmailDispatcherAPI/Modal/EmailIdempotency.cs b/EmailDispatcherAPI/Modal/EmailIdempotency.cs new file mode 100644 index 0000000..d2af4b1 --- /dev/null +++ b/EmailDispatcherAPI/Modal/EmailIdempotency.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace EmailDispatcherAPI.Modal +{ + public class EmailIdempotency + { + [Key] + public int Id { get; set; } + public string MessageKey { get; set; } + public Guid EmailId { get; set; } + public bool IsPublished { get; set; } + public DateTime? CompletedAt { get; set; } + public DateTime CreatedAt { get; set; } + public EmailLog EmailLog { get; set; } + + } +} diff --git a/EmailDispatcherAPI/Modal/EmailLog .cs b/EmailDispatcherAPI/Modal/EmailLog .cs new file mode 100644 index 0000000..615dab8 --- /dev/null +++ b/EmailDispatcherAPI/Modal/EmailLog .cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace EmailDispatcherAPI.Modal +{ + public class EmailLog + { + [Key] + public int Id { get; set; } + public int AttemptCount { get; set; } + public string ToAddress { get; set; } + public string Subject { get; set; } + public string Body { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? SentAt { get; set; } + public DateTime? LockedUntil { get; set; } + public DateTime? NextAttemptAt { get; set; } + public string? LastError { get; set; } + public int EmailStatusId { get; set; } + public EmailStatus? EmailStatus { get; set; } + public int EmailIdempotencyId { get; set; } + public EmailIdempotency? EmailIdempotency { get; set; } + + } +} diff --git a/EmailDispatcherAPI/Modal/EmailStatus.cs b/EmailDispatcherAPI/Modal/EmailStatus.cs new file mode 100644 index 0000000..c9e2714 --- /dev/null +++ b/EmailDispatcherAPI/Modal/EmailStatus.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace EmailDispatcherAPI.Modal +{ + public class EmailStatus + { + [Key] + public int Id { get; set; } + public string Status { get; set; } + } +} diff --git a/EmailDispatcherAPI/Program.cs b/EmailDispatcherAPI/Program.cs new file mode 100644 index 0000000..f8503e9 --- /dev/null +++ b/EmailDispatcherAPI/Program.cs @@ -0,0 +1,82 @@ + +using EmailDispatcherAPI.Contract; +using EmailDispatcherAPI.Data; +using EmailDispatcherAPI.Dto; +using EmailDispatcherAPI.Repository; +using EmailDispatcherAPI.Service; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; + +namespace EmailDispatcherAPI +{ + public class Program + { + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + builder.Services.AddExceptionHandler(); + builder.Services.AddProblemDetails(); + builder.Services.AddDbContext(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + //Asynchronous Initialization via Hosted Service + builder.Services.Configure(builder.Configuration.GetSection("RabbitMQ")); + builder.Services.AddSingleton(); + builder.Services.AddSingleton( + sp => sp.GetRequiredService() + ); + + builder.Services.AddHostedService(); + + + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = "My Minimal API", Version = "v1" }); + }); + // Add services to the container. + builder.Services.AddAuthorization(); + + // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi + builder.Services.AddOpenApi(); + + var app = builder.Build(); + + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi(); + app.UseSwagger(); + app.UseSwaggerUI(); + app.UseDeveloperExceptionPage(); + } + else { + app.UseExceptionHandler(); + } + app.UseHttpsRedirection(); + + app.UseAuthorization(); + + var summaries = new[] + { + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + }; + + app.MapPost("/sendEmail", async (HttpContext httpContext, IEmailService emailService,string mailTo,int entityId) => + { + if (mailTo.IsNullOrEmpty() || !await emailService.IsValidEmail(mailTo)) { + throw new ArgumentException("Invalid Email ID"); + } + if (entityId == default(int) || entityId < 1) { + throw new ArgumentException("Invalid Entity Id"); + } + await emailService.SendEmail(mailTo, entityId); + return Results.Ok("Mail Scheduled SuccessFully"); + }) + .WithName("SendEmailNotification"); + + app.Run(); + } + } +} \ No newline at end of file diff --git a/EmailDispatcherAPI/Properties/launchSettings.json b/EmailDispatcherAPI/Properties/launchSettings.json new file mode 100644 index 0000000..f89306c --- /dev/null +++ b/EmailDispatcherAPI/Properties/launchSettings.json @@ -0,0 +1,26 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5213" + + }, + "https": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7068;http://localhost:5213" + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} \ No newline at end of file diff --git a/EmailDispatcherAPI/Repository/EmailRepository.cs b/EmailDispatcherAPI/Repository/EmailRepository.cs new file mode 100644 index 0000000..e729e92 --- /dev/null +++ b/EmailDispatcherAPI/Repository/EmailRepository.cs @@ -0,0 +1,39 @@ +using EmailDispatcherAPI.Contract; +using EmailDispatcherAPI.Data; +using EmailDispatcherAPI.Modal; +using Microsoft.EntityFrameworkCore; + +namespace EmailDispatcherAPI.Repository +{ + public class EmailRepository : IEmailRepository + { + private AppDBContext dBContext; + + public EmailRepository(AppDBContext dBContext) { + this.dBContext = dBContext; + } + + public async Task GetEmailIdempotencyAsync(string idempotencyKey) { + return await this.dBContext.EmailIdempotency.AsNoTracking().FirstOrDefaultAsync(e => e.MessageKey == idempotencyKey); + } + + public async Task CreateEmailLog(EmailLog emailLog) { + await this.dBContext.EmailLog.AddAsync(emailLog); + await this.dBContext.SaveChangesAsync(); + return emailLog; + } + + public async Task CreateEmailIdempotency(EmailIdempotency emailIdempotency) { + await this.dBContext.EmailIdempotency.AddAsync(emailIdempotency); + await this.dBContext.SaveChangesAsync(); + return emailIdempotency; + } + + public async Task MarkEmailIdempotencyAsPublishedAsync(int id) { + var emailIdempotency = await this.dBContext.EmailIdempotency.FindAsync(id); + emailIdempotency.IsPublished = true; + return await this.dBContext.SaveChangesAsync() != default(int) ? true : false; + } + + } +} diff --git a/EmailDispatcherAPI/Service/EmailService.cs b/EmailDispatcherAPI/Service/EmailService.cs new file mode 100644 index 0000000..0d63812 --- /dev/null +++ b/EmailDispatcherAPI/Service/EmailService.cs @@ -0,0 +1,77 @@ +using EmailDispatcherAPI.Constant; +using EmailDispatcherAPI.Contract; +using EmailDispatcherAPI.Exception; +using EmailDispatcherAPI.Modal; +using Newtonsoft.Json; +using RabbitMQ.Client; +using System.ComponentModel.DataAnnotations; +using System.Text; + +namespace EmailDispatcherAPI.Service +{ + public class EmailService : IEmailService + { + private readonly IEmailRepository _emailRepository; + private readonly IRabbitMqConnection _rabbitMqConnection; + + public EmailService(IEmailRepository emailRepository, IRabbitMqConnection rabbitMqConnection) { + this._emailRepository = emailRepository; + this._rabbitMqConnection = rabbitMqConnection; + } + + public async Task IsValidEmail(string email) + { + var emailAttr = new EmailAddressAttribute(); + return emailAttr.IsValid(email); + } + + + public async Task SendEmail(string mailTo,int entityId) { + var emailIdempotency = await this.CreateEmailIdempotency(entityId); + await this._emailRepository.CreateEmailLog(new Modal.EmailLog + { + EmailStatusId = (int)Constant.Enum.EmailStatus.Pending, + EmailIdempotencyId = emailIdempotency.Id, + ToAddress = mailTo, + Subject = "Test Mail", + Body = "Message through message broker", + CreatedAt = DateTime.Now, + }); + await this.InsertMessageToRabbitMQ(emailIdempotency); + } + + private async Task CreateEmailIdempotency(int entityKey) { + var messageKey = $"SuccessMail:{entityKey}"; + var existsEmailIdempotencyKey = await this._emailRepository.GetEmailIdempotencyAsync(messageKey); + if (existsEmailIdempotencyKey != null) { + throw new ResourceAlreadyExistsException("Email already in process"); + } + return await this._emailRepository.CreateEmailIdempotency(new EmailIdempotency + { + MessageKey = messageKey, + EmailId = Guid.NewGuid(), + CreatedAt = DateTime.Now, + IsPublished = false + }); + } + + private async Task InsertMessageToRabbitMQ(EmailIdempotency emailIdempotency) { + var rabbitMQPayload = new { + MessageKey = emailIdempotency.MessageKey, + EmailId = emailIdempotency.EmailId + }; + var jsonPayload = JsonConvert.SerializeObject(rabbitMQPayload); + using (var channel = await this._rabbitMqConnection.Connection.CreateChannelAsync()) + { + var props = new BasicProperties + { + Persistent = true + }; + await channel.QueueDeclareAsync(queue: AppConstant.QueueName, durable: true, exclusive: false, autoDelete: false, arguments: null); + var body = Encoding.UTF8.GetBytes(jsonPayload); + await channel.BasicPublishAsync(exchange: string.Empty, routingKey: AppConstant.QueueName, true, basicProperties: props, body: body); + } + await this._emailRepository.MarkEmailIdempotencyAsPublishedAsync(emailIdempotency.Id); + } + } +} \ No newline at end of file diff --git a/EmailDispatcherAPI/Service/RabbitMqConnection.cs b/EmailDispatcherAPI/Service/RabbitMqConnection.cs new file mode 100644 index 0000000..f735716 --- /dev/null +++ b/EmailDispatcherAPI/Service/RabbitMqConnection.cs @@ -0,0 +1,16 @@ +using EmailDispatcherAPI.Contract; +using RabbitMQ.Client; + +namespace EmailDispatcherAPI.Service +{ + public sealed class RabbitMqConnection : IRabbitMqConnection + { + public IConnection Connection { get; private set; } = default!; + + internal void SetConnection(IConnection connection) + { + Connection = connection; + } + } + +} \ No newline at end of file diff --git a/EmailDispatcherAPI/Service/RabbitMqHostedService .cs b/EmailDispatcherAPI/Service/RabbitMqHostedService .cs new file mode 100644 index 0000000..4338a3a --- /dev/null +++ b/EmailDispatcherAPI/Service/RabbitMqHostedService .cs @@ -0,0 +1,38 @@ +using EmailDispatcherAPI.Dto; +using Microsoft.Extensions.Options; +using RabbitMQ.Client; + +namespace EmailDispatcherAPI.Service +{ + public sealed class RabbitMqHostedService : IHostedService + { + private readonly RabbitMqConnection _connection; + private readonly ConnectionFactory _factory; + + public RabbitMqHostedService(RabbitMqConnection connection, IOptions rabbitMQConfig) + { + _connection = connection; + var config = rabbitMQConfig.Value; + _factory = new ConnectionFactory + { + HostName = config.HostName, + Port = config.Port, + UserName = config.UserName, + Password = config.Password + }; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var conn = await _factory.CreateConnectionAsync(cancellationToken); + _connection.SetConnection(conn); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _connection.Connection?.Dispose(); + return Task.CompletedTask; + } + } + +} \ No newline at end of file diff --git a/EmailRetryScheduler/Constant/AppConstant.cs b/EmailRetryScheduler/Constant/AppConstant.cs new file mode 100644 index 0000000..851f54a --- /dev/null +++ b/EmailRetryScheduler/Constant/AppConstant.cs @@ -0,0 +1,9 @@ +namespace EmailRetryScheduler.Constant +{ + internal class AppConstant + { + public const string QueueName = "email.dispatcher.send"; + public const string DLQQueueName = "email.dispatcher.dlq"; + public const int LeaseLockTime = 10; + } +} diff --git a/EmailRetryScheduler/Constant/Enum/EmailStatus.cs b/EmailRetryScheduler/Constant/Enum/EmailStatus.cs new file mode 100644 index 0000000..78756d3 --- /dev/null +++ b/EmailRetryScheduler/Constant/Enum/EmailStatus.cs @@ -0,0 +1,12 @@ +namespace EmailRetryScheduler.Constant.Enum +{ + public enum EmailStatus + { + Pending = 1, + Scheduled = 2, + Sent = 3, + Failed = 4, + RetryQueued = 5, + Dead = 6 + } +} diff --git a/EmailRetryScheduler/Contract/IEmailRepository.cs b/EmailRetryScheduler/Contract/IEmailRepository.cs new file mode 100644 index 0000000..ea0a69f --- /dev/null +++ b/EmailRetryScheduler/Contract/IEmailRepository.cs @@ -0,0 +1,12 @@ +using EmailRetryScheduler.Modal; + +namespace EmailRetryScheduler.Contract +{ + public interface IEmailRepository + { + Task GetEmailIdempotencyAsync(Guid emailId); + Task> GetRetryMailsForSend(); + Task MarkMailAsPublished(Guid id); + Task InsertEmailActionLog(EmailActionLog actionLog); + } +} diff --git a/EmailRetryScheduler/Contract/IEmailService.cs b/EmailRetryScheduler/Contract/IEmailService.cs new file mode 100644 index 0000000..10eb2f4 --- /dev/null +++ b/EmailRetryScheduler/Contract/IEmailService.cs @@ -0,0 +1,8 @@ + +namespace EmailRetryScheduler.Contract +{ + public interface IEmailService + { + Task RescheduleFailedMailsToSend(); + } +} diff --git a/EmailRetryScheduler/Contract/IRabbitMQService .cs b/EmailRetryScheduler/Contract/IRabbitMQService .cs new file mode 100644 index 0000000..bb69589 --- /dev/null +++ b/EmailRetryScheduler/Contract/IRabbitMQService .cs @@ -0,0 +1,10 @@ +using EmailRetryScheduler.Modal; + +namespace EmailRetryScheduler.Contract +{ + public interface IRabbitMQService + { + Task CreateConnection(CancellationToken cancellationToken); + Task InsertMessageToRabbitMQ(EmailIdempotency emailIdempotency, string queueName); + } +} diff --git a/EmailRetryScheduler/Data/AppDBContext.cs b/EmailRetryScheduler/Data/AppDBContext.cs new file mode 100644 index 0000000..930bddc --- /dev/null +++ b/EmailRetryScheduler/Data/AppDBContext.cs @@ -0,0 +1,28 @@ +using EmailRetryScheduler.Modal; +using Microsoft.EntityFrameworkCore; + +namespace EmailRetryScheduler.Data +{ + public class AppDBContext : DbContext + { + protected readonly IConfiguration Configuration; + public AppDBContext(IConfiguration configuration) + { + Configuration = configuration; + } + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")); + } + public DbSet EmailLog { get; set; } + public DbSet EmailIdempotency { get; set; } + public DbSet EmailStatus { get; set; } + public DbSet EmailActionLog { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasIndex(e => new { e.EmailStatusId, e.NextAttemptAt }); + } + } +} \ No newline at end of file diff --git a/EmailRetryScheduler/Dto/RabbitMQConfig.cs b/EmailRetryScheduler/Dto/RabbitMQConfig.cs new file mode 100644 index 0000000..fa0774b --- /dev/null +++ b/EmailRetryScheduler/Dto/RabbitMQConfig.cs @@ -0,0 +1,11 @@ +namespace EmailRetryScheduler.Dto +{ + public class RabbitMQConfig + { + public string HostName { get; set; } = "localhost"; + public int Port { get; set; } = 5672; + public string UserName { get; set; } = "guest"; + public string Password { get; set; } = "guest"; + } +} + diff --git a/EmailRetryScheduler/Dto/RabitMQDto.cs b/EmailRetryScheduler/Dto/RabitMQDto.cs new file mode 100644 index 0000000..42ba734 --- /dev/null +++ b/EmailRetryScheduler/Dto/RabitMQDto.cs @@ -0,0 +1,8 @@ +namespace EmailRetryScheduler.Dto +{ + public class RabitMQDto + { + public string MessageKey { get; set; } + public Guid EmailId { get; set; } + } +} diff --git a/EmailRetryScheduler/EmailRetryScheduler.cs b/EmailRetryScheduler/EmailRetryScheduler.cs new file mode 100644 index 0000000..bc0d14b --- /dev/null +++ b/EmailRetryScheduler/EmailRetryScheduler.cs @@ -0,0 +1,46 @@ +using EmailRetryScheduler.Contract; +using EmailRetryScheduler.Dto; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace EmailRetryScheduler +{ + public class EmailRetryScheduler : BackgroundService + { + private readonly IRabbitMQService _rabbitMQService; + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly ILogger _logger; + + public EmailRetryScheduler( + IRabbitMQService rabbitMQService, + IServiceScopeFactory serviceScopeFactory, + ILogger logger) + { + _rabbitMQService = rabbitMQService; + _serviceScopeFactory = serviceScopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + await _rabbitMQService.CreateConnection(cancellationToken); + while (!cancellationToken.IsCancellationRequested) + { + try + { + using (IServiceScope scope = _serviceScopeFactory.CreateAsyncScope()) + { + var emailService = scope.ServiceProvider.GetRequiredService(); + await emailService.RescheduleFailedMailsToSend(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while rescheduling failed emails. The scheduler will continue running."); + } + + await Task.Delay(10000, cancellationToken); + } + } + } +} diff --git a/EmailRetryScheduler/EmailRetryScheduler.csproj b/EmailRetryScheduler/EmailRetryScheduler.csproj new file mode 100644 index 0000000..fba3a23 --- /dev/null +++ b/EmailRetryScheduler/EmailRetryScheduler.csproj @@ -0,0 +1,17 @@ + + + + net9.0 + enable + enable + dotnet-EmailRetryScheduler-66035599-2ab0-48f8-a6a6-a1ca3d9ae526 + + + + + + + + + + diff --git a/EmailRetryScheduler/Modal/EmailActionLog.cs b/EmailRetryScheduler/Modal/EmailActionLog.cs new file mode 100644 index 0000000..fea8610 --- /dev/null +++ b/EmailRetryScheduler/Modal/EmailActionLog.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace EmailRetryScheduler.Modal +{ + public class EmailActionLog + { + [Key] + public int Id { get; set; } + public Guid EmailId { get; set; } + public string Message { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/EmailRetryScheduler/Modal/EmailIdempotency.cs b/EmailRetryScheduler/Modal/EmailIdempotency.cs new file mode 100644 index 0000000..c04801b --- /dev/null +++ b/EmailRetryScheduler/Modal/EmailIdempotency.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace EmailRetryScheduler.Modal +{ + public class EmailIdempotency + { + [Key] + public int Id { get; set; } + public string MessageKey { get; set; } + public Guid EmailId { get; set; } + public bool IsPublished { get; set; } + public DateTime? CompletedAt { get; set; } + public DateTime CreatedAt { get; set; } + public EmailLog EmailLog { get; set; } + } +} diff --git a/EmailRetryScheduler/Modal/EmailLog .cs b/EmailRetryScheduler/Modal/EmailLog .cs new file mode 100644 index 0000000..e9be142 --- /dev/null +++ b/EmailRetryScheduler/Modal/EmailLog .cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace EmailRetryScheduler.Modal +{ + public class EmailLog + { + [Key] + public int Id { get; set; } + public int AttemptCount { get; set; } + public string ToAddress { get; set; } + public string Subject { get; set; } + public string Body { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? SentAt { get; set; } + public DateTime? LockedUntil { get; set; } + public DateTime? NextAttemptAt { get; set; } + public string? LastError { get; set; } + public int EmailStatusId { get; set; } + public EmailStatus? EmailStatus { get; set; } + public int EmailIdempotencyId { get; set; } + public EmailIdempotency? EmailIdempotency { get; set; } + + } +} diff --git a/EmailRetryScheduler/Modal/EmailStatus.cs b/EmailRetryScheduler/Modal/EmailStatus.cs new file mode 100644 index 0000000..bccc145 --- /dev/null +++ b/EmailRetryScheduler/Modal/EmailStatus.cs @@ -0,0 +1,12 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; + +namespace EmailRetryScheduler.Modal +{ + public class EmailStatus + { + [Key] + public int Id { get; set; } + public string Status { get; set; } + } +} diff --git a/EmailRetryScheduler/Program.cs b/EmailRetryScheduler/Program.cs new file mode 100644 index 0000000..7153699 --- /dev/null +++ b/EmailRetryScheduler/Program.cs @@ -0,0 +1,26 @@ +using EmailRetryScheduler.Contract; +using EmailRetryScheduler.Data; +using EmailRetryScheduler.Dto; +using EmailRetryScheduler.Repository; +using EmailRetryScheduler.Service; + +namespace EmailRetryScheduler +{ + public class Program + { + public static void Main(string[] args) + { + var builder = Host.CreateApplicationBuilder(args); + + builder.Services.AddDbContext(); + builder.Services.Configure(builder.Configuration.GetSection("RabbitMQ")); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); + + var host = builder.Build(); + host.Run(); + } + } +} \ No newline at end of file diff --git a/EmailRetryScheduler/Properties/launchSettings.json b/EmailRetryScheduler/Properties/launchSettings.json new file mode 100644 index 0000000..900e270 --- /dev/null +++ b/EmailRetryScheduler/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "EmailRetryScheduler": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/EmailRetryScheduler/Repository/EmailRepository.cs b/EmailRetryScheduler/Repository/EmailRepository.cs new file mode 100644 index 0000000..35b11c3 --- /dev/null +++ b/EmailRetryScheduler/Repository/EmailRepository.cs @@ -0,0 +1,60 @@ +using EmailRetryScheduler.Contract; +using EmailRetryScheduler.Data; +using EmailRetryScheduler.Modal; +using Microsoft.EntityFrameworkCore; + +namespace EmailRetryScheduler.Repository +{ + public class EmailRepository : IEmailRepository + { + private AppDBContext _dBContext; + + public EmailRepository(AppDBContext dBContext) { + this._dBContext = dBContext; + } + + public async Task GetEmailIdempotencyAsync(Guid emailId) { + return await _dBContext.EmailIdempotency + .AsNoTracking() + .Include(e => e.EmailLog) + .ThenInclude(e => e.EmailStatus) + .Where(e => e.EmailId == emailId) + .FirstOrDefaultAsync(); + } + + public async Task> GetRetryMailsForSend() { + var now = DateTime.Now; + return await _dBContext.EmailIdempotency + .AsNoTracking() + .Include(e => e.EmailLog) + .ThenInclude(e => e.EmailStatus) + .Where(e => + e.EmailLog.EmailStatusId == (int) Constant.Enum.EmailStatus.RetryQueued + && e.EmailLog.NextAttemptAt < now + && e.IsPublished == false + && (e.EmailLog.LockedUntil == null || e.EmailLog.LockedUntil < now) + ).ToListAsync(); + } + + public async Task MarkMailAsPublished(Guid emailId) { + var targetEmailIdempotency = await _dBContext.EmailIdempotency + .Include(e => e.EmailLog) + .Where(e => e.EmailId == emailId).FirstOrDefaultAsync(); + if (targetEmailIdempotency == null) return false; + targetEmailIdempotency.EmailLog.EmailStatusId = (int)Constant.Enum.EmailStatus.Pending; + targetEmailIdempotency.IsPublished = true; + targetEmailIdempotency.CompletedAt = null; + targetEmailIdempotency.EmailLog.SentAt = null; + targetEmailIdempotency.EmailLog.LockedUntil = null; + targetEmailIdempotency.EmailLog.NextAttemptAt = null; + await _dBContext.SaveChangesAsync(); + return true; + } + + + public async Task InsertEmailActionLog(EmailActionLog actionLog) { + await _dBContext.EmailActionLog.AddAsync(actionLog); + await _dBContext.SaveChangesAsync(); + } + } +} diff --git a/EmailRetryScheduler/Service/EmailService.cs b/EmailRetryScheduler/Service/EmailService.cs new file mode 100644 index 0000000..4ea0746 --- /dev/null +++ b/EmailRetryScheduler/Service/EmailService.cs @@ -0,0 +1,48 @@ +using EmailRetryScheduler.Constant; +using EmailRetryScheduler.Contract; +using EmailRetryScheduler.Modal; + +namespace EmailRetryScheduler.Service +{ + public class EmailService : IEmailService + { + private readonly IEmailRepository _emailRepository; + protected readonly IConfiguration configuration; + private readonly IRabbitMQService _rabbitMQService; + + public EmailService(IEmailRepository emailRepository, IRabbitMQService rabbitMQService) + { + _rabbitMQService = rabbitMQService; + _emailRepository = emailRepository; + } + + public async Task RescheduleFailedMailsToSend() { + var pendingMailToPublish = await _emailRepository.GetRetryMailsForSend(); + foreach (var item in pendingMailToPublish) + { + try + { + await _rabbitMQService.InsertMessageToRabbitMQ(item, AppConstant.QueueName); + await _emailRepository.MarkMailAsPublished(item.EmailId); + await AddActionLog(item.EmailId, $"Retry scheduler has successfully added failed mail Message to primary queue for send a retry mail.", DateTime.Now); + } + catch (Exception ex) + { + await AddActionLog(item.EmailId, $"Retry scheduler Failed to insert message to primary queue for send a retry mail. Error Message was : {ex.Message}", DateTime.Now); + } + } + return true; + } + + private async Task AddActionLog(Guid emailId, string message ,DateTime? actionAt) { + var emailAction = new EmailActionLog + { + EmailId = emailId, + Message = message, + CreatedAt = actionAt ?? DateTime.Now + }; + await _emailRepository.InsertEmailActionLog(emailAction); + return true; + } + } +} \ No newline at end of file diff --git a/EmailRetryScheduler/Service/RabbitMQService.cs b/EmailRetryScheduler/Service/RabbitMQService.cs new file mode 100644 index 0000000..0f12811 --- /dev/null +++ b/EmailRetryScheduler/Service/RabbitMQService.cs @@ -0,0 +1,54 @@ +using EmailRetryScheduler.Contract; +using EmailRetryScheduler.Dto; +using EmailRetryScheduler.Modal; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using RabbitMQ.Client; +using System.Text; + +namespace EmailRetryScheduler +{ + public class RabbitMQService : IRabbitMQService + { + private readonly ConnectionFactory _factory; + private IConnection _connection; + + public RabbitMQService(IOptions rabbitMQConfig) + { + var config = rabbitMQConfig.Value; + _factory = new ConnectionFactory + { + HostName = config.HostName, + Port = config.Port, + UserName = config.UserName, + Password = config.Password + }; + } + + public async Task CreateConnection(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) return; + _connection = await _factory.CreateConnectionAsync(cancellationToken); + } + + public async Task InsertMessageToRabbitMQ(EmailIdempotency emailIdempotency, string queueName) + { + var rabbitMQPayload = new + { + MessageKey = emailIdempotency.MessageKey, + EmailId = emailIdempotency.EmailId + }; + var jsonPayload = JsonConvert.SerializeObject(rabbitMQPayload); + using (var channel = await this._connection.CreateChannelAsync()) + { + var props = new BasicProperties + { + Persistent = true + }; + await channel.QueueDeclareAsync(queue: queueName, durable: true, exclusive: false, autoDelete: false, arguments: null); + var body = Encoding.UTF8.GetBytes(jsonPayload); + await channel.BasicPublishAsync(exchange: string.Empty, routingKey: queueName, true, basicProperties: props, body: body); + } + } + } +} diff --git a/EmailWorker/Constant/AppConstant.cs b/EmailWorker/Constant/AppConstant.cs new file mode 100644 index 0000000..8eb8dbc --- /dev/null +++ b/EmailWorker/Constant/AppConstant.cs @@ -0,0 +1,9 @@ +namespace EmailWorker.Constant +{ + internal class AppConstant + { + public const string QueueName = "email.dispatcher.send"; + public const string DLQQueueName = "email.dispatcher.dlq"; + public const int LeaseLockTime = 10; + } +} diff --git a/EmailWorker/Constant/Enum/EmailStatus.cs b/EmailWorker/Constant/Enum/EmailStatus.cs new file mode 100644 index 0000000..31044a4 --- /dev/null +++ b/EmailWorker/Constant/Enum/EmailStatus.cs @@ -0,0 +1,12 @@ +namespace EmailWorker.Constant.Enum +{ + public enum EmailStatus + { + Pending = 1, + Scheduled = 2, + Sent = 3, + Failed = 4, + RetryQueued = 5, + Dead = 6 + } +} diff --git a/EmailWorker/Contract/IEmailRepository.cs b/EmailWorker/Contract/IEmailRepository.cs new file mode 100644 index 0000000..68c6fbd --- /dev/null +++ b/EmailWorker/Contract/IEmailRepository.cs @@ -0,0 +1,14 @@ +using EmailWorker.Modal; + +namespace EmailWorker.Contract +{ + public interface IEmailRepository + { + Task GetEmailIdempotencyAsync(Guid emailId); + Task LockEmailSendIdempotency(EmailIdempotency emailIdempotency); + Task MarkEmailSuccess(Guid emailId, DateTime actionAt); + Task MarkEmailFail(Guid emailId, string lastError, DateTime retryTime); + Task InsertEmailActionLog(EmailActionLog actionLog); + Task MarkEmailAsDead(Guid emailId); + } + } diff --git a/EmailWorker/Contract/IEmailService.cs b/EmailWorker/Contract/IEmailService.cs new file mode 100644 index 0000000..5754099 --- /dev/null +++ b/EmailWorker/Contract/IEmailService.cs @@ -0,0 +1,9 @@ +using EmailWorker.Dto; + +namespace EmailWorker.Contract +{ + public interface IEmailService + { + Task SendEmail(RabitMQDto message); + } +} diff --git a/EmailWorker/Contract/IRabbitMQService .cs b/EmailWorker/Contract/IRabbitMQService .cs new file mode 100644 index 0000000..1e4ec6b --- /dev/null +++ b/EmailWorker/Contract/IRabbitMQService .cs @@ -0,0 +1,10 @@ +using EmailWorker.Modal; + +namespace EmailWorker.Contract +{ + public interface IRabbitMQService + { + Task CreateConnection(CancellationToken cancellationToken); + Task InsertMessageToRabbitMQ(EmailIdempotency emailIdempotency, string queueName); + } +} diff --git a/EmailWorker/Data/AppDBContext.cs b/EmailWorker/Data/AppDBContext.cs new file mode 100644 index 0000000..55dff21 --- /dev/null +++ b/EmailWorker/Data/AppDBContext.cs @@ -0,0 +1,28 @@ +using EmailWorker.Modal; +using Microsoft.EntityFrameworkCore; + +namespace EmailWorker.Data +{ + public class AppDBContext : DbContext + { + protected readonly IConfiguration Configuration; + public AppDBContext(IConfiguration configuration) + { + Configuration = configuration; + } + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")); + } + public DbSet EmailLog { get; set; } + public DbSet EmailIdempotency { get; set; } + public DbSet EmailStatus { get; set; } + public DbSet EmailActionLog { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasIndex(e => new { e.EmailStatusId, e.NextAttemptAt }); + } + } +} \ No newline at end of file diff --git a/EmailWorker/Dto/MailConfig.cs b/EmailWorker/Dto/MailConfig.cs new file mode 100644 index 0000000..020c02f --- /dev/null +++ b/EmailWorker/Dto/MailConfig.cs @@ -0,0 +1,14 @@ + +namespace EmailWorker.Dto +{ + public class MailConfig + { + public string FromAddress { get; set; } = null!; + public string Name { get; set; } = null!; + + public string MailDomain { get; set; } = null!; + public string MailPassword { get; set; } = null!; + public int FirstRetryAttemptTimeSpanInMinutes { get; set; } = 1; + } + +} diff --git a/EmailWorker/Dto/RabbitMQConfig.cs b/EmailWorker/Dto/RabbitMQConfig.cs new file mode 100644 index 0000000..5e6c87b --- /dev/null +++ b/EmailWorker/Dto/RabbitMQConfig.cs @@ -0,0 +1,11 @@ +namespace EmailWorker.Dto +{ + public class RabbitMQConfig + { + public string HostName { get; set; } = "localhost"; + public int Port { get; set; } = 5672; + public string UserName { get; set; } = "guest"; + public string Password { get; set; } = "guest"; + } +} + diff --git a/EmailWorker/Dto/RabitMQDto.cs b/EmailWorker/Dto/RabitMQDto.cs new file mode 100644 index 0000000..2e30449 --- /dev/null +++ b/EmailWorker/Dto/RabitMQDto.cs @@ -0,0 +1,8 @@ +namespace EmailWorker.Dto +{ + public class RabitMQDto + { + public string MessageKey { get; set; } + public Guid EmailId { get; set; } + } +} diff --git a/EmailWorker/Dto/RetryPolicyOptions.cs b/EmailWorker/Dto/RetryPolicyOptions.cs new file mode 100644 index 0000000..a80f373 --- /dev/null +++ b/EmailWorker/Dto/RetryPolicyOptions.cs @@ -0,0 +1,10 @@ +namespace EmailRetryScheduler.Dto +{ + public class RetryPolicyOptions + { + public int RetryIntervalSeconds { get; set; } = 60; + public int MaxAttempts { get; set; } = 8; + public List BackoffScheduleMinutes { get; set; } = new(); + } + +} diff --git a/EmailWorker/EmailWorker.cs b/EmailWorker/EmailWorker.cs new file mode 100644 index 0000000..92a63e6 --- /dev/null +++ b/EmailWorker/EmailWorker.cs @@ -0,0 +1,20 @@ +using EmailWorker.Contract; + +namespace EmailWorker +{ + public class EmailWorker : BackgroundService + { + private IRabbitMQService _rabbitMQService; + + public EmailWorker(IRabbitMQService rabbitMQService) + { + _rabbitMQService = rabbitMQService; + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) return; + await _rabbitMQService.CreateConnection(cancellationToken); + } + } +} diff --git a/EmailWorker/EmailWorker.csproj b/EmailWorker/EmailWorker.csproj new file mode 100644 index 0000000..0fca62c --- /dev/null +++ b/EmailWorker/EmailWorker.csproj @@ -0,0 +1,17 @@ + + + + net9.0 + enable + enable + dotnet-EmailWorker-44bc1de6-00d8-4c73-88f9-7d8f6cbdef07 + + + + + + + + + + diff --git a/EmailWorker/Modal/EmailActionLog.cs b/EmailWorker/Modal/EmailActionLog.cs new file mode 100644 index 0000000..2bcf7e6 --- /dev/null +++ b/EmailWorker/Modal/EmailActionLog.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace EmailWorker.Modal +{ + public class EmailActionLog + { + [Key] + public int Id { get; set; } + public Guid EmailId { get; set; } + public string Message { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/EmailWorker/Modal/EmailIdempotency.cs b/EmailWorker/Modal/EmailIdempotency.cs new file mode 100644 index 0000000..8b78aac --- /dev/null +++ b/EmailWorker/Modal/EmailIdempotency.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace EmailWorker.Modal +{ + public class EmailIdempotency + { + [Key] + public int Id { get; set; } + public string MessageKey { get; set; } + public Guid EmailId { get; set; } + public bool IsPublished { get; set; } + public DateTime? CompletedAt { get; set; } + public DateTime CreatedAt { get; set; } + public EmailLog EmailLog { get; set; } + } +} diff --git a/EmailWorker/Modal/EmailLog .cs b/EmailWorker/Modal/EmailLog .cs new file mode 100644 index 0000000..40119c0 --- /dev/null +++ b/EmailWorker/Modal/EmailLog .cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace EmailWorker.Modal +{ + public class EmailLog + { + [Key] + public int Id { get; set; } + public int AttemptCount { get; set; } + public string ToAddress { get; set; } + public string Subject { get; set; } + public string Body { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? SentAt { get; set; } + public DateTime? LockedUntil { get; set; } + public DateTime? NextAttemptAt { get; set; } + public string? LastError { get; set; } + public int EmailStatusId { get; set; } + public EmailStatus? EmailStatus { get; set; } + public int EmailIdempotencyId { get; set; } + public EmailIdempotency? EmailIdempotency { get; set; } + + } +} diff --git a/EmailWorker/Modal/EmailStatus.cs b/EmailWorker/Modal/EmailStatus.cs new file mode 100644 index 0000000..7e13dea --- /dev/null +++ b/EmailWorker/Modal/EmailStatus.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace EmailWorker.Modal +{ + public class EmailStatus + { + [Key] + public int Id { get; set; } + public string Status { get; set; } + } +} diff --git a/EmailWorker/Program.cs b/EmailWorker/Program.cs new file mode 100644 index 0000000..873f909 --- /dev/null +++ b/EmailWorker/Program.cs @@ -0,0 +1,31 @@ +using EmailRetryScheduler.Dto; +using EmailWorker.Contract; +using EmailWorker.Data; +using EmailWorker.Dto; +using EmailWorker.Repository; +using EmailWorker.Service; +using Microsoft.Extensions.Configuration; + +namespace EmailWorker +{ + public class Program + { + public static void Main(string[] args) + { + var builder = Host.CreateApplicationBuilder(args); + + builder.Services.AddDbContext(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.Configure(builder.Configuration.GetSection("RabbitMQ")); + builder.Services.Configure(builder.Configuration.GetSection("RetryPolicy")); + builder.Services.Configure(builder.Configuration.GetSection("MailConfig")); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); + + + var host = builder.Build(); + host.Run(); + } + } +} \ No newline at end of file diff --git a/EmailWorker/Properties/launchSettings.json b/EmailWorker/Properties/launchSettings.json new file mode 100644 index 0000000..a8b158c --- /dev/null +++ b/EmailWorker/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "EmailWorker": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/EmailWorker/Repository/EmailRepository.cs b/EmailWorker/Repository/EmailRepository.cs new file mode 100644 index 0000000..1d1db76 --- /dev/null +++ b/EmailWorker/Repository/EmailRepository.cs @@ -0,0 +1,96 @@ +using EmailWorker.Constant; +using EmailWorker.Contract; +using EmailWorker.Data; +using EmailWorker.Modal; +using Microsoft.EntityFrameworkCore; + +namespace EmailWorker.Repository +{ + public class EmailRepository : IEmailRepository + { + private AppDBContext _dBContext; + + public EmailRepository(AppDBContext dBContext) { + this._dBContext = dBContext; + } + + public async Task GetEmailIdempotencyAsync(Guid emailId) { + return await _dBContext.EmailIdempotency + .AsNoTracking() + .Include(e => e.EmailLog) + .ThenInclude(e => e.EmailStatus) + .Where(e => e.EmailId == emailId) + .FirstOrDefaultAsync(); + } + + public async Task LockEmailSendIdempotency(EmailIdempotency emailIdempotency) { + var targetEmailIdempotency = await _dBContext.EmailIdempotency + .Include(e => e.EmailLog) + .Where(e => e.Id == emailIdempotency.Id).FirstOrDefaultAsync(); + if (targetEmailIdempotency == null) return false; + targetEmailIdempotency.EmailLog.EmailStatusId = (int)Constant.Enum.EmailStatus.Scheduled; + targetEmailIdempotency.EmailLog.LockedUntil = DateTime.Now.AddSeconds(AppConstant.LeaseLockTime); + targetEmailIdempotency.EmailLog.AttemptCount += 1; + await _dBContext.SaveChangesAsync(); + return true; + } + + public async Task MarkEmailSuccess(Guid emailId, DateTime actionAt) { + var targetEmailIdempotency = await _dBContext.EmailIdempotency + .Include(e => e.EmailLog) + .Where(e => e.EmailId == emailId).FirstOrDefaultAsync(); + if (targetEmailIdempotency == null) return false; + targetEmailIdempotency.EmailLog.EmailStatusId = (int)Constant.Enum.EmailStatus.Sent; + targetEmailIdempotency.EmailLog.SentAt = actionAt; + targetEmailIdempotency.EmailLog.LockedUntil = null; + targetEmailIdempotency.EmailLog.LastError = null; + targetEmailIdempotency.CompletedAt = actionAt; + targetEmailIdempotency.EmailLog.NextAttemptAt = null; + await _dBContext.SaveChangesAsync(); + return true; + } + + public async Task MarkEmailFail(Guid emailId, string lastError, DateTime retryTime) + { + var targetEmailIdempotency = await _dBContext.EmailIdempotency + .Include(e => e.EmailLog) + .Where(e => e.EmailId == emailId).FirstOrDefaultAsync(); + if (targetEmailIdempotency == null) return false; + targetEmailIdempotency.EmailLog.EmailStatusId = (int)Constant.Enum.EmailStatus.RetryQueued; + targetEmailIdempotency.EmailLog.LockedUntil = retryTime; + targetEmailIdempotency.EmailLog.LastError = lastError; + targetEmailIdempotency.EmailLog.NextAttemptAt = retryTime; + targetEmailIdempotency.IsPublished = false; + await _dBContext.SaveChangesAsync(); + return true; + } + + public async Task MarkEmailAsDead(Guid emailId) + { + var targetEmailIdempotency = await _dBContext.EmailIdempotency + .Include(e => e.EmailLog) + .Where(e => e.EmailId == emailId).FirstOrDefaultAsync(); + if (targetEmailIdempotency == null) return false; + targetEmailIdempotency.EmailLog.EmailStatusId = (int)Constant.Enum.EmailStatus.Dead; + targetEmailIdempotency.IsPublished = false; + await _dBContext.SaveChangesAsync(); + return true; + } + + public async Task MarkMailAsPublished(EmailIdempotency emailIdempotency) + { + var targetEmailIdempotency = await _dBContext.EmailIdempotency + .Include(e => e.EmailLog) + .Where(e => e.Id == emailIdempotency.Id).FirstOrDefaultAsync(); + if (targetEmailIdempotency == null) return false; + targetEmailIdempotency.IsPublished = true; + await _dBContext.SaveChangesAsync(); + return true; + } + + public async Task InsertEmailActionLog(EmailActionLog actionLog) { + await _dBContext.EmailActionLog.AddAsync(actionLog); + await _dBContext.SaveChangesAsync(); + } + } +} diff --git a/EmailWorker/Service/EmailService.cs b/EmailWorker/Service/EmailService.cs new file mode 100644 index 0000000..66123b0 --- /dev/null +++ b/EmailWorker/Service/EmailService.cs @@ -0,0 +1,149 @@ +using EmailRetryScheduler.Dto; +using EmailWorker.Constant; +using EmailWorker.Contract; +using EmailWorker.Dto; +using EmailWorker.Modal; +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Options; +using MimeKit; + +namespace EmailWorker.Service +{ + public class EmailService : IEmailService + { + private readonly IEmailRepository _emailRepository; + protected readonly IConfiguration configuration; + private readonly MailConfig _mailConfig; + private readonly RetryPolicyOptions _retryPloicyOption; + private readonly IRabbitMQService _rabbitMQService; + + public EmailService(IEmailRepository emailRepository, IOptions mailConfig, IOptions retryPolicyOption, IRabbitMQService rabbitMQService) + { + _rabbitMQService = rabbitMQService; + _emailRepository = emailRepository; + _mailConfig = mailConfig?.Value; + _retryPloicyOption = retryPolicyOption?.Value; + + + if (string.IsNullOrWhiteSpace(_mailConfig.FromAddress) || + string.IsNullOrWhiteSpace(_mailConfig.MailDomain) || + string.IsNullOrWhiteSpace(_mailConfig.MailPassword) || + string.IsNullOrWhiteSpace(_mailConfig.Name)) + { + throw new InvalidOperationException( + "MailConfig is missing required values."); + } + } + + public async Task SendEmail(RabitMQDto message) { + var emailIdempotency = await _emailRepository.GetEmailIdempotencyAsync(message.EmailId); + if (emailIdempotency == null) return; + if (await this.ShouldSkipEmailProcessing(emailIdempotency)) return; + await this.SendEmailAsync(emailIdempotency); + return; + } + + private async Task ShouldSkipEmailProcessing(EmailIdempotency emailIdempotency) { + var now = DateTime.Now; + + if (emailIdempotency.EmailLog.EmailStatusId == (int)Constant.Enum.EmailStatus.Sent) + { + return true; + } + + if (emailIdempotency.CompletedAt != null) + { + return true; + } + + var isPublished = emailIdempotency.IsPublished; + var isNotCompleted = emailIdempotency.CompletedAt == null; + var isPending = emailIdempotency.EmailLog.EmailStatusId == (int) Constant.Enum.EmailStatus.Pending; + var isNotLocked = emailIdempotency.EmailLog.LockedUntil == null || emailIdempotency.EmailLog.LockedUntil < now; + var isDueForAttempt = emailIdempotency.EmailLog.NextAttemptAt == null || emailIdempotency.EmailLog.NextAttemptAt <= now; + var isNotSent = emailIdempotency.EmailLog.SentAt == null; + var isSuccessFullyLocked = await _emailRepository.LockEmailSendIdempotency(emailIdempotency); + + if (isPublished && isNotCompleted && isPending && isNotLocked && isDueForAttempt && isNotSent && isSuccessFullyLocked) + { + return false; + } + + return true; + } + + private async Task SendEmailAsync(EmailIdempotency emailIdempotency) + { + var message = new MimeMessage(); + message.From.Add(new MailboxAddress(_mailConfig.Name, _mailConfig.FromAddress)); + //Empty string may be receiver name + message.To.Add(new MailboxAddress(string.Empty, emailIdempotency.EmailLog.ToAddress)); + + message.Subject = emailIdempotency.EmailLog.Subject; + var body = emailIdempotency.EmailLog.Body; + var bodyBuilder = new BodyBuilder + { + HtmlBody = $"

{body}

", + TextBody = body + }; + message.Body = bodyBuilder.ToMessageBody(); + + using (var client = new SmtpClient()) + { + try + { + await client.ConnectAsync(_mailConfig.MailDomain, 587, SecureSocketOptions.StartTls); + await client.AuthenticateAsync(_mailConfig.FromAddress, _mailConfig.MailPassword); + await client.SendAsync(message); + DateTime now = DateTime.Now; + await _emailRepository.MarkEmailSuccess(emailIdempotency.EmailId, now); + await AddActionLog(emailIdempotency.EmailId, "Mail delivered successfully", now); + } + catch (Exception ex) + { + if (_retryPloicyOption.MaxAttempts <= emailIdempotency.EmailLog.AttemptCount + 1 ) { + try + { + await _rabbitMQService.InsertMessageToRabbitMQ(emailIdempotency, AppConstant.DLQQueueName); + await _emailRepository.MarkEmailAsDead(emailIdempotency.EmailId); + await AddActionLog(emailIdempotency.EmailId, $"Dead : Failed mail message inserted to DLQ after reached the maximum attempt", DateTime.Now); + } + catch (Exception e) + { + await AddActionLog(emailIdempotency.EmailId, $"Email Worker Failed to insert message to DLQ after reach the maximum attempt. Error Message was : {e.Message}", DateTime.Now); + } + } + else { + var retryTime = DateTime.Now.AddMinutes(GetRescheduleMinutes(emailIdempotency.EmailLog.AttemptCount)); + await _emailRepository.MarkEmailFail(emailIdempotency.EmailId, ex.Message, retryTime); + await AddActionLog(emailIdempotency.EmailId, $"Mail delivery failed at attempt of {emailIdempotency.EmailLog.AttemptCount + 1}. And Marked as not published. Error Message was : {ex.Message}", DateTime.Now); + } + } + finally + { + await client.DisconnectAsync(true); + } + } + } + + private int GetRescheduleMinutes(int attempt) + { + var index = attempt - 1; + if (index < 0) index = 0; + if (index >= _retryPloicyOption.BackoffScheduleMinutes.Count) index = _retryPloicyOption.BackoffScheduleMinutes.Count - 1; + return _retryPloicyOption.BackoffScheduleMinutes[index]; + } + + private async Task AddActionLog(Guid emailId, string message ,DateTime? actionAt) { + var emailAction = new EmailActionLog + { + EmailId = emailId, + Message = message, + CreatedAt = actionAt ?? DateTime.Now + }; + await _emailRepository.InsertEmailActionLog(emailAction); + return true; + } + } +} \ No newline at end of file diff --git a/EmailWorker/Service/RabbitMQService.cs b/EmailWorker/Service/RabbitMQService.cs new file mode 100644 index 0000000..0819dfc --- /dev/null +++ b/EmailWorker/Service/RabbitMQService.cs @@ -0,0 +1,109 @@ +using EmailWorker.Constant; +using EmailWorker.Contract; +using EmailWorker.Dto; +using EmailWorker.Modal; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using System.Text; + +namespace EmailWorker +{ + public class RabbitMQService : IRabbitMQService + { + private readonly ILogger _logger; + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly ConnectionFactory _factory; + private IConnection _connection; + private IChannel _channel; + + public RabbitMQService(IServiceScopeFactory serviceScopeFactory, IOptions rabbitMQConfig) + { + _serviceScopeFactory = serviceScopeFactory; + var config = rabbitMQConfig.Value; + _factory = new ConnectionFactory + { + HostName = config.HostName, + Port = config.Port, + UserName = config.UserName, + Password = config.Password + }; + } + + public async Task CreateConnection(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) return; + _connection = await _factory.CreateConnectionAsync(cancellationToken); + await this.ListenRabbitMq(cancellationToken); + } + + private async Task ListenRabbitMq(CancellationToken cancellationToken) + { + _channel = await _connection.CreateChannelAsync(); + + await _channel.QueueDeclareAsync( + queue: AppConstant.QueueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: null); + var consumer = new AsyncEventingBasicConsumer(_channel); + + consumer.ReceivedAsync += async (model, ea) => + { + await Task.Delay(5000); + if (cancellationToken.IsCancellationRequested) return; + var body = ea.Body.ToArray(); + var messageJson = Encoding.UTF8.GetString(ea.Body.ToArray()); + var message = JsonConvert.DeserializeObject(messageJson); + if (message == null || + string.IsNullOrEmpty(message.MessageKey) || + message.EmailId == Guid.Empty) + { + return; + } + + await this.SendEmail(cancellationToken, message); + await _channel.BasicAckAsync(ea.DeliveryTag, false); + return; + }; + + await _channel.BasicConsumeAsync( + queue: AppConstant.QueueName, + autoAck: false, + consumer: consumer); + } + + public async Task InsertMessageToRabbitMQ(EmailIdempotency emailIdempotency, string queueName) + { + var rabbitMQPayload = new + { + MessageKey = emailIdempotency.MessageKey, + EmailId = emailIdempotency.EmailId + }; + var jsonPayload = JsonConvert.SerializeObject(rabbitMQPayload); + using (var channel = await this._connection.CreateChannelAsync()) + { + var props = new BasicProperties + { + Persistent = true + }; + await channel.QueueDeclareAsync(queue: queueName, durable: true, exclusive: false, autoDelete: false, arguments: null); + var body = Encoding.UTF8.GetBytes(jsonPayload); + await channel.BasicPublishAsync(exchange: string.Empty, routingKey: queueName, true, basicProperties: props, body: body); + } + } + + private async Task SendEmail(CancellationToken stoppingToken, RabitMQDto message) + { + if (stoppingToken.IsCancellationRequested) return; + using (IServiceScope scope = _serviceScopeFactory.CreateAsyncScope()) + { + var emailService = scope.ServiceProvider.GetRequiredService(); + await emailService.SendEmail(message); + } + } + + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..16ba141 --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# User Story 1 +## Reliable Email Dispatch with Queue, DLQ, and Idempotency (No Retry Scheduler) + +**Project Name:** Email.Dispatcher +**Title:** Send emails asynchronously with reliable delivery and no duplicates + +--- + +## Tech Stack (Must Follow) +- **RabbitMQ** + - Main Queue: `email.dispatcher.send` + - Dead Letter Queue (DLQ): `email.dispatcher.dlq` +- **.NET Worker Service** + - `Email.Dispatcher` (RabbitMQ Consumer + Email Sender) +- **Database (SQL Server or equivalent)** + - `EmailLog` table (email state, attempts, errors) + - `EmailIdempotency` table (prevents duplicate sends) +- **SMTP / Email Provider** + - SendGrid / SMTP / SES (provider adapter based) + +--- + +## Business Goal +As a system, I want to send emails (OTP, notifications, invoices, etc.) asynchronously so that: +- API responses are fast +- emails are not lost +- duplicates are prevented +- failures are captured and moved to DLQ +- operations teams can track and replay failed emails + +--- + +## Actors +- End User (triggers actions requiring email) +- API (creates email request and publishes message) +- `Email.Dispatcher` – .NET Worker Service (RabbitMQ consumer) +- RabbitMQ (Main queue + DLQ) +- Database (EmailLog + Idempotency tables) +- Ops/Admin (monitor and replay failed emails) + +--- + +## Functional Flow + +### 1) Create Email Request (API) +**Given** a user action requires an email +**When** the API processes the request +**Then** the API must: + +#### Database +- Insert **one row per email** into `EmailLog` with: + - `Status = Pending` + - `AttemptCount = 0` + - `MessageKey` (unique idempotency key) + - `CreatedAt = NOW` + +#### Idempotency +- Insert into `EmailIdempotency`: + - `MessageKey` + - `EmailId` +- Enforce unique constraint on `MessageKey` + +#### RabbitMQ +- Publish **one message per EmailId** to: + - `email.dispatcher.send` +- Message payload must contain: + - `EmailId` + - `MessageKey` + +#### Response +- Return success without waiting for email to be sent + +**Acceptance Criteria** +- API must not send emails directly +- One email record = one RabbitMQ message +- If publish fails after DB save, email remains recoverable +- Email record must exist before sending is attempted + +--- + +### 2) Send Email +(**Email.Dispatcher – RabbitMQ Consumer / Worker Service**) + +**Given** a message is consumed from `email.dispatcher.send` +**When** the worker processes the message +**Then** it must: + +#### Read +- Load `EmailLog` by `EmailId` + +#### Idempotency Check (Mandatory) +- If `Status = Sent` → ACK and stop +- If `MessageKey` already completed → ACK and stop + +#### Locking / Lease +- Acquire DB lease: + - `LockedUntil = NOW + LeaseDuration` +- Only one worker may send the email + +#### Send +- Send email using configured SMTP/provider + +##### On Success +- Update DB: + - `Status = Sent` + - `SentAt = NOW` + - Clear `LastError` +- Mark idempotency as completed +- ACK RabbitMQ message + +##### On Failure +- Update DB: + - `Status = Failed` + - `AttemptCount = AttemptCount + 1` + - `LastError = ` +- Publish message to DLQ: `email.dispatcher.dlq` +- ACK original message + +**Acceptance Criteria** +- At-least-once delivery must not cause duplicates +- Multiple worker instances must be safe +- Failures must never block the main queue +- DLQ message must contain enough data for replay + +--- + +### 3) Dead Letter Handling (DLQ) +**Given** email sending fails +**When** failure is detected +**Then**: +- Message is published to `email.dispatcher.dlq` +- Email remains visible in DB with failure reason + +**Acceptance Criteria** +- DLQ is the source for inspection and replay +- Ops/Admin can re-publish EmailId after fixing issues + +--- + +## Non-Functional Requirements + +### Idempotency +- `MessageKey` must be unique per logical email +- Idempotency table must prevent duplicate sends +- Duplicate RabbitMQ deliveries must be safe + +### Locking / Concurrency +- DB lease (`LockedUntil`) prevents double sends +- Lease expiry allows recovery if worker crashes + +### Observability +- Log every attempt: + - EmailId + - Attempt number + - Error type +- Metrics: + - Pending + - Failed + - Dead (DLQ) + - Sent per hour/day + +### Scalability +- Multiple worker instances supported +- RabbitMQ distributes messages automatically +- Throughput increases by adding workers + +--- + +## Definition of Done +- API inserts EmailLog + EmailIdempotency +- API publishes one message per EmailId +- Email.Dispatcher consumes and sends emails +- Idempotency guarantees no duplicates +- Failures are sent to DLQ +- Logs and metrics available +- Retry scheduler explicitly excluded diff --git a/Template/EmailDispatcherAPI.zip b/Template/EmailDispatcherAPI.zip new file mode 100644 index 0000000..a8b3690 Binary files /dev/null and b/Template/EmailDispatcherAPI.zip differ diff --git a/Template/EmailRetryScheduler.zip b/Template/EmailRetryScheduler.zip new file mode 100644 index 0000000..95c1ebc Binary files /dev/null and b/Template/EmailRetryScheduler.zip differ diff --git a/Template/EmailWorker.zip b/Template/EmailWorker.zip new file mode 100644 index 0000000..38ea2c4 Binary files /dev/null and b/Template/EmailWorker.zip differ diff --git a/Untitled b/Untitled new file mode 100644 index 0000000..8a6e14c --- /dev/null +++ b/Untitled @@ -0,0 +1 @@ +D:\EmailDispathcer\Email.Dispatcher \ No newline at end of file diff --git a/architecture.png b/architecture.png new file mode 100644 index 0000000..cfd0126 Binary files /dev/null and b/architecture.png differ diff --git a/user-story-3.md b/user-story-3.md new file mode 100644 index 0000000..8d47c5c --- /dev/null +++ b/user-story-3.md @@ -0,0 +1,68 @@ +# User Story 3 +## Provide Email.Dispatcher as an Installable Template + +**Title:** Install email dispatch pipeline via command or Visual Studio template + +--- + +## Goal +As a platform/dev-experience owner, +I want to provide Email.Dispatcher as an installable template +So that developers can add reliable email dispatch to their solution using a command or Visual Studio, with minimal changes. + +--- + +## Scope +Template must support: +- Creating a new ready-to-run setup +- Adding Email.Dispatcher into an existing solution + +--- + +## Acceptance Criteria +- Template is installable via: + - dotnet new (primary) + - Appears in Visual Studio templates (optional) +- Developers can run: + - `dotnet new email-dispatcher --name MyApp` + - `dotnet new email-dispatcher.add --existingSolution` + +--- + +## Template Generates +- Email.Dispatcher – .NET Worker Service (Email Sender) +- Email.Dispatcher.RetryScheduler – .NET Worker Service +- DB scripts/migrations: + - Email + - Outbox / Attempts +- appsettings samples: + - RabbitMQ + - SMTP / email provider +- Short README: + - What to configure + - What not to change + +--- + +## Developer Changes Required +- Connection string +- RabbitMQ configuration +- Email provider configuration + +--- + +## Template Defaults +- Retry backoff policy +- Max retry attempts +- Idempotency guidance +- Fake email provider for local/dev mode + +--- + +## Definition of Done +- Template published (internal feed or NuGet) +- Documented with: + - Install command + - Create-new command + - Add-to-existing command + - Minimal configuration checklist diff --git a/user-story2.md b/user-story2.md new file mode 100644 index 0000000..2d5ab95 --- /dev/null +++ b/user-story2.md @@ -0,0 +1,155 @@ +# User Story 2 +## Automated Retry Scheduler for Failed Emails (DB-driven) with RabbitMQ Re-queueing + +**Project Name:** Email.Dispatcher.RetryScheduler +**Title:** Automatically retry failed emails using backoff and re-publish to RabbitMQ + +--- + +## Tech Stack (Must Follow) +- **.NET Worker Service** + - `Email.Dispatcher.RetryScheduler` (runs continuously) +- **Database (SQL Server or equivalent)** + - `EmailLog` table (holds Status, AttemptCount, NextAttemptAt, errors) + - Recommended index on `(Status, NextAttemptAt)` +- **RabbitMQ** + - Main Queue: `email.dispatcher.send` (re-queue EmailId for resend) + - Optional Retry Queue: `email.dispatcher.retry` (if you want separate routing) +- **Configuration** + - `RetryIntervalSeconds` (default 60) + - `MaxAttempts` (default 8) + - `BackoffSchedule` (default: 1m, 5m, 15m, 1h, 3h, 6h, 12h, 24h) + +--- + +## Business Goal +As a system, I want failed emails to be retried automatically so that: +- transient issues (SMTP outage, network issues) recover without manual effort +- customers receive emails eventually +- system avoids duplicate sends +- operations teams only handle true “dead” emails + +--- + +## Actors +- Email.Dispatcher (sender worker that marks failures) +- Database (stores retry state) +- Email.Dispatcher.RetryScheduler (worker that schedules retries) +- RabbitMQ (re-queues EmailId messages) +- Ops/Admin (monitors Dead emails if retries exhausted) + +--- + +## Functional Flow + +### 1) Failure State Is Recorded (Pre-condition) +**Given** an email send attempt failed +**When** Email.Dispatcher (sender worker) updates DB +**Then** the email record must contain: +- `Status = Failed` +- `AttemptCount = AttemptCount + 1` +- `LastError = ` +- `NextAttemptAt = NOW + Backoff(AttemptCount)` +- `LockedUntil = NULL` (or cleared) + +**Acceptance Criteria** +- Every failed email must have a `NextAttemptAt` set +- Permanent failures should not be retried (marked `Dead` by sender worker or by separate policy) + +--- + +### 2) Retry Scheduler Loop (Email.Dispatcher.RetryScheduler) +**Given** the RetryScheduler service is running +**When** the scheduler interval elapses (every 30s / 1 min) +**Then** it must: + +#### Step A — Select Due Retries (DB) +Select only emails where: +- `Status = Failed` +- `NextAttemptAt <= NOW` +- `AttemptCount < MaxAttempts` +- `LockedUntil IS NULL OR LockedUntil < NOW` (if you use lease locking) + +#### Step B — Claim Rows (Prevent double scheduling) +For selected rows, update them (atomic claim) to avoid multiple schedulers picking same rows: +- set `LockedUntil = NOW + LeaseDuration` (e.g., 2 minutes) +- optionally set `Status = RetryQueued` + +#### Step C — Publish to RabbitMQ +For each claimed email: +- Publish message to `email.dispatcher.send` containing: + - `EmailId` + - `MessageKey` + +#### Step D — Update DB after publish +- Set `Status = Pending` (or keep `RetryQueued` until sender picks it) +- Clear `LockedUntil` (or keep until send starts, based on your design) +- Store `LastQueuedAt = NOW` (optional) + +**Acceptance Criteria** +- Scheduler must only pick “due” emails (no full table scan) +- Scheduler must never publish the same EmailId twice for the same attempt window +- Scheduler must be safe when multiple instances run (idempotent/claiming behavior) + +--- + +### 3) Exhausted Retries → Mark as Dead +**Given** an email has reached MaxAttempts +**When** scheduler detects `AttemptCount >= MaxAttempts` +**Then** it must: +- Update DB: + - `Status = Dead` + - `DeadReason = MaxAttemptsExceeded` + - keep `LastError` +- (Optional) Publish a DLQ/alert event for Ops visibility + +**Acceptance Criteria** +- Emails exceeding MaxAttempts must not be retried automatically +- Dead emails must be visible for Ops/Admin manual action + +--- + +## Retry Policy (Must Follow) +- Default `MaxAttempts = 8` (configurable) +- Default backoff schedule (configurable): + - 1m → 5m → 15m → 1h → 3h → 6h → 12h → 24h +- Only transient failures should enter retry flow +- Permanent failures should be marked Dead (no retry) + +--- + +## Non-Functional Requirements + +### Reliability +- Scheduler must continue working after restarts (DB is source of truth) +- RabbitMQ publish failures must not lose retries: + - if publish fails, keep email in `Failed` with `NextAttemptAt` moved forward (or keep same) + +### Concurrency / Safety +- Scheduler must support multiple instances without duplicate scheduling +- Use DB claiming (lease) to prevent race conditions + +### Observability +- Log every scheduling action: + - EmailId + - AttemptCount + - NextAttemptAt + - Published/Skipped reason +- Metrics: + - due retries count + - re-queued count + - dead count (max attempts exceeded) + +### Performance +- Required DB index: + - `(Status, NextAttemptAt)` to query due retries efficiently + +--- + +## Definition of Done +- RetryScheduler runs as .NET Worker Service continuously +- It picks only due failed emails and re-publishes EmailId to RabbitMQ +- Backoff and MaxAttempts are configurable +- Rows are claimed safely to avoid duplicate scheduling +- Emails exceeding retries are marked Dead +- Logs and basic metrics exist