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.
This commit is contained in:
Paul Schneider 2026-06-22 01:44:24 +01:00
commit 2dec799d71
13 changed files with 264 additions and 10 deletions

View file

@ -0,0 +1,25 @@
using System.Threading;
using System.Threading.Tasks;
using MimeKit;
namespace Yavsc.Interfaces
{
/// <summary>
/// Yavsc's own SMTP client surface. Narrower than
/// <c>MailKit.Net.Smtp.ISmtpClient</c>: only the calls that
/// <c>MailSender</c> actually makes. Production wires
/// <c>MailKitSmtpClient</c>; tests wire a recording fake.
/// </summary>
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);
}
}

View file

@ -0,0 +1,13 @@
namespace Yavsc.Interfaces
{
/// <summary>
/// Builds fresh <see cref="ISmtpClient"/> instances on demand.
/// Per-call construction is required because <c>MailSender</c>
/// owns its client with a <c>using</c> block: a singleton or
/// scoped client would race across concurrent sends.
/// </summary>
public interface ISmtpClientFactory
{
ISmtpClient CreateClient();
}
}

View file

@ -0,0 +1,53 @@
using MailKit.Security;
using MimeKit;
using Yavsc.Interfaces;
namespace Yavsc.Services
{
/// <summary>
/// Production adapter from <see cref="Yavsc.Interfaces.ISmtpClient"/>
/// to MailKit's <c>SmtpClient</c>. Stateless; the underlying
/// MailKit client is created per <c>Connect</c> cycle by the
/// factory and disposed by <c>MailSender</c>'s <c>using</c>.
/// </summary>
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();
}
}
}

View file

@ -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<ApplicationUser>, IEmailSender, ITrueEmailSender
{
private readonly IStringLocalizer<MailSender> localizer;
readonly SiteSettings siteSettings;
readonly SmtpSettings smtpSettings;
private readonly ILogger logger;
private readonly ISmtpClientFactory _smtpClientFactory;
public MailSender(
IOptions<SiteSettings> sitesOptions,
IOptions<SmtpSettings> smtpOptions,
ILoggerFactory loggerFactory,
IStringLocalizer<MailSender> localizer
IStringLocalizer<MailSender> localizer,
ISmtpClientFactory smtpClientFactory
)
{
this.localizer = localizer;
siteSettings = sitesOptions.Value;
smtpSettings = smtpOptions.Value;
logger = loggerFactory.CreateLogger<MailSender>();
_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(

View file

@ -0,0 +1,14 @@
using Yavsc.Interfaces;
namespace Yavsc.Services
{
/// <summary>
/// Production <see cref="ISmtpClientFactory"/>: hands out a
/// fresh <see cref="MailKitSmtpClient"/> per call. Stateless;
/// safe to register as a singleton.
/// </summary>
public sealed class SmtpClientFactory : ISmtpClientFactory
{
public ISmtpClient CreateClient() => new MailKitSmtpClient();
}
}