Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
177 changes: 176 additions & 1 deletion Sources/Mailozaurr.Tests/SmtpAsyncWrappersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,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 @@ -42,6 +68,155 @@ public async Task ConnectAsync_InvokesClientConnectAsync()
Assert.True(result.Status);
}

[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_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 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);
}
}
81 changes: 79 additions & 2 deletions Sources/Mailozaurr/Smtp/Smtp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ public string? PendingMessagesPath {
/// <summary>Credentials used during authentication.</summary>
public NetworkCredential? Credential { get; private set; }

/// <summary>
/// Effective secure socket options used by the active or most recent connection attempt.
/// </summary>
public SecureSocketOptions ActiveSecureSocketOptions => _activeSecureSocketOptions;

/// <summary>
/// Optional identity hint used to isolate SMTP connection pooling by credentials.
/// </summary>
Expand Down Expand Up @@ -524,8 +529,15 @@ public SmtpResult Connect(string server, int port, SecureSocketOptions secureSoc
/// <param name="useSsl">Compatibility switch. Overrides
/// <paramref name="secureSocketOptions"/> only when set to <c>true</c> and the
/// option is left as <see cref="SecureSocketOptions.Auto"/>.</param>
/// <param name="cancellationToken">Cancellation token for the connect operation.</param>
/// <returns></returns>
public async Task<SmtpResult> ConnectAsync(string server, int port, SecureSocketOptions secureSocketOptions = SecureSocketOptions.Auto, bool useSsl = false) {
public async Task<SmtpResult> ConnectAsync(
string server,
int port,
SecureSocketOptions secureSocketOptions = SecureSocketOptions.Auto,
bool useSsl = false,
CancellationToken cancellationToken = default) {
cancellationToken.ThrowIfCancellationRequested();
var oldServer = Server;
var oldPort = Port;
var oldPoolIdentity = _poolIdentity ?? GetConnectionPoolIdentity();
Expand Down Expand Up @@ -575,11 +587,13 @@ public async Task<SmtpResult> ConnectAsync(string server, int port, SecureSocket
try {
if (!Client.IsConnected)
{
await Client.ConnectAsync(server, port, effectiveOptions);
await Client.ConnectAsync(server, port, effectiveOptions, cancellationToken).ConfigureAwait(false);
}
_poolIdentity = poolIdentity;
LogVerbose($"Connected to {server} on {port} port using SSL: {effectiveOptions}");
return new SmtpResult(true, EmailAction.Connect, SentTo, SentFrom, server, port, Stopwatch.Elapsed, "");
} catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) {
throw;
} catch (Exception ex) {
LogWarning($"Send-EmailMessage - Error during connect: {ex.Message}");
LogWarning($"Send-EmailMessage - Possible issue: Port? ({port} was used), Using SSL? ({effectiveOptions}, was used). You can also try 'SkipCertificateValidation' or 'SkipCertificateRevocation'.");
Expand All @@ -590,6 +604,69 @@ public async Task<SmtpResult> ConnectAsync(string server, int port, SecureSocket
}
}

/// <summary>
/// Connects and authenticates using the provided user name/secret in one step.
/// </summary>
/// <param name="server">SMTP server hostname.</param>
/// <param name="port">SMTP server port.</param>
/// <param name="userName">SMTP user name.</param>
/// <param name="secret">SMTP password or OAuth token.</param>
/// <param name="secureSocketOptions">TLS/SSL options.</param>
/// <param name="useSsl">Compatibility SSL switch.</param>
/// <param name="authMode">Authentication mode.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Combined connect/auth outcome.</returns>
public async Task<SmtpConnectAuthenticateResult> ConnectAndAuthenticateAsync(
string server,
int port,
string userName,
string secret,
SecureSocketOptions secureSocketOptions = SecureSocketOptions.Auto,
bool useSsl = false,
ProtocolAuthMode authMode = ProtocolAuthMode.Basic,
CancellationToken cancellationToken = default) {
cancellationToken.ThrowIfCancellationRequested();

var connectResult = await ConnectAsync(server, port, secureSocketOptions, useSsl, cancellationToken).ConfigureAwait(false);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Key pooled connects with the requested username

This method connects before assigning Credential, but ConnectAsync derives connection-pool identity from GetConnectionPoolIdentity() (which falls back to Credential.UserName). When pooling is enabled and the same Smtp instance is reused across different userName values, the connect phase uses stale/anonymous identity, so clients can be returned/rented under the wrong pool key and reused for the wrong account context.

Useful? React with 👍 / 👎.

if (!connectResult.Status) {
return new SmtpConnectAuthenticateResult {
IsSuccess = false,
SecureSocketOptions = ActiveSecureSocketOptions,
ErrorCode = "connect_failed",
Error = connectResult.Error ?? "Connect failed.",
IsTransient = SmtpValidation.TryValidateServer(server, port, out _)
};
}

if (DryRun) {
LogVerbose("Send-EmailMessage - DryRun enabled, skipping authentication.");
Credential = new NetworkCredential(userName?.Trim() ?? string.Empty, secret ?? string.Empty);
return new SmtpConnectAuthenticateResult {
IsSuccess = true,
SecureSocketOptions = ActiveSecureSocketOptions
};
}

try {
await ProtocolAuth.AuthenticateSmtpAsync(Client, userName, secret, authMode, cancellationToken).ConfigureAwait(false);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip authentication when DryRun is enabled

This method unconditionally runs ProtocolAuth.AuthenticateSmtpAsync(...) after ConnectAsync, but ConnectAsync returns success in dry-run mode without creating a real connection; as a result, ConnectAndAuthenticateAsync can return auth_failed in dry-run scenarios instead of behaving like the existing AuthenticateAsync path that short-circuits under DryRun. That breaks simulation workflows and makes dry-run execution report false failures.

Useful? React with 👍 / 👎.

Credential = new NetworkCredential(userName?.Trim() ?? string.Empty, secret ?? string.Empty);
return new SmtpConnectAuthenticateResult {
IsSuccess = true,
SecureSocketOptions = ActiveSecureSocketOptions
};
} catch (OperationCanceledException) {
throw;
} catch (Exception ex) {
return new SmtpConnectAuthenticateResult {
IsSuccess = false,
SecureSocketOptions = ActiveSecureSocketOptions,
ErrorCode = "auth_failed",
Error = ex.Message,
IsTransient = false
};
}
}

/// <summary>
/// Authenticate using the provided credentials.
/// </summary>
Expand Down
Loading
Loading