Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions Sources/Mailozaurr.Tests/ConnectorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ private class FakeImapClient : ImapClient
public override bool IsAuthenticated => Authenticated;
private bool _connected;
private int _timeout;
public string? AuthMechanism { get; private set; }
public override bool IsConnected => _connected;
public override int Timeout { get => _timeout; set => _timeout = value; }
public bool Disposed { get; private set; }
Expand All @@ -44,6 +45,11 @@ public override Task DisconnectAsync(bool quit, CancellationToken cancellationTo
_connected = false;
return Task.CompletedTask;
}
public override Task AuthenticateAsync(SaslMechanism mechanism, CancellationToken cancellationToken = default) {
AuthMechanism = mechanism.GetType().Name;
Authenticated = true;
return Task.CompletedTask;
}
protected override void Dispose(bool disposing)
{
Disposed = true;
Expand Down Expand Up @@ -160,6 +166,25 @@ public async Task ImapConnector_RequestOverload_UsesRequestSettings()
Assert.Equal(4321, fake.Timeout);
}

[Fact]
public async Task ImapConnector_ConnectAuthenticatedAsync_UsesOAuthAuthentication() {
var fake = new FakeImapClient();
ImapConnector.ClientFactory = () => fake;
var request = new ImapConnectionRequest("imap.example.test", 993);

var client = await ImapConnector.ConnectAuthenticatedAsync(
request,
userName: "user@example.com",
secret: "oauth-token",
mode: ProtocolAuthMode.OAuth2);

ImapConnector.ClientFactory = () => new ImapClient();

Assert.Same(fake, client);
Assert.True(fake.IsAuthenticated);
Assert.Equal(nameof(SaslMechanismOAuth2), fake.AuthMechanism);
}

[Fact]
public async Task ImapConnector_RequestOverload_ValidatesArguments()
{
Expand Down
241 changes: 240 additions & 1 deletion Sources/Mailozaurr.Tests/SmtpAsyncWrappersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -16,9 +17,35 @@ public class SmtpAsyncWrappersTests
private class FakeConnectClient : ClientSmtp
{
public bool ConnectCalled;
public override Task ConnectAsync(string host, int port, SecureSocketOptions options, CancellationToken cancellationToken = default)
public bool ThrowOnConnect;
public bool ThrowOnAuthenticate;
public bool BlockConnectUntilCanceled;
public bool ConnectCanceled;
public SecureSocketOptions? LastSecureSocketOptions;
public CancellationToken LastConnectCancellationToken;
public string? AuthMechanism;
public override async Task ConnectAsync(string host, int port, SecureSocketOptions options, CancellationToken cancellationToken = default)
{
LastConnectCancellationToken = cancellationToken;
if (ThrowOnConnect) {
throw new InvalidOperationException("connect failed");
}
if (BlockConnectUntilCanceled) {
try {
await Task.Delay(global::System.Threading.Timeout.InfiniteTimeSpan, cancellationToken);
} catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) {
ConnectCanceled = true;
throw;
}
}
ConnectCalled = true;
LastSecureSocketOptions = options;
}
public override Task AuthenticateAsync(SaslMechanism mechanism, CancellationToken cancellationToken = default) {
if (ThrowOnAuthenticate) {
throw new InvalidOperationException("auth failed");
}
AuthMechanism = mechanism.GetType().Name;
return Task.CompletedTask;
}
}
Expand All @@ -29,6 +56,18 @@ private static void SetClient(Smtp smtp, ClientSmtp client)
field.SetValue(smtp, client);
}

private static void SetCredential(Smtp smtp, NetworkCredential credential)
{
var field = typeof(Smtp).GetField("<Credential>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)!;
field.SetValue(smtp, credential);
}

private static string? GetPoolIdentity(Smtp smtp)
{
var field = typeof(Smtp).GetField("_poolIdentity", BindingFlags.Instance | BindingFlags.NonPublic)!;
return field.GetValue(smtp) as string;
}

[Fact]
public async Task ConnectAsync_InvokesClientConnectAsync()
{
Expand All @@ -42,6 +81,206 @@ public async Task ConnectAsync_InvokesClientConnectAsync()
Assert.True(result.Status);
}

[Fact]
public void ConnectAsync_PreservesLegacyFourArgumentOverload()
{
var method = typeof(Smtp).GetMethod(
nameof(Smtp.ConnectAsync),
new[] { typeof(string), typeof(int), typeof(SecureSocketOptions), typeof(bool) });

Assert.NotNull(method);
Assert.Equal(typeof(Task<SmtpResult>), method!.ReturnType);
}

[Fact]
public async Task ConnectAndAuthenticateAsync_ReturnsSuccessForOAuthAuthentication()
{
var smtp = new Smtp();
var fake = new FakeConnectClient();
SetClient(smtp, fake);

var result = await smtp.ConnectAndAuthenticateAsync(
"host",
587,
"user@example.com",
"oauth-token",
SecureSocketOptions.StartTls,
useSsl: false,
authMode: ProtocolAuthMode.OAuth2);

Assert.True(result.IsSuccess);
Assert.Equal(SecureSocketOptions.StartTls, result.SecureSocketOptions);
Assert.Equal(nameof(SaslMechanismOAuth2), fake.AuthMechanism);
}

[Fact]
public async Task ConnectAndAuthenticateAsync_MapsConnectFailure()
{
var smtp = new Smtp();
var fake = new FakeConnectClient { ThrowOnConnect = true };
SetClient(smtp, fake);

var result = await smtp.ConnectAndAuthenticateAsync(
"host",
587,
"user@example.com",
"secret",
authMode: ProtocolAuthMode.OAuth2);

Assert.False(result.IsSuccess);
Assert.Equal("connect_failed", result.ErrorCode);
Assert.True(result.IsTransient);
}

[Fact]
public async Task ConnectAndAuthenticateAsync_MapsValidationFailureAsNonTransient()
{
var smtp = new Smtp();
var fake = new FakeConnectClient();
SetClient(smtp, fake);

var result = await smtp.ConnectAndAuthenticateAsync(
string.Empty,
0,
"user@example.com",
"secret",
authMode: ProtocolAuthMode.OAuth2);

Assert.False(result.IsSuccess);
Assert.Equal("connect_failed", result.ErrorCode);
Assert.False(result.IsTransient);
Assert.False(fake.ConnectCalled);
}

[Fact]
public async Task ConnectAndAuthenticateAsync_MapsAuthenticationFailure()
{
var smtp = new Smtp();
var fake = new FakeConnectClient { ThrowOnAuthenticate = true };
SetClient(smtp, fake);

var result = await smtp.ConnectAndAuthenticateAsync(
"host",
587,
"user@example.com",
"secret",
authMode: ProtocolAuthMode.OAuth2);

Assert.False(result.IsSuccess);
Assert.Equal("auth_failed", result.ErrorCode);
Assert.False(result.IsTransient);
}

[Fact]
public async Task ConnectAndAuthenticateAsync_ReThrowsAuthFailureWhenErrorActionStop()
{
var smtp = new Smtp {
ErrorAction = ActionPreference.Stop
};
var fake = new FakeConnectClient { ThrowOnAuthenticate = true };
SetClient(smtp, fake);

await Assert.ThrowsAsync<InvalidOperationException>(() => smtp.ConnectAndAuthenticateAsync(
"host",
587,
"user@example.com",
"secret",
authMode: ProtocolAuthMode.OAuth2));
}

[Fact]
public async Task ConnectAndAuthenticateAsync_DryRunSkipsAuthentication()
{
var smtp = new Smtp {
DryRun = true
};
var fake = new FakeConnectClient();
SetClient(smtp, fake);

var result = await smtp.ConnectAndAuthenticateAsync(
"host",
587,
"user@example.com",
"secret",
authMode: ProtocolAuthMode.OAuth2);

Assert.True(result.IsSuccess);
Assert.True(string.IsNullOrWhiteSpace(result.ErrorCode));
Assert.Null(fake.AuthMechanism);
}

[Fact]
public async Task ConnectAndAuthenticateAsync_ThrowsWhenAlreadyCanceled()
{
var smtp = new Smtp();
var fake = new FakeConnectClient();
SetClient(smtp, fake);

using var cts = new CancellationTokenSource();
cts.Cancel();

await Assert.ThrowsAsync<OperationCanceledException>(() => smtp.ConnectAndAuthenticateAsync(
"host",
587,
"user@example.com",
"secret",
authMode: ProtocolAuthMode.OAuth2,
cancellationToken: cts.Token));

Assert.False(fake.ConnectCalled);
Assert.Null(fake.AuthMechanism);
}

[Fact]
public async Task ConnectAndAuthenticateAsync_PropagatesCancellationIntoConnect()
{
var smtp = new Smtp();
var fake = new FakeConnectClient {
BlockConnectUntilCanceled = true
};
SetClient(smtp, fake);

using var cts = new CancellationTokenSource();
var operation = smtp.ConnectAndAuthenticateAsync(
"host",
587,
"user@example.com",
"secret",
authMode: ProtocolAuthMode.OAuth2,
cancellationToken: cts.Token);

cts.CancelAfter(TimeSpan.FromMilliseconds(25));

await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operation);

Assert.True(fake.ConnectCanceled);
Assert.True(fake.LastConnectCancellationToken.CanBeCanceled);
Assert.Null(fake.AuthMechanism);
}

[Fact]
public async Task ConnectAndAuthenticateAsync_UsesRequestedUsernameForPoolIdentityWhenCredentialWasStale()
{
var smtp = new Smtp();
var fake = new FakeConnectClient();
SetClient(smtp, fake);
SetCredential(smtp, new NetworkCredential("old.user@example.com", "old-secret"));

var result = await smtp.ConnectAndAuthenticateAsync(
"host",
587,
"new.user@example.com",
"secret",
authMode: ProtocolAuthMode.OAuth2);

var poolIdentity = GetPoolIdentity(smtp);

Assert.True(result.IsSuccess);
Assert.NotNull(poolIdentity);
Assert.StartsWith("new.user@example.com|", poolIdentity!, StringComparison.Ordinal);
Assert.Null(smtp.ConnectionPoolIdentity);
}

[Fact]
public async Task CreateMessageAsync_AutoEmbedImagesAddsInlineAttachment()
{
Expand Down
25 changes: 25 additions & 0 deletions Sources/Mailozaurr/Connections/ImapConnector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,29 @@ public static Task<ImapClient> ConnectAsync(
retryDelayBackoff,
DelayAsync,
cancellationToken);

/// <summary>
/// Connects and authenticates to an IMAP server using protocol auth settings in one step.
/// </summary>
/// <param name="request">Connection request settings.</param>
/// <param name="userName">IMAP user name.</param>
/// <param name="secret">IMAP password or OAuth token.</param>
/// <param name="mode">Authentication mode.</param>
/// <param name="cancellationToken">Token used to cancel the operation.</param>
/// <returns>Authenticated <see cref="ImapClient"/> instance.</returns>
public static Task<ImapClient> ConnectAuthenticatedAsync(
ImapConnectionRequest request,
string userName,
string secret,
ProtocolAuthMode mode = ProtocolAuthMode.Basic,
CancellationToken cancellationToken = default) {
if (request is null) {
throw new ArgumentNullException(nameof(request));
}

return ConnectAsync(
request,
(client, ct) => ProtocolAuth.AuthenticateImapAsync(client, userName, secret, mode, ct),
cancellationToken);
}
}
Loading
Loading