Skip to content

Commit 8c89a47

Browse files
committed
Implement JobQueue from Korga (resolves #4)
1 parent 06abcc0 commit 8c89a47

27 files changed

+1001
-505
lines changed

src/TravelBlog/Configuration/MailingOptions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ public class MailingOptions
88
public bool EnableMailing { get; set; }
99
[Required] public string? SenderName { get; set; }
1010
[Required, EmailAddress] public string? SenderAddress { get; set; }
11-
public string? AuthorName { get; set; }
12-
[EmailAddress] public string? AuthorAddress { get; set; }
11+
[Required] public string? AuthorName { get; set; }
12+
[Required, EmailAddress] public string? AuthorAddress { get; set; }
1313

1414
[Required] public string? SmtpUsername { get; set; }
1515
[Required] public string? SmtpPassword { get; set; }

src/TravelBlog/Controllers/BlogPostController.cs

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,32 +8,34 @@
88
using Microsoft.EntityFrameworkCore;
99
using Microsoft.Extensions.Options;
1010
using Microsoft.Extensions.Primitives;
11+
using MimeKit;
1112
using TravelBlog.Configuration;
1213
using TravelBlog.Database;
1314
using TravelBlog.Database.Entities;
1415
using TravelBlog.Extensions;
1516
using TravelBlog.Models;
1617
using TravelBlog.Services;
17-
using TravelBlog.Services.LightJobManager;
1818
using UAParser;
1919

2020
namespace TravelBlog.Controllers;
2121

2222
[Route("~/post/{id?}/{action=Index}")]
2323
public class BlogPostController : Controller
2424
{
25-
private readonly IOptions<SiteOptions> options;
25+
private readonly IOptions<SiteOptions> siteOptions;
26+
private readonly IOptions<MailingOptions> mailingOptions;
2627
private readonly DatabaseContext database;
27-
private readonly JobSchedulerService<MailJob, MailJobContext> scheduler;
28+
private readonly EmailDeliveryService deliveryService;
2829
private readonly AuthenticationService authentication;
2930
private readonly MarkdownService markdown;
3031

31-
public BlogPostController(IOptions<SiteOptions> options, DatabaseContext database,
32-
JobSchedulerService<MailJob, MailJobContext> scheduler, AuthenticationService authentication, MarkdownService markdown)
32+
public BlogPostController(IOptions<SiteOptions> siteOptions, IOptions<MailingOptions> mailingOptions, DatabaseContext database,
33+
EmailDeliveryService deliveryService, AuthenticationService authentication, MarkdownService markdown)
3334
{
34-
this.options = options;
35+
this.siteOptions = siteOptions;
36+
this.mailingOptions = mailingOptions;
3537
this.database = database;
36-
this.scheduler = scheduler;
38+
this.deliveryService = deliveryService;
3739
this.authentication = authentication;
3840
this.markdown = markdown;
3941
}
@@ -202,17 +204,25 @@ public async Task<IActionResult> Publish(int id, string title, string? content,
202204
private async Task NotifySubscribers(BlogPost post)
203205
{
204206
List<Subscriber> subscribers = await database.Subscribers
205-
.Where(s => s.ConfirmationTime != default && s.DeletionTime == default).ToListAsync();
207+
.Where(s => s.MailAddress != null && s.ConfirmationTime != default && s.DeletionTime == default).ToListAsync();
206208

207-
await scheduler.Enqueue(subscribers.Select(s =>
209+
await deliveryService.Enqueue(subscribers.Select(subscriber =>
208210
{
209-
string postUrl = Url.ContentLink($"~/post/{post.Id}/auth?token={s.Token}");
210-
string unsubscribeUrl = Url.ContentLink("~/unsubscribe?token=" + s.Token);
211-
string message = $"Hey {s.GivenName},\r\n" +
212-
$"es wurde etwas neues auf {options.Value.BlogName} gepostet:\r\n" +
211+
string postUrl = Url.ContentLink($"~/post/{post.Id}/auth?token={subscriber.Token}");
212+
string unsubscribeUrl = Url.ContentLink("~/unsubscribe?token=" + subscriber.Token);
213+
string message = $"Hey {subscriber.GivenName},\r\n" +
214+
$"es wurde etwas neues auf {siteOptions.Value.BlogName} gepostet:\r\n" +
213215
$"{postUrl}\r\n\r\n" +
214216
$"Du kannst dich von diesem Blog jederzeit hier abmelden: {unsubscribeUrl}";
215-
return new MailJob(id: default, s.Id, subject: "Neuer Post", message);
216-
}));
217+
218+
MimeMessage mimeMessage = new();
219+
mimeMessage.From.Add(new MailboxAddress(mailingOptions.Value.SenderName, mailingOptions.Value.SenderAddress));
220+
mimeMessage.To.Add(new MailboxAddress(subscriber.GetName(), subscriber.MailAddress));
221+
mimeMessage.ReplyTo.Add(new MailboxAddress(mailingOptions.Value.AuthorName, mailingOptions.Value.AuthorAddress));
222+
mimeMessage.Subject = "Neuer Post";
223+
mimeMessage.Body = new TextPart("plain") { Text = message };
224+
225+
return (subscriber.MailAddress!, mimeMessage);
226+
}), post.Id);
217227
}
218228
}

src/TravelBlog/Database/DatabaseContext.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ public class DatabaseContext : DbContext
1313
public DbSet<Subscriber> Subscribers => Set<Subscriber>();
1414
public DbSet<BlogPost> BlogPosts => Set<BlogPost>();
1515
public DbSet<PostRead> PostReads => Set<PostRead>();
16-
public DbSet<MailJob> MailJobs => Set<MailJob>();
16+
public DbSet<OutboxEmail> OutboxEmails => Set<OutboxEmail>();
17+
public DbSet<SentEmail> SentEmails => Set<SentEmail>();
1718

1819
public DatabaseContext(IOptions<DatabaseOptions> options)
1920
{
@@ -47,8 +48,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
4748
postRead.HasOne(r => r.Post).WithMany(p => p!.Reads).HasForeignKey(r => r.PostId);
4849
postRead.HasOne(r => r.Subscriber).WithMany(s => s!.Reads).HasForeignKey(r => r.SubscriberId);
4950

50-
var mailJob = modelBuilder.Entity<MailJob>();
51-
mailJob.HasKey(t => t.Id);
52-
mailJob.HasOne(t => t.Subscriber).WithMany().HasForeignKey(t => t.SubscriberId);
51+
var outboxEmail = modelBuilder.Entity<OutboxEmail>();
52+
outboxEmail.HasKey(e => e.Id);
53+
outboxEmail.HasOne(e => e.BlogPost).WithMany().HasForeignKey(e => e.BlogPostId);
54+
55+
var sentEmail = modelBuilder.Entity<SentEmail>();
56+
sentEmail.HasKey(e => e.Id);
57+
sentEmail.HasOne(e => e.BlogPost).WithMany().HasForeignKey(e => e.BlogPostId);
58+
sentEmail.HasIndex(e => e.DeliveryTime);
59+
sentEmail.Property(e => e.Id).ValueGeneratedNever();
5360
}
5461
}

src/TravelBlog/Database/Entities/MailJob.cs

Lines changed: 0 additions & 20 deletions
This file was deleted.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace TravelBlog.Database.Entities;
2+
3+
public class OutboxEmail
4+
{
5+
public int Id { get; set; }
6+
7+
public int? BlogPostId { get; set; }
8+
public BlogPost? BlogPost { get; set; }
9+
10+
public required string EmailAddress { get; set; }
11+
public required byte[] Content { get; set; }
12+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using System;
2+
3+
namespace TravelBlog.Database.Entities;
4+
5+
public class SentEmail
6+
{
7+
public int Id { get; set; }
8+
9+
public int? BlogPostId { get; set; }
10+
public BlogPost? BlogPost { get; set; }
11+
12+
public required string EmailAddress { get; set; }
13+
public required int ContentSize { get; set; }
14+
public string? ErrorMessage { get; set; }
15+
public required DateTime DeliveryTime { get; set; }
16+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using Nito.AsyncEx;
2+
using System.Threading.Tasks;
3+
using System.Threading;
4+
using System;
5+
6+
namespace TravelBlog.Extensions;
7+
8+
public static class AsyncExtensions
9+
{
10+
// Inspired by https://github.com/StephenCleary/AsyncEx/issues/212#issuecomment-653765593
11+
public static async Task<bool> WaitAsync(this AsyncAutoResetEvent mEvent, TimeSpan timeout, CancellationToken token = default)
12+
{
13+
using var timeOut = new CancellationTokenSource(timeout);
14+
using var combined = CancellationTokenSource.CreateLinkedTokenSource(timeOut.Token, token);
15+
16+
try
17+
{
18+
await mEvent.WaitAsync(combined.Token).ConfigureAwait(false);
19+
return true;
20+
}
21+
// Don't catch the OperationCanceledException from external Token
22+
catch (OperationCanceledException) when (!token.IsCancellationRequested)
23+
{
24+
return false; //Here the OperationCanceledException was raised by Timeout
25+
}
26+
}
27+
}

src/TravelBlog/GlobalSuppressions.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// This file is used by Code Analysis to maintain SuppressMessage
2+
// attributes that are applied to this project.
3+
// Project-level suppressions either have no target or are given
4+
// a specific target and scoped to a namespace, type, member, etc.
5+
6+
using System.Diagnostics.CodeAnalysis;
7+
8+
[assembly: SuppressMessage("Style", "IDE0290:Use primary constructor", Justification = "Primary constructors do not support readonly values in .NET 8.0")]

0 commit comments

Comments
 (0)