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
13 changes: 8 additions & 5 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
# 3. Runs integration tests against the live SMTP2GO API (requires secrets)
#
# Tests run against .NET 10 (primary target framework).
# Webhook delivery tests are excluded from CI because they require cloudflared.
# Webhook tests (delivery + management) are excluded from CI because they require cloudflared:
# SMTP2GO validates that a webhook URL points to a reachable destination, so even CRUD tests
# need a real tunnel-backed endpoint.

name: CI

Expand Down Expand Up @@ -64,7 +66,8 @@ jobs:
run: ./tests/Smtp2Go.NET.UnitTests/bin/Debug/net10.0/Smtp2Go.NET.UnitTests

# Run integration tests against the live SMTP2GO API.
# Webhook delivery tests are excluded because they require cloudflared (not available in CI).
# Webhook tests (delivery + management) are excluded because they require cloudflared (not available in CI):
# SMTP2GO now validates webhook-URL reachability, so even CRUD tests need a real tunnel-backed endpoint.
# Sandbox and live API tests run with secrets configured as environment variables.
integration-tests:
needs: build
Expand All @@ -91,11 +94,11 @@ jobs:
# upload-artifact/download-artifact strips POSIX execute bits.
run: chmod +x ./tests/Smtp2Go.NET.IntegrationTests/bin/Debug/net10.0/Smtp2Go.NET.IntegrationTests

- name: Run integration tests (excluding webhook delivery)
# Exclude webhook delivery tests (require cloudflared + tunnel infrastructure).
- name: Run integration tests (excluding webhook tests)
# Exclude webhook tests — delivery AND management/CRUD (require cloudflared + tunnel infrastructure).
# xUnit v3 uses -trait- (with trailing dash) to exclude tests by trait.
# This excludes tests with [Trait("Category", "Integration.Webhook")].
# Sandbox, live API, and webhook management tests all run.
# Sandbox and live API (email/send, email/mime, stats) tests run.
run: ./tests/Smtp2Go.NET.IntegrationTests/bin/Debug/net10.0/Smtp2Go.NET.IntegrationTests -trait- "Category=Integration.Webhook"
env:
# SMTP2GO API keys and test addresses — configured as GitHub repository secrets.
Expand Down
17 changes: 17 additions & 0 deletions src/Smtp2Go.NET/ISmtp2GoClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,21 @@ public interface ISmtp2GoClient
/// <exception cref="Exceptions.Smtp2GoApiException">Thrown when the SMTP2GO API returns an error.</exception>
/// <exception cref="HttpRequestException">Thrown when the HTTP request fails.</exception>
Task<EmailSendResponse> SendEmailAsync(EmailSendRequest request, CancellationToken ct = default);

/// <summary>
/// Sends a pre-built MIME message via the SMTP2GO <c>email/mime</c> endpoint.
/// </summary>
/// <param name="request">The request carrying the Base64-encoded MIME message.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The send response containing success/failure counts and the assigned email id.</returns>
/// <remarks>
/// <para>
/// Unlike <see cref="SendEmailAsync" />, SMTP2GO sends the supplied MIME verbatim instead of reconstructing it
/// from structured fields — preserving the caller's <c>Content-Type</c> (e.g. <c>multipart/alternative</c>) and
/// <c>Message-ID</c>. The response envelope is identical to <c>email/send</c>.
/// </para>
/// </remarks>
/// <exception cref="Exceptions.Smtp2GoApiException">Thrown when the SMTP2GO API returns an error.</exception>
/// <exception cref="HttpRequestException">Thrown when the HTTP request fails.</exception>
Task<EmailSendResponse> SendMimeAsync(EmailMimeRequest request, CancellationToken ct = default);
}
1 change: 1 addition & 0 deletions src/Smtp2Go.NET/Internal/LoggingConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ public static class EventIds
public const int EmailSendStarted = 100;
public const int EmailSendCompleted = 101;
public const int EmailSendFailed = 102;
public const int MimeSendStarted = 103;
public const int EmailSummaryRequested = 110;

// HTTP client events (200-299)
Expand Down
36 changes: 36 additions & 0 deletions src/Smtp2Go.NET/Models/Email/EmailMimeRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace Smtp2Go.NET.Models.Email;

using System.Text.Json.Serialization;

/// <summary>
/// Request model for the SMTP2GO <c>POST /email/mime</c> endpoint.
/// </summary>
/// <remarks>
/// <para>
/// Unlike <see cref="EmailSendRequest" /> — which sends structured fields (<c>sender</c>, <c>to</c>,
/// <c>subject</c>, <c>html_body</c>, …) that SMTP2GO assembles into a MIME message server-side — this endpoint
/// takes a complete, pre-built MIME message and transmits it verbatim. That gives the caller full control over the
/// MIME structure and headers: a true <c>multipart/alternative</c> body, and a caller-supplied <c>Message-ID</c>
/// that SMTP2GO preserves rather than overwriting.
/// </para>
/// <para>
/// The API key travels in the <c>X-Smtp2go-Api-Key</c> request header (set by the client), so the only body field
/// is <see cref="MimeEmail" />. The sender, recipients, subject, and every header are taken from inside the MIME
/// message itself.
/// </para>
/// </remarks>
public class EmailMimeRequest
{
/// <summary>
/// Gets or sets the complete MIME message to send, Base64-encoded.
/// </summary>
/// <remarks>
/// <para>
/// The value must be a valid RFC 5322 / MIME message (headers + body) that has then been Base64-encoded.
/// SMTP2GO sends this exact message; the <c>From</c>, <c>To</c>, <c>Subject</c>, and all headers are read from
/// within it.
/// </para>
/// </remarks>
[JsonPropertyName("mime_email")]
public required string MimeEmail { get; set; }
}
2 changes: 1 addition & 1 deletion src/Smtp2Go.NET/Smtp2Go.NET.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<!-- NuGet Package Properties (common properties inherited from Directory.Build.props) -->
<PackageId>Smtp2Go.NET</PackageId>
<Version>1.2.0</Version>
<Version>1.3.0</Version>
<Description>A .NET client library for the SMTP2GO email delivery API. Supports sending emails, webhook management, and email statistics with built-in resilience.</Description>
<PackageTags>smtp2go;email;smtp;api;webhook;dotnet</PackageTags>
</PropertyGroup>
Expand Down
23 changes: 23 additions & 0 deletions src/Smtp2Go.NET/Smtp2GoClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ internal sealed partial class Smtp2GoClient : Smtp2GoResource, ISmtp2GoClient
/// <summary>API endpoint for sending emails.</summary>
private const string EmailSendEndpoint = "email/send";

/// <summary>API endpoint for sending a pre-built (raw) MIME message.</summary>
private const string EmailMimeEndpoint = "email/mime";

#endregion


Expand Down Expand Up @@ -121,6 +124,22 @@ public async Task<EmailSendResponse> SendEmailAsync(EmailSendRequest request, Ca
return response;
}


/// <inheritdoc />
public async Task<EmailSendResponse> SendMimeAsync(EmailMimeRequest request, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);

LogMimeSendStarted(request.MimeEmail?.Length ?? 0);

var response = await PostAsync<EmailMimeRequest, EmailSendResponse>(
EmailMimeEndpoint, request, ct).ConfigureAwait(false);

LogEmailSendCompleted(response.Data?.Succeeded ?? 0, response.Data?.Failed ?? 0);

return response;
}

#endregion


Expand All @@ -134,5 +153,9 @@ public async Task<EmailSendResponse> SendEmailAsync(EmailSendRequest request, Ca
"Email send completed: {Succeeded} succeeded, {Failed} failed")]
private partial void LogEmailSendCompleted(int succeeded, int failed);

[LoggerMessage(LoggingConstants.EventIds.MimeSendStarted, LogLevel.Information,
"Sending raw MIME email ({MimeLength} base64 chars)")]
private partial void LogMimeSendStarted(int mimeLength);

#endregion
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
namespace Smtp2Go.NET.IntegrationTests.Email;

using Fixtures;
using Helpers;
using Smtp2Go.NET.Models.Email;

/// <summary>
/// Live integration test for the <see cref="ISmtp2GoClient.SendMimeAsync" /> endpoint (<c>email/mime</c>) using the
/// live API key (the message is actually delivered).
/// </summary>
/// <remarks>
/// <para>
/// Sends a real, pre-built multipart/alternative message to the configured test recipient. Asserts the live API
/// accepted the verbatim MIME for delivery; it does not read the mailbox back (receipt-level verification — that
/// SMTP2GO delivered the multipart and the deterministic Message-ID intact — is owned by the consumer's delivery E2E,
/// AlosNotify <c>DR01</c>/<c>DR02</c>). Use with caution: the recipient must be a controlled mailbox.
/// </para>
/// </remarks>
[Trait("Category", "Integration.Live")]
public sealed class EmailMimeLiveIntegrationTests : IClassFixture<Smtp2GoLiveFixture>
{
#region Properties & Fields - Non-Public

/// <summary>The live-configured client fixture.</summary>
private readonly Smtp2GoLiveFixture _fixture;

#endregion


#region Constructors

/// <summary>Initializes a new instance of the <see cref="EmailMimeLiveIntegrationTests" /> class.</summary>
public EmailMimeLiveIntegrationTests(Smtp2GoLiveFixture fixture)
{
_fixture = fixture;
}

#endregion


#region Send MIME - Live Delivery

[Fact]
public async Task SendMime_WithLiveKey_DeliversToRecipient()
{
// Fail if live secrets are not configured.
TestSecretValidator.AssertLiveSecretsPresent();

// Arrange — a real multipart/alternative message submitted verbatim via email/mime.
var mimeEmail = RawMimeBuilder.BuildMultipartAlternativeBase64(
from: _fixture.TestSender,
to: _fixture.TestRecipient,
subject: $"Smtp2Go.NET Live MIME Integration Test - {DateTime.UtcNow:O}",
messageId: $"{Guid.NewGuid():N}@smtp2go-net.test",
textBody: "This is a live email/mime integration test from Smtp2Go.NET. No action required.",
htmlBody: $"""
<h2>Smtp2Go.NET Live email/mime Integration Test</h2>
<p>This message was submitted verbatim through the email/mime endpoint.</p>
<p>No action is required. It confirms live delivery via SendMimeAsync is working correctly.</p>
<hr />
<p style="color: #999; font-size: 12px;">Sent at {DateTime.UtcNow:O}</p>
""");

// Act
var response = await _fixture.Client.SendMimeAsync(
new EmailMimeRequest { MimeEmail = mimeEmail },
TestContext.Current.CancellationToken);

// Assert — the live API should accept and queue the email for delivery.
response.Should().NotBeNull();
response.RequestId.Should().NotBeNullOrWhiteSpace();
response.Data.Should().NotBeNull();
response.Data!.Succeeded.Should().Be(1, "the test recipient should succeed");
response.Data.Failed.Should().Be(0, "no recipients should fail");
response.Data.EmailId.Should().NotBeNullOrWhiteSpace("a live email should receive an email ID");
}

#endregion
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
namespace Smtp2Go.NET.IntegrationTests.Email;

using Fixtures;
using Helpers;
using Smtp2Go.NET.Exceptions;
using Smtp2Go.NET.Models.Email;

/// <summary>
/// Integration tests for the <see cref="ISmtp2GoClient.SendMimeAsync" /> endpoint (<c>email/mime</c>) using the
/// sandbox API key (the message is accepted but not delivered).
/// </summary>
/// <remarks>
/// <para>
/// Unlike <c>email/send</c> (which reconstructs the MIME from structured fields), <c>email/mime</c> takes a pre-built
/// RFC 5322 message as Base64 and sends it verbatim. These tests prove the SDK posts that payload to the real endpoint
/// and the API accepts it; end-to-end preservation of the verbatim <c>Content-Type</c>/<c>Message-ID</c> at the
/// recipient is owned by the consumer's delivery E2E (AlosNotify <c>DR01</c>/<c>DR02</c>).
/// </para>
/// </remarks>
[Trait("Category", "Integration")]
public sealed class EmailMimeSandboxIntegrationTests : IClassFixture<Smtp2GoSandboxFixture>
{
#region Properties & Fields - Non-Public

/// <summary>The sandbox-configured client fixture.</summary>
private readonly Smtp2GoSandboxFixture _fixture;

#endregion


#region Constructors

/// <summary>Initializes a new instance of the <see cref="EmailMimeSandboxIntegrationTests" /> class.</summary>
public EmailMimeSandboxIntegrationTests(Smtp2GoSandboxFixture fixture)
{
_fixture = fixture;
}

#endregion


#region Send MIME - Success

[Fact]
public async Task SendMime_WithSandboxKey_ReturnsSuccessResponse()
{
// Fail if sandbox secrets are not configured.
TestSecretValidator.AssertSandboxSecretsPresent();

// Arrange — a pre-built multipart/alternative message submitted verbatim.
var mimeEmail = RawMimeBuilder.BuildMultipartAlternativeBase64(
from: _fixture.TestSender,
to: "sandbox-recipient@example.com",
subject: $"Smtp2Go.NET MIME Integration Test - {DateTime.UtcNow:O}",
messageId: $"{Guid.NewGuid():N}@smtp2go-net.test",
textBody: "This is the plain-text alternative of an automated email/mime integration test. No action needed.",
htmlBody: "<html><body><h1>email/mime Integration Test</h1><p>Automated test — no action needed.</p></body></html>");

// Act
var response = await _fixture.Client.SendMimeAsync(
new EmailMimeRequest { MimeEmail = mimeEmail },
TestContext.Current.CancellationToken);

// Assert — the sandbox API should accept the verbatim MIME and return a success response.
response.Should().NotBeNull();
response.RequestId.Should().NotBeNullOrWhiteSpace("the API should return a request ID");
response.Data.Should().NotBeNull("the response should contain data");
response.Data!.Succeeded.Should().BeGreaterThanOrEqualTo(1, "the sandbox API should accept the MIME message");
response.Data.EmailId.Should().NotBeNullOrWhiteSpace("the API should return an email ID");
}

#endregion


#region Send MIME - Error Handling

[Fact]
public async Task SendMime_WithInvalidApiKey_ThrowsSmtp2GoApiException()
{
// Arrange — a correctly-formatted but nonexistent key triggers an auth error (not a format error).
var invalidClient = Smtp2GoClientFactory.CreateClient("api-00000000000000000000000000000000");

var mimeEmail = RawMimeBuilder.BuildMultipartAlternativeBase64(
from: _fixture.TestSender,
to: "recipient@example.com",
subject: "Invalid Key MIME Test",
messageId: $"{Guid.NewGuid():N}@smtp2go-net.test",
textBody: "This should fail.",
htmlBody: "<p>This should fail.</p>");

// Act
var act = async () => await invalidClient.SendMimeAsync(
new EmailMimeRequest { MimeEmail = mimeEmail },
TestContext.Current.CancellationToken);

// Assert
await act.Should().ThrowAsync<Smtp2GoApiException>();
}

#endregion
}
Loading
Loading