Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
74 changes: 74 additions & 0 deletions Sources/Mailozaurr.Tests/SmtpAsyncWrappersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,24 @@ public class SmtpAsyncWrappersTests
private class FakeConnectClient : ClientSmtp
{
public bool ConnectCalled;
public bool ThrowOnConnect;
public bool ThrowOnAuthenticate;
public SecureSocketOptions? LastSecureSocketOptions;
public string? AuthMechanism;
public override Task ConnectAsync(string host, int port, SecureSocketOptions options, CancellationToken cancellationToken = default)
{
if (ThrowOnConnect) {
throw new InvalidOperationException("connect failed");
}
ConnectCalled = true;
LastSecureSocketOptions = options;
return Task.CompletedTask;
}
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 +57,65 @@ 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_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 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);
}
}
57 changes: 57 additions & 0 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 @@ -590,6 +595,58 @@ 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) {
var connectResult = await ConnectAsync(server, port, secureSocketOptions, useSsl).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 Honor cancellation before starting SMTP connect

The cancellationToken parameter is documented for the combined operation, but the method starts ConnectAsync(...) without checking the token first, so an already-canceled call can still open a network connection before cancellation is observed during authentication. This violates caller expectations for cooperative cancellation and can trigger side effects after cancellation was requested.

Useful? React with 👍 / 👎.

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 Propagate cancellation into the SMTP connect step

ConnectAndAuthenticateAsync accepts a CancellationToken for the combined operation, but the connect phase calls ConnectAsync(server, port, ...) without any token, so cancellation requested after entry is not observed until authentication starts. In practice, a canceled operation can still block through DNS/TCP/TLS connect timeouts and establish a connection even though the caller already canceled.

Useful? React with 👍 / 👎.

if (!connectResult.Status) {
return new SmtpConnectAuthenticateResult {
IsSuccess = false,
SecureSocketOptions = ActiveSecureSocketOptions,
ErrorCode = "connect_failed",
Error = connectResult.Error ?? "Connect failed.",
IsTransient = true
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 Mark non-retryable connect failures as non-transient

All connect failures are currently labeled IsTransient = true, including configuration validation failures such as empty server name or non-positive port returned by SmtpValidation.TryValidateServer in ConnectAsync. This misclassifies permanent input errors as retryable, which can cause wrapper retry logic to loop on bad configuration instead of failing fast.

Useful? React with 👍 / 👎.

};
}

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
33 changes: 33 additions & 0 deletions Sources/Mailozaurr/Smtp/SmtpConnectAuthenticateResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using MailKit.Security;

namespace Mailozaurr;

/// <summary>
/// Result of connecting and authenticating an SMTP session.
/// </summary>
public sealed class SmtpConnectAuthenticateResult {
/// <summary>
/// True when both connect and authenticate succeeded.
/// </summary>
public bool IsSuccess { get; init; }

/// <summary>
/// Effective secure socket options used for the connection.
/// </summary>
public SecureSocketOptions SecureSocketOptions { get; init; } = SecureSocketOptions.Auto;

/// <summary>
/// Stable error code for connect/auth failures.
/// </summary>
public string ErrorCode { get; init; } = string.Empty;

/// <summary>
/// Human-readable error text.
/// </summary>
public string Error { get; init; } = string.Empty;

/// <summary>
/// True when the failure is likely transient.
/// </summary>
public bool IsTransient { get; init; }
}
Loading