Draft: Email distribution system

This commit is contained in:
Mia Rose Winter 2024-02-08 00:50:24 +01:00
parent c1b05b28a5
commit ef3c71e2ab
Signed by: miawinter
GPG key ID: 4B6F6A83178F595E
11 changed files with 885 additions and 0 deletions

View file

@ -76,5 +76,24 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
category.ToTable("Categories"); category.ToTable("Categories");
}); });
builder.Entity<EmailNewsletter>(newsletter => {
newsletter.HasKey(n => n.Id);
newsletter.HasOne(n => n.Article).WithOne().HasForeignKey<EmailNewsletter>()
.IsRequired().OnDelete(DeleteBehavior.Cascade);
newsletter.ToTable("Newsletter");
});
builder.Entity<EmailSubscriber>(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");
});
} }
} }

View file

@ -3,6 +3,7 @@
public class Customization { public class Customization {
public string AppName { get; set; } = "Wave"; public string AppName { get; set; } = "Wave";
public string AppDescription { get; set; } = ""; public string AppDescription { get; set; } = "";
public string AppUrl { get; set; } = "http://localhost";
public string DefaultTheme { get; set; } = ""; public string DefaultTheme { get; set; } = "";
public string LogoLink { get; set; } = ""; public string LogoLink { get; set; } = "";
public string Footer { get; set; } = ""; public string Footer { get; set; } = "";

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -0,0 +1,566 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("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<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Wave.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("AboutTheAuthor")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("Biography")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<string>("BiographyHtml")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<string>("FullName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AuthorId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BodyHtml")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreationDate")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("LastModified")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset>("PublishDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReviewerId")
.HasColumnType("text");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Guid>("ArticleId")
.HasColumnType("uuid");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("Color")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(25);
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Guid>("ArticleId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("DistributionDateTime")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsSend")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("ArticleId")
.IsUnique();
b.ToTable("Newsletter", (string)null);
});
modelBuilder.Entity("Wave.Data.EmailSubscriber", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Name")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<bool>("Unsubscribed")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("Unsubscribed");
b.ToTable("NewsletterSubscribers", (string)null);
});
modelBuilder.Entity("Wave.Data.ProfilePicture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ApplicationUserId")
.HasColumnType("text");
b.Property<Guid>("ImageId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("ProfilePictures", (string)null);
});
modelBuilder.Entity("Wave.Data.UserLink", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ApplicationUserId")
.HasColumnType("text");
b.Property<string>("UrlString")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Id");
b.HasIndex("ApplicationUserId");
b.ToTable("UserLink");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Wave.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Wave.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", 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<string>", 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
}
}
}

View file

@ -0,0 +1,63 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Wave.Data.Migrations.postgres;
/// <inheritdoc />
public partial class Email : Migration {
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) {
migrationBuilder.CreateTable(
name: "Newsletter",
columns: table => new {
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
IsSend = table.Column<bool>(type: "boolean", nullable: false),
DistributionDateTime = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
ArticleId = table.Column<Guid>(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<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Unsubscribed = table.Column<bool>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) {
migrationBuilder.DropTable(
name: "Newsletter");
migrationBuilder.DropTable(
name: "NewsletterSubscribers");
}
}

View file

@ -339,6 +339,56 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("Categories", (string)null); b.ToTable("Categories", (string)null);
}); });
modelBuilder.Entity("Wave.Data.EmailNewsletter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Guid>("ArticleId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("DistributionDateTime")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsSend")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("ArticleId")
.IsUnique();
b.ToTable("Newsletter", (string)null);
});
modelBuilder.Entity("Wave.Data.EmailSubscriber", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Name")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<bool>("Unsubscribed")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("Unsubscribed");
b.ToTable("NewsletterSubscribers", (string)null);
});
modelBuilder.Entity("Wave.Data.ProfilePicture", b => modelBuilder.Entity("Wave.Data.ProfilePicture", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -472,6 +522,17 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("Category"); 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 => modelBuilder.Entity("Wave.Data.ProfilePicture", b =>
{ {
b.HasOne("Wave.Data.ApplicationUser", null) b.HasOne("Wave.Data.ApplicationUser", null)

View file

@ -125,6 +125,8 @@
logMessages.Add("No Email provider configured."); logMessages.Add("No Email provider configured.");
} }
builder.Services.AddHostedService<EmailBackgroundWorker>();
#endregion #endregion

View file

@ -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<EmailBackgroundWorker> logger, IDbContextFactory<ApplicationDbContext> contextFactory, IOptions<SmtpConfiguration> config, IOptions<Customization> customizations) : IHostedService, IDisposable {
private ILogger<EmailBackgroundWorker> Logger { get; } = logger;
private IDbContextFactory<ApplicationDbContext> 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<EmailNewsletter>()
.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 = $"""
<mjml><mj-body><mj-section><mj-column>
<mj-text align="center"><a href="{articleLink}">Read in Browser</a></mj-text>
<mj-image width="200px" src="https://blog.winter-software.com/img/logo.png"></mj-image>
<mj-divider border-width="1px"></mj-divider>
<mj-text>{newsletter.Article.BodyHtml}</mj-text>
<mj-divider border-width="1px"></mj-divider>
<mj-text align="center"><a href="{unsubscribeLink}">Unsubscribe</a></mj-text>
</mj-column></mj-section></mj-body></mjml>
""";
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<EmailSubscriber>()
.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.");
}
}
}

View file

@ -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;
}
}

View file

@ -26,6 +26,7 @@
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.Options" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.19.6" /> <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.19.6" />
<PackageReference Include="Mjml.Net" Version="3.8.0" />
<PackageReference Include="NetEscapades.Configuration.Yaml" Version="3.1.0" /> <PackageReference Include="NetEscapades.Configuration.Yaml" Version="3.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
<PackageReference Include="System.ServiceModel.Syndication" Version="8.0.0" /> <PackageReference Include="System.ServiceModel.Syndication" Version="8.0.0" />