Draft: Email distribution system
This commit is contained in:
parent
c1b05b28a5
commit
ef3c71e2ab
|
@ -76,5 +76,24 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
|
|||
|
||||
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");
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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; } = "";
|
||||
|
|
12
Wave/Data/EmailNewsletter.cs
Normal file
12
Wave/Data/EmailNewsletter.cs
Normal 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; }
|
||||
}
|
15
Wave/Data/EmailSubscriber.cs
Normal file
15
Wave/Data/EmailSubscriber.cs
Normal 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; }
|
||||
}
|
566
Wave/Data/Migrations/postgres/20240207214055_Email.Designer.cs
generated
Normal file
566
Wave/Data/Migrations/postgres/20240207214055_Email.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
63
Wave/Data/Migrations/postgres/20240207214055_Email.cs
Normal file
63
Wave/Data/Migrations/postgres/20240207214055_Email.cs
Normal 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");
|
||||
}
|
||||
}
|
|
@ -339,6 +339,56 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
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")
|
||||
|
@ -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)
|
||||
|
|
|
@ -125,6 +125,8 @@
|
|||
logMessages.Add("No Email provider configured.");
|
||||
}
|
||||
|
||||
builder.Services.AddHostedService<EmailBackgroundWorker>();
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
|
|
129
Wave/Services/EmailBackgroundWorker.cs
Normal file
129
Wave/Services/EmailBackgroundWorker.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
16
Wave/Utilities/ArticleUtilities.cs
Normal file
16
Wave/Utilities/ArticleUtilities.cs
Normal 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;
|
||||
}
|
||||
}
|
|
@ -26,6 +26,7 @@
|
|||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||
<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="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
|
||||
<PackageReference Include="System.ServiceModel.Syndication" Version="8.0.0" />
|
||||
|
|
Loading…
Reference in a new issue