From 2dec799d71c7c06a00220626164189109fe558a4 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 22 Jun 2026 01:44:24 +0100 Subject: [PATCH] Introduce Yavsc.Interfaces.ISmtpClient and a recording test fake Narrow ISmtpClient to the four operations MailSender actually uses, behind a Yavsc.Interfaces.ISmtpClientFactory. Production wires MailKitSmtpClient (SmtpClientFactory); tests wire a recording fake (RecordingSmtpClientFactory). The fake is pre-registered in WebServerFixture so SMTP calls are short-circuited; the EMailling test now asserts the Connect -> Authenticate -> Send -> Disconnect sequence. Yavsc.Abstract stays free of MailKit/MimeKit. --- .../PostIt.Desktop/PlatformBootstrap.cs | 4 +- src/Yavsc.Api/Program.cs | 12 ++-- src/Yavsc.Blogs/Program.cs | 7 +- .../Fakes/RecordingSmtpClient.cs | 71 +++++++++++++++++++ .../Fakes/RecordingSmtpClientFactory.cs | 21 ++++++ .../NonRegression/EMailling.cs | 19 +++++ src/Yavsc.Org.Tests/WebServerFixture.cs | 21 ++++++ src/Yavsc.Org/Extensions/HostingExtensions.cs | 3 + src/Yavsc.Server/Interfaces/ISmtpClient.cs | 25 +++++++ .../Interfaces/ISmtpClientFactory.cs | 13 ++++ .../Services/MailKitSmtpClient.cs | 53 ++++++++++++++ src/Yavsc.Server/Services/MailSender.cs | 11 ++- .../Services/SmtpClientFactory.cs | 14 ++++ 13 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 src/Yavsc.Org.Tests/Fakes/RecordingSmtpClient.cs create mode 100644 src/Yavsc.Org.Tests/Fakes/RecordingSmtpClientFactory.cs create mode 100644 src/Yavsc.Server/Interfaces/ISmtpClient.cs create mode 100644 src/Yavsc.Server/Interfaces/ISmtpClientFactory.cs create mode 100644 src/Yavsc.Server/Services/MailKitSmtpClient.cs create mode 100644 src/Yavsc.Server/Services/SmtpClientFactory.cs diff --git a/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs b/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs index eb021646..a8bd576a 100644 --- a/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs +++ b/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs @@ -19,6 +19,6 @@ internal static class PlatformBootstrap return; Platform.DefaultRedirectUri = Settings.DefaultLoopbackRedirectUri; - Platform.CreateBrowser = () => new LoopbackBrowser(); + } -} \ No newline at end of file +} diff --git a/src/Yavsc.Api/Program.cs b/src/Yavsc.Api/Program.cs index 4fa640d9..f7f247cc 100644 --- a/src/Yavsc.Api/Program.cs +++ b/src/Yavsc.Api/Program.cs @@ -1,10 +1,10 @@ /* - Copyright (c) 2024 HigginsSoft, Alexander Higgins - https://github.com/alexhiggins732/ + Copyright (c) 2024 HigginsSoft, Alexander Higgins - https://github.com/alexhiggins732/ Copyright (c) 2018, Brock Allen & Dominick Baier. All rights reserved. - Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. - Source code and license this software can be found + Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + Source code and license this software can be found The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. @@ -14,10 +14,12 @@ using Anthropic.SDK; using IdentityModel; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection.Extensions; using Yavsc; using Yavsc.Abstract.Interfaces; using Yavsc.Helpers; using Yavsc.Interface; +using Yavsc.Interfaces; using Yavsc.Models; using Yavsc.Server.Helpers; using Yavsc.Services; @@ -79,11 +81,13 @@ internal class Program { options.ResourcesPath = "Resources"; }); - // + // services.AddTransient, MailSender>(); services.AddTransient() .AddTransient() + .TryAddSingleton(); + services .AddTransient() .AddTransient(); services.AddTransient(); diff --git a/src/Yavsc.Blogs/Program.cs b/src/Yavsc.Blogs/Program.cs index 768d8606..8139049c 100644 --- a/src/Yavsc.Blogs/Program.cs +++ b/src/Yavsc.Blogs/Program.cs @@ -2,7 +2,10 @@ using IdentityModel; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Yavsc; using Yavsc.Interface; +using Yavsc.Interfaces; using Yavsc.Models; using Yavsc.Services; using Yavsc.Server.Helpers; @@ -48,6 +51,8 @@ internal class Program services .AddTransient() .AddTransient, MailSender>() + .TryAddSingleton(); + services .AddTransient() .AddTransient() .AddTransient() @@ -89,7 +94,7 @@ internal class Program new JsonResult(context?.User?.Claims.Select(c => new { c.Type, c.Value })) ); - app.UseSession(); + app.UseSession(); await app.RunAsync(); } } diff --git a/src/Yavsc.Org.Tests/Fakes/RecordingSmtpClient.cs b/src/Yavsc.Org.Tests/Fakes/RecordingSmtpClient.cs new file mode 100644 index 00000000..fe3f3a9e --- /dev/null +++ b/src/Yavsc.Org.Tests/Fakes/RecordingSmtpClient.cs @@ -0,0 +1,71 @@ +using MailKit.Security; +using MimeKit; +using Yavsc.Interfaces; + +namespace Yavsc.Org.Tests.Fakes +{ + /// + /// In-memory test double for . Records + /// every call for assertion and short-circuits the SMTP + /// roundtrip. + /// + public sealed class RecordingSmtpClient : ISmtpClient + { + public List Calls { get; } = new(); + + public int Timeout { get; set; } + public string? LastConnectedHost { get; private set; } + public int LastConnectedPort { get; private set; } + public SecureSocketOptions LastConnectedOptions { get; private set; } + public string? LastAuthenticatedUser { get; private set; } + public MimeMessage? LastSentMessage { get; private set; } + public bool WasDisconnected { get; private set; } + public bool Disposed { get; private set; } + + public void Connect(string host, int port, SecureSocketOptions options) + { + LastConnectedHost = host; + LastConnectedPort = port; + LastConnectedOptions = options; + Calls.Add(new RecordingSmtpCall(RecordingSmtpCallKind.Connect, host, port, options)); + } + + public void Authenticate(string userName, string password) + { + LastAuthenticatedUser = userName; + Calls.Add(new RecordingSmtpCall(RecordingSmtpCallKind.Authenticate, userName, null, null)); + } + + public Task SendAsync(MimeMessage message, CancellationToken cancellationToken = default) + { + LastSentMessage = message; + Calls.Add(new RecordingSmtpCall(RecordingSmtpCallKind.Send, message.Subject, null, null)); + return Task.CompletedTask; + } + + public void Disconnect(bool quit) + { + WasDisconnected = quit; + Calls.Add(new RecordingSmtpCall(RecordingSmtpCallKind.Disconnect, null, null, null)); + } + + public void Dispose() + { + Disposed = true; + } + } + + public enum RecordingSmtpCallKind + { + Connect, + Authenticate, + Send, + Disconnect, + } + + public readonly record struct RecordingSmtpCall( + RecordingSmtpCallKind Kind, + string? HostOrSubjectOrUser, + int? Port, + SecureSocketOptions? Options); +} diff --git a/src/Yavsc.Org.Tests/Fakes/RecordingSmtpClientFactory.cs b/src/Yavsc.Org.Tests/Fakes/RecordingSmtpClientFactory.cs new file mode 100644 index 00000000..96bdae56 --- /dev/null +++ b/src/Yavsc.Org.Tests/Fakes/RecordingSmtpClientFactory.cs @@ -0,0 +1,21 @@ +using Yavsc.Interfaces; + +namespace Yavsc.Org.Tests.Fakes +{ + /// + /// Test double for . Hands out + /// instances and tracks them so + /// tests can assert on every SMTP interaction. + /// + public sealed class RecordingSmtpClientFactory : ISmtpClientFactory + { + public List Created { get; } = new(); + + public ISmtpClient CreateClient() + { + var client = new RecordingSmtpClient(); + Created.Add(client); + return client; + } + } +} diff --git a/src/Yavsc.Org.Tests/NonRegression/EMailling.cs b/src/Yavsc.Org.Tests/NonRegression/EMailling.cs index 0c65f1ca..453c2650 100644 --- a/src/Yavsc.Org.Tests/NonRegression/EMailling.cs +++ b/src/Yavsc.Org.Tests/NonRegression/EMailling.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Yavsc.Interface; +using Yavsc.Interfaces; +using Yavsc.Org.Tests.Fakes; namespace Yavsc.Org.Tests { @@ -26,6 +28,8 @@ namespace Yavsc.Org.Tests using IServiceScope scope = _serverFixture.Services.CreateScope(); ITrueEmailSender mailSender = scope.ServiceProvider.GetRequiredService(); + var factory = Assert.IsType( + scope.ServiceProvider.GetRequiredService()); output.WriteLine("SendEMailSynchrone ..."); mailSender.SendEmailAsync @@ -35,6 +39,21 @@ namespace Yavsc.Org.Tests $"monthly email", "test boby monthly email").Wait(); + // Assert the SMTP roundtrip was short-circuited by the + // recording fake installed in WebServerFixture: exactly + // one client was created and it saw the expected sequence + // of Connect → Authenticate → Send → Disconnect. + var client = Assert.Single(factory.Created); + Assert.Equal( + new[] + { + RecordingSmtpCallKind.Connect, + RecordingSmtpCallKind.Authenticate, + RecordingSmtpCallKind.Send, + RecordingSmtpCallKind.Disconnect, + }, + client.Calls.Select(c => c.Kind).ToArray()); + Assert.Equal(_serverFixture.SiteSettings.Owner.EMail, client.LastSentMessage?.To.Mailboxes.First().Address); } } } diff --git a/src/Yavsc.Org.Tests/WebServerFixture.cs b/src/Yavsc.Org.Tests/WebServerFixture.cs index fd2af225..da1b30a2 100644 --- a/src/Yavsc.Org.Tests/WebServerFixture.cs +++ b/src/Yavsc.Org.Tests/WebServerFixture.cs @@ -17,9 +17,11 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Yavsc; using Yavsc.Extensions; +using Yavsc.Interfaces; using Yavsc.Models; using Yavsc.Server.Helpers; using Client = IdentityServer8.EntityFramework.Entities.Client; +using Yavsc.Org.Tests.Fakes; namespace Yavsc.Org.Tests @@ -40,6 +42,7 @@ namespace Yavsc.Org.Tests private static string? _sharedTestingUserName; private static string? _sharedTestingUserPassword; private static string? _sharedTestingUserEmail; + private static RecordingSmtpClientFactory? _sharedSmtpClientFactory; private static IServiceProvider? _sharedServices; private static IConfiguration? _sharedConfiguration; private static SiteSettings? _sharedSiteSettings; @@ -64,6 +67,7 @@ namespace Yavsc.Org.Tests public SiteSettings? SiteSettings { get => siteSettings; set => siteSettings = value; } public string? TestClientSecret { get; set; } public string? TestingUserEmail { get; set; } + public RecordingSmtpClientFactory? SmtpClientFactory { get; private set; } public WebServerFixture() { lock (_sync) @@ -100,6 +104,7 @@ namespace Yavsc.Org.Tests _sharedTestingUserName = null; _sharedTestingUserPassword = null; _sharedTestingUserEmail = null; + _sharedSmtpClientFactory = null; } } } @@ -116,6 +121,7 @@ namespace Yavsc.Org.Tests TestingUserName = _sharedTestingUserName; TestingUserPassword = _sharedTestingUserPassword; TestingUserEmail = _sharedTestingUserEmail; + SmtpClientFactory = _sharedSmtpClientFactory; } public async Task SetupHost() @@ -156,8 +162,23 @@ namespace Yavsc.Org.Tests // IServiceCollection.AddSingleton. builder.Services.AddSingleton(); + // Replace the production ISmtpClientFactory (added later + // by ConfigureWebAppServices via TryAddSingleton) with a + // recording fake. By pre-registering here, the prod + // TryAdd becomes a no-op and tests get a single shared + // fake they can assert against. + var smtpFactory = new RecordingSmtpClientFactory(); + builder.Services.AddSingleton(smtpFactory); + _sharedSmtpClientFactory = smtpFactory; + _app = builder.ConfigureWebAppServices(); + // Note: a recording ISmtpClientFactory is registered + // BEFORE ConfigureWebAppServices() above (further up in + // this method) so the production TryAddSingleton inside + // ConfigureWebAppServices becomes a no-op. + // _sharedSmtpClientFactory is captured there. + // The MSBuild target CopyYavscOrgStaticAssets in // Yavsc.Org.Tests.csproj mirrors the Yavsc.Org static // assets manifest into the test bin directory. Call diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index 9ba36563..f2ae820e 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -102,6 +103,8 @@ public static class HostingExtensions services.AddTransient() .AddTransient(); + services.TryAddSingleton(); + services.AddTransient() .AddTransient() diff --git a/src/Yavsc.Server/Interfaces/ISmtpClient.cs b/src/Yavsc.Server/Interfaces/ISmtpClient.cs new file mode 100644 index 00000000..6692cdba --- /dev/null +++ b/src/Yavsc.Server/Interfaces/ISmtpClient.cs @@ -0,0 +1,25 @@ +using System.Threading; +using System.Threading.Tasks; +using MimeKit; + +namespace Yavsc.Interfaces +{ + /// + /// Yavsc's own SMTP client surface. Narrower than + /// MailKit.Net.Smtp.ISmtpClient: only the calls that + /// MailSender actually makes. Production wires + /// MailKitSmtpClient; tests wire a recording fake. + /// + public interface ISmtpClient : IDisposable + { + int Timeout { get; set; } + + void Connect(string host, int port, MailKit.Security.SecureSocketOptions options); + + void Authenticate(string userName, string password); + + Task SendAsync(MimeMessage message, CancellationToken cancellationToken = default); + + void Disconnect(bool quit); + } +} diff --git a/src/Yavsc.Server/Interfaces/ISmtpClientFactory.cs b/src/Yavsc.Server/Interfaces/ISmtpClientFactory.cs new file mode 100644 index 00000000..af394f3c --- /dev/null +++ b/src/Yavsc.Server/Interfaces/ISmtpClientFactory.cs @@ -0,0 +1,13 @@ +namespace Yavsc.Interfaces +{ + /// + /// Builds fresh instances on demand. + /// Per-call construction is required because MailSender + /// owns its client with a using block: a singleton or + /// scoped client would race across concurrent sends. + /// + public interface ISmtpClientFactory + { + ISmtpClient CreateClient(); + } +} diff --git a/src/Yavsc.Server/Services/MailKitSmtpClient.cs b/src/Yavsc.Server/Services/MailKitSmtpClient.cs new file mode 100644 index 00000000..f8bfb59c --- /dev/null +++ b/src/Yavsc.Server/Services/MailKitSmtpClient.cs @@ -0,0 +1,53 @@ +using MailKit.Security; +using MimeKit; +using Yavsc.Interfaces; + +namespace Yavsc.Services +{ + /// + /// Production adapter from + /// to MailKit's SmtpClient. Stateless; the underlying + /// MailKit client is created per Connect cycle by the + /// factory and disposed by MailSender's using. + /// + public sealed class MailKitSmtpClient : ISmtpClient + { + private readonly MailKit.Net.Smtp.SmtpClient _client; + + public MailKitSmtpClient() + { + _client = new MailKit.Net.Smtp.SmtpClient(); + } + + public int Timeout + { + get => _client.Timeout; + set => _client.Timeout = value; + } + + public void Connect(string host, int port, SecureSocketOptions options) + { + _client.Connect(host, port, options); + } + + public void Authenticate(string userName, string password) + { + _client.Authenticate(userName, password); + } + + public Task SendAsync(MimeMessage message, CancellationToken cancellationToken = default) + { + return _client.SendAsync(message, cancellationToken); + } + + public void Disconnect(bool quit) + { + _client.Disconnect(quit); + } + + public void Dispose() + { + _client.Dispose(); + } + } +} diff --git a/src/Yavsc.Server/Services/MailSender.cs b/src/Yavsc.Server/Services/MailSender.cs index 98f7e35d..5e9e6d49 100644 --- a/src/Yavsc.Server/Services/MailSender.cs +++ b/src/Yavsc.Server/Services/MailSender.cs @@ -1,11 +1,11 @@ using System.Net; -using MailKit.Net.Smtp; using MailKit.Security; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MimeKit; using Microsoft.AspNetCore.Identity; using Yavsc.Interface; +using Yavsc.Interfaces; using Yavsc.Settings; using Yavsc.Models; using Microsoft.AspNetCore.Identity.UI.Services; @@ -16,21 +16,26 @@ namespace Yavsc.Services { public class MailSender : IEmailSender, IEmailSender, ITrueEmailSender { + private readonly IStringLocalizer localizer; readonly SiteSettings siteSettings; readonly SmtpSettings smtpSettings; private readonly ILogger logger; + private readonly ISmtpClientFactory _smtpClientFactory; + public MailSender( IOptions sitesOptions, IOptions smtpOptions, ILoggerFactory loggerFactory, - IStringLocalizer localizer + IStringLocalizer localizer, + ISmtpClientFactory smtpClientFactory ) { this.localizer = localizer; siteSettings = sitesOptions.Value; smtpSettings = smtpOptions.Value; logger = loggerFactory.CreateLogger(); + _smtpClientFactory = smtpClientFactory; } public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) @@ -69,7 +74,7 @@ namespace Yavsc.Services msg.MessageId = MimeKit.Utils.MimeUtils.GenerateMessageId( siteSettings.Authority ); - using (SmtpClient sc = new()) + using ISmtpClient sc = _smtpClientFactory.CreateClient(); { sc.Timeout = 30000; sc.Connect( diff --git a/src/Yavsc.Server/Services/SmtpClientFactory.cs b/src/Yavsc.Server/Services/SmtpClientFactory.cs new file mode 100644 index 00000000..9ff478a6 --- /dev/null +++ b/src/Yavsc.Server/Services/SmtpClientFactory.cs @@ -0,0 +1,14 @@ +using Yavsc.Interfaces; + +namespace Yavsc.Services +{ + /// + /// Production : hands out a + /// fresh per call. Stateless; + /// safe to register as a singleton. + /// + public sealed class SmtpClientFactory : ISmtpClientFactory + { + public ISmtpClient CreateClient() => new MailKitSmtpClient(); + } +}