diff --git a/Wave/Data/ApplicationDbContext.cs b/Wave/Data/ApplicationDbContext.cs index 297b5cf..4d8eb8e 100644 --- a/Wave/Data/ApplicationDbContext.cs +++ b/Wave/Data/ApplicationDbContext.cs @@ -76,5 +76,24 @@ public class ApplicationDbContext(DbContextOptions options category.ToTable("Categories"); }); + + builder.Entity(newsletter => { + newsletter.HasKey(n => n.Id); + newsletter.HasOne(n => n.Article).WithOne().HasForeignKey() + .IsRequired().OnDelete(DeleteBehavior.Cascade); + + newsletter.ToTable("Newsletter"); + }); + builder.Entity(subscriber => { + subscriber.HasKey(s => s.Id); + + subscriber.Property(s => s.Name).IsRequired(false).HasMaxLength(128); + subscriber.Property(s => s.Email).IsRequired().HasMaxLength(256); + + subscriber.HasIndex(s => s.Unsubscribed); + + subscriber.HasQueryFilter(s => !s.Unsubscribed); + subscriber.ToTable("NewsletterSubscribers"); + }); } } \ No newline at end of file diff --git a/Wave/Data/Customization.cs b/Wave/Data/Customization.cs index e1c277a..6d699c9 100644 --- a/Wave/Data/Customization.cs +++ b/Wave/Data/Customization.cs @@ -3,6 +3,7 @@ public class Customization { public string AppName { get; set; } = "Wave"; public string AppDescription { get; set; } = ""; + public string AppUrl { get; set; } = "http://localhost"; public string DefaultTheme { get; set; } = ""; public string LogoLink { get; set; } = ""; public string Footer { get; set; } = ""; diff --git a/Wave/Data/EmailNewsletter.cs b/Wave/Data/EmailNewsletter.cs new file mode 100644 index 0000000..142b373 --- /dev/null +++ b/Wave/Data/EmailNewsletter.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Wave.Data; + +public class EmailNewsletter { + [Key] + public int Id { get; set; } + + public bool IsSend { get; set; } + public required DateTimeOffset DistributionDateTime { get; set; } + public required Article Article { get; set; } +} \ No newline at end of file diff --git a/Wave/Data/EmailSubscriber.cs b/Wave/Data/EmailSubscriber.cs new file mode 100644 index 0000000..8eaf951 --- /dev/null +++ b/Wave/Data/EmailSubscriber.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Wave.Data; + +public class EmailSubscriber { + [Key] + public Guid Id { get; set; } + + [MaxLength(128)] + public string? Name { get; set; } + [EmailAddress, MaxLength(256)] + public required string Email { get; set; } + + public bool Unsubscribed { get; set; } +} \ No newline at end of file diff --git a/Wave/Data/Migrations/postgres/20240207214055_Email.Designer.cs b/Wave/Data/Migrations/postgres/20240207214055_Email.Designer.cs new file mode 100644 index 0000000..c25eae1 --- /dev/null +++ b/Wave/Data/Migrations/postgres/20240207214055_Email.Designer.cs @@ -0,0 +1,566 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Wave.Data; + +#nullable disable + +namespace Wave.Data.Migrations.postgres +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20240207214055_Email")] + partial class Email + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:default-case-insensitive", "und-u-kf-upper-ks-level1,und-u-kf-upper-ks-level1,icu,False") + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Wave.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AboutTheAuthor") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Biography") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("BiographyHtml") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FullName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Wave.Data.Article", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyHtml") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastModified") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("PublishDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewerId") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("ReviewerId"); + + b.ToTable("Articles", (string)null); + }); + + modelBuilder.Entity("Wave.Data.ArticleCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArticleId") + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ArticleId"); + + b.HasIndex("CategoryId"); + + b.ToTable("ArticleCategories", (string)null); + }); + + modelBuilder.Entity("Wave.Data.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(25); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .UseCollation("default-case-insensitive"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Categories", (string)null); + }); + + modelBuilder.Entity("Wave.Data.EmailNewsletter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArticleId") + .HasColumnType("uuid"); + + b.Property("DistributionDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSend") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("ArticleId") + .IsUnique(); + + b.ToTable("Newsletter", (string)null); + }); + + modelBuilder.Entity("Wave.Data.EmailSubscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Unsubscribed") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Unsubscribed"); + + b.ToTable("NewsletterSubscribers", (string)null); + }); + + modelBuilder.Entity("Wave.Data.ProfilePicture", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApplicationUserId") + .HasColumnType("text"); + + b.Property("ImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId") + .IsUnique(); + + b.ToTable("ProfilePictures", (string)null); + }); + + modelBuilder.Entity("Wave.Data.UserLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApplicationUserId") + .HasColumnType("text"); + + b.Property("UrlString") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId"); + + b.ToTable("UserLink"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Wave.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Wave.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wave.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Wave.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wave.Data.Article", b => + { + b.HasOne("Wave.Data.ApplicationUser", "Author") + .WithMany("Articles") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wave.Data.ApplicationUser", "Reviewer") + .WithMany() + .HasForeignKey("ReviewerId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Author"); + + b.Navigation("Reviewer"); + }); + + modelBuilder.Entity("Wave.Data.ArticleCategory", b => + { + b.HasOne("Wave.Data.Article", "Article") + .WithMany() + .HasForeignKey("ArticleId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Wave.Data.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Article"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Wave.Data.EmailNewsletter", b => + { + b.HasOne("Wave.Data.Article", "Article") + .WithOne() + .HasForeignKey("Wave.Data.EmailNewsletter", "ArticleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Article"); + }); + + modelBuilder.Entity("Wave.Data.ProfilePicture", b => + { + b.HasOne("Wave.Data.ApplicationUser", null) + .WithOne("ProfilePicture") + .HasForeignKey("Wave.Data.ProfilePicture", "ApplicationUserId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("Wave.Data.UserLink", b => + { + b.HasOne("Wave.Data.ApplicationUser", null) + .WithMany("Links") + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Wave.Data.ApplicationUser", b => + { + b.Navigation("Articles"); + + b.Navigation("Links"); + + b.Navigation("ProfilePicture"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Wave/Data/Migrations/postgres/20240207214055_Email.cs b/Wave/Data/Migrations/postgres/20240207214055_Email.cs new file mode 100644 index 0000000..df07cc0 --- /dev/null +++ b/Wave/Data/Migrations/postgres/20240207214055_Email.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Wave.Data.Migrations.postgres; + +/// +public partial class Email : Migration { + /// + protected override void Up(MigrationBuilder migrationBuilder) { + migrationBuilder.CreateTable( + name: "Newsletter", + columns: table => new { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", + NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + IsSend = table.Column(type: "boolean", nullable: false), + DistributionDateTime = table.Column(type: "timestamp with time zone", nullable: false), + ArticleId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => { + table.PrimaryKey("PK_Newsletter", x => x.Id); + table.ForeignKey( + name: "FK_Newsletter_Articles_ArticleId", + column: x => x.ArticleId, + principalTable: "Articles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "NewsletterSubscribers", + columns: table => new { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Unsubscribed = table.Column(type: "boolean", nullable: false) + }, + constraints: table => { table.PrimaryKey("PK_NewsletterSubscribers", x => x.Id); }); + + migrationBuilder.CreateIndex( + name: "IX_Newsletter_ArticleId", + table: "Newsletter", + column: "ArticleId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_NewsletterSubscribers_Unsubscribed", + table: "NewsletterSubscribers", + column: "Unsubscribed"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.DropTable( + name: "Newsletter"); + + migrationBuilder.DropTable( + name: "NewsletterSubscribers"); + } +} \ No newline at end of file diff --git a/Wave/Data/Migrations/postgres/ApplicationDbContextModelSnapshot.cs b/Wave/Data/Migrations/postgres/ApplicationDbContextModelSnapshot.cs index 3b38bf3..d7b0177 100644 --- a/Wave/Data/Migrations/postgres/ApplicationDbContextModelSnapshot.cs +++ b/Wave/Data/Migrations/postgres/ApplicationDbContextModelSnapshot.cs @@ -339,6 +339,56 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Categories", (string)null); }); + modelBuilder.Entity("Wave.Data.EmailNewsletter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArticleId") + .HasColumnType("uuid"); + + b.Property("DistributionDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSend") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("ArticleId") + .IsUnique(); + + b.ToTable("Newsletter", (string)null); + }); + + modelBuilder.Entity("Wave.Data.EmailSubscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Unsubscribed") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Unsubscribed"); + + b.ToTable("NewsletterSubscribers", (string)null); + }); + modelBuilder.Entity("Wave.Data.ProfilePicture", b => { b.Property("Id") @@ -472,6 +522,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Category"); }); + modelBuilder.Entity("Wave.Data.EmailNewsletter", b => + { + b.HasOne("Wave.Data.Article", "Article") + .WithOne() + .HasForeignKey("Wave.Data.EmailNewsletter", "ArticleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Article"); + }); + modelBuilder.Entity("Wave.Data.ProfilePicture", b => { b.HasOne("Wave.Data.ApplicationUser", null) diff --git a/Wave/Program.cs b/Wave/Program.cs index 3e7134e..84edbcf 100644 --- a/Wave/Program.cs +++ b/Wave/Program.cs @@ -125,6 +125,8 @@ logMessages.Add("No Email provider configured."); } +builder.Services.AddHostedService(); + #endregion diff --git a/Wave/Services/EmailBackgroundWorker.cs b/Wave/Services/EmailBackgroundWorker.cs new file mode 100644 index 0000000..a23cd4a --- /dev/null +++ b/Wave/Services/EmailBackgroundWorker.cs @@ -0,0 +1,129 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using MimeKit; +using Mjml.Net; +using Wave.Data; +using Wave.Utilities; + +namespace Wave.Services; + +public class EmailBackgroundWorker(ILogger logger, IDbContextFactory contextFactory, IOptions config, IOptions customizations) : IHostedService, IDisposable { + private ILogger Logger { get; } = logger; + private IDbContextFactory ContextFactory { get; } = contextFactory; + private SmtpConfiguration Configuration { get; } = config.Value; + private Customization Customizations { get; } = customizations.Value; + + private Timer? Timer { get; set; } + + public Task StartAsync(CancellationToken cancellationToken) { + Logger.LogInformation("Background email worker starting."); + + // we want this timer to execute every 15 minutes, at fixed times (:00, :15, :30, :45) + var now = DateTimeOffset.UtcNow; + int nowMinute = now.Minute; + int waitTime = 15 - nowMinute % 15; + Logger.LogInformation("First distribution check will be in {waitTime} minutes, at {time}.", + waitTime, now.AddMinutes(waitTime).LocalDateTime.ToString("u")); + Timer = new Timer(DoWork, null, TimeSpan.FromMinutes(waitTime), TimeSpan.FromMinutes(15)); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) { + Logger.LogInformation("Background email worker stopping."); + Timer?.Change(Timeout.Infinite, 0); + return Task.CompletedTask; + } + + public void Dispose() { + Timer?.Dispose(); + GC.SuppressFinalize(this); + } + + private void DoWork(object? _) { + try { + Logger.LogInformation("Checking Articles..."); + if (string.IsNullOrWhiteSpace(Configuration.SenderName)) return; + + using var context = ContextFactory.CreateDbContext(); + var now = DateTimeOffset.UtcNow; + var newsletters = context.Set() + .Include(n => n.Article.Author) + .Include(n => n.Article.Categories) + .Where(n => !n.IsSend && n.DistributionDateTime <= now) + .ToList(); + if (newsletters.Count < 1) return; + + Logger.LogInformation("Processing {count} Articles...", newsletters.Count); + + var sender = new MailboxAddress(Configuration.SenderName, Configuration.SenderEmail); + using var client = new SmtpClient(); + client.Connect(Configuration.Host, Configuration.Port, + Configuration.Ssl ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.None); + if (!string.IsNullOrWhiteSpace(Configuration.Username)) { + client.Authenticate(Configuration.Username, Configuration.Password); + } + + var mjmlRenderer = new MjmlRenderer(); + var options = new MjmlOptions { + Beautify = false + }; + foreach (var newsletter in newsletters) { + Logger.LogInformation("Processing '{title}'.", newsletter.Article.Title); + // set newsletter to send first, so we don't spam people + // in case something unforeseen goes wrong + newsletter.IsSend = true; + context.SaveChanges(); + + string articleLink = ArticleUtilities.GenerateArticleLink( + newsletter.Article, new Uri(Customizations.AppUrl, UriKind.Absolute)); + string unsubscribeLink = new Uri(new Uri(Customizations.AppUrl, UriKind.Absolute), "/unsubscribe").AbsoluteUri; + string template = $""" + + Read in Browser + + + {newsletter.Article.BodyHtml} + + Unsubscribe + + """; + var message = new MimeMessage { + From = { sender }, + To = { }, + Subject = newsletter.Article.Title + }; + var builder = new BodyBuilder() { + HtmlBody = mjmlRenderer.Render(template, options).Html + }; + message.Body = builder.ToMessageBody(); + + EmailSubscriber? last = null; + while (context.Set() + .Where(s => !s.Unsubscribed && (last == null || s.Id > last.Id)) + .OrderBy(s => s.Id) + .Take(50) + .ToList() is { Count: > 0 } subscribers) { + last = subscribers.Last(); + + foreach (var subscriber in subscribers) { + message.To.Clear(); + message.To.Add(new MailboxAddress(subscriber.Name, subscriber.Email)); + client.Send(message); + } + + Task.Delay(TimeSpan.FromSeconds(10)).Wait(); + } + } + + client.Disconnect(true); + Logger.LogInformation("Processing complete."); + } catch (Exception ex) { + Logger.LogError(ex, "Failed to distribute emails."); + } + } +} \ No newline at end of file diff --git a/Wave/Utilities/ArticleUtilities.cs b/Wave/Utilities/ArticleUtilities.cs new file mode 100644 index 0000000..f6d47be --- /dev/null +++ b/Wave/Utilities/ArticleUtilities.cs @@ -0,0 +1,16 @@ +using Wave.Data; + +namespace Wave.Utilities; + +public static class ArticleUtilities { + public static string GenerateArticleLink(Article article, Uri? host) { + string link; + if (article.PublishDate.Year >= 9999) { + link = $"/article/{article.Id}"; + } else { + string titleEncoded = Uri.EscapeDataString(article.Title.ToLowerInvariant()).Replace("-", "+").Replace("%20", "-"); + link = $"/{article.PublishDate.Year}/{article.PublishDate.Month:D2}/{article.PublishDate.Day:D2}/{titleEncoded}"; + } + return (host != null ? new Uri(host, link) : new Uri(link)).AbsoluteUri; + } +} \ No newline at end of file diff --git a/Wave/Wave.csproj b/Wave/Wave.csproj index cf1ca19..e595254 100644 --- a/Wave/Wave.csproj +++ b/Wave/Wave.csproj @@ -26,6 +26,7 @@ +