+{
+ #region Properties & Fields - Non-Public
+
+ /// The live-configured client fixture.
+ private readonly Smtp2GoLiveFixture _fixture;
+
+ #endregion
+
+
+ #region Constructors
+
+ /// Initializes a new instance of the class.
+ 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: $"""
+ Smtp2Go.NET Live email/mime Integration Test
+ This message was submitted verbatim through the email/mime endpoint.
+ No action is required. It confirms live delivery via SendMimeAsync is working correctly.
+
+ Sent at {DateTime.UtcNow:O}
+ """);
+
+ // 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
+}
diff --git a/tests/Smtp2Go.NET.IntegrationTests/Email/EmailMimeSandboxIntegrationTests.cs b/tests/Smtp2Go.NET.IntegrationTests/Email/EmailMimeSandboxIntegrationTests.cs
new file mode 100644
index 0000000..6084ebb
--- /dev/null
+++ b/tests/Smtp2Go.NET.IntegrationTests/Email/EmailMimeSandboxIntegrationTests.cs
@@ -0,0 +1,101 @@
+namespace Smtp2Go.NET.IntegrationTests.Email;
+
+using Fixtures;
+using Helpers;
+using Smtp2Go.NET.Exceptions;
+using Smtp2Go.NET.Models.Email;
+
+///
+/// Integration tests for the endpoint (email/mime) using the
+/// sandbox API key (the message is accepted but not delivered).
+///
+///
+///
+/// Unlike email/send (which reconstructs the MIME from structured fields), email/mime 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 Content-Type/Message-ID at the
+/// recipient is owned by the consumer's delivery E2E (AlosNotify DR01/DR02).
+///
+///
+[Trait("Category", "Integration")]
+public sealed class EmailMimeSandboxIntegrationTests : IClassFixture
+{
+ #region Properties & Fields - Non-Public
+
+ /// The sandbox-configured client fixture.
+ private readonly Smtp2GoSandboxFixture _fixture;
+
+ #endregion
+
+
+ #region Constructors
+
+ /// Initializes a new instance of the class.
+ 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: "email/mime Integration Test
Automated test — no action needed.
");
+
+ // 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: "This should fail.
");
+
+ // Act
+ var act = async () => await invalidClient.SendMimeAsync(
+ new EmailMimeRequest { MimeEmail = mimeEmail },
+ TestContext.Current.CancellationToken);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ #endregion
+}
diff --git a/tests/Smtp2Go.NET.IntegrationTests/Helpers/RawMimeBuilder.cs b/tests/Smtp2Go.NET.IntegrationTests/Helpers/RawMimeBuilder.cs
new file mode 100644
index 0000000..311ad2c
--- /dev/null
+++ b/tests/Smtp2Go.NET.IntegrationTests/Helpers/RawMimeBuilder.cs
@@ -0,0 +1,65 @@
+namespace Smtp2Go.NET.IntegrationTests.Helpers;
+
+using System.Text;
+
+///
+/// Builds a raw RFC 5322 multipart/alternative MIME message and Base64-encodes it for the SMTP2GO
+/// email/mime endpoint ().
+///
+///
+///
+/// The SDK deliberately takes a pre-built MIME as an opaque Base64 string — it has no MIME library of its own — so
+/// these integration tests construct a minimal valid message by hand rather than pulling one in. That also mirrors
+/// the real contract the endpoint exists to serve: a caller hands the SDK a finished RFC 5322 message and SMTP2GO
+/// transmits it verbatim (preserving its Content-Type and Message-ID) instead of reconstructing it from
+/// structured fields.
+///
+///
+internal static class RawMimeBuilder
+{
+ #region Methods
+
+ ///
+ /// Builds a multipart/alternative message (a plain-text part and an HTML part) and returns its Base64
+ /// encoding, ready to assign to .
+ ///
+ /// The From address; for live sends this must be a sender verified in the SMTP2GO account.
+ /// The recipient address.
+ /// The subject line.
+ /// The Message-ID content; emitted wrapped in angle brackets.
+ /// The plain-text alternative.
+ /// The HTML alternative.
+ public static string BuildMultipartAlternativeBase64(
+ string from,
+ string to,
+ string subject,
+ string messageId,
+ string textBody,
+ string htmlBody)
+ {
+ var boundary = $"alt-{Guid.NewGuid():N}";
+
+ var mime = new StringBuilder()
+ .Append($"From: {from}\r\n")
+ .Append($"To: {to}\r\n")
+ .Append($"Subject: {subject}\r\n")
+ .Append($"Message-ID: <{messageId}>\r\n")
+ .Append("MIME-Version: 1.0\r\n")
+ .Append($"Content-Type: multipart/alternative; boundary=\"{boundary}\"\r\n")
+ .Append("\r\n")
+ .Append($"--{boundary}\r\n")
+ .Append("Content-Type: text/plain; charset=utf-8\r\n")
+ .Append("\r\n")
+ .Append(textBody).Append("\r\n")
+ .Append($"--{boundary}\r\n")
+ .Append("Content-Type: text/html; charset=utf-8\r\n")
+ .Append("\r\n")
+ .Append(htmlBody).Append("\r\n")
+ .Append($"--{boundary}--\r\n")
+ .ToString();
+
+ return Convert.ToBase64String(Encoding.UTF8.GetBytes(mime));
+ }
+
+ #endregion
+}
diff --git a/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookDeliveryIntegrationTests.cs b/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookDeliveryIntegrationTests.cs
index a7ba415..680584d 100644
--- a/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookDeliveryIntegrationTests.cs
+++ b/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookDeliveryIntegrationTests.cs
@@ -1,7 +1,5 @@
namespace Smtp2Go.NET.IntegrationTests.Webhooks;
-using System.Net.Http.Headers;
-using System.Text;
using Fixtures;
using Helpers;
using Smtp2Go.NET.Models.Email;
@@ -34,23 +32,6 @@ namespace Smtp2Go.NET.IntegrationTests.Webhooks;
[Trait("Category", "Integration.Webhook")]
public sealed class WebhookDeliveryIntegrationTests : IClassFixture
{
- #region Constants & Statics
-
- ///
- /// Arbitrary Basic Auth username for the webhook receiver.
- /// We define this when creating the webhook — it is NOT an external secret.
- ///
- private const string WebhookUsername = "test-webhook-user";
-
- ///
- /// Arbitrary Basic Auth password for the webhook receiver.
- /// We define this when creating the webhook — it is NOT an external secret.
- ///
- private const string WebhookPassword = "test-webhook-pass";
-
- #endregion
-
-
#region Properties & Fields - Non-Public
/// The live-configured client fixture.
@@ -141,7 +122,7 @@ public async Task SendEmail_ReceivesDeliveredWebhook()
}
finally
{
- await CleanupWebhookAsync(webhookId, ct);
+ await WebhookPipelineHelper.CleanupWebhookAsync(_fixture.Client, webhookId, ct);
}
}
@@ -220,7 +201,7 @@ public async Task SendEmail_ToNonExistentDomain_ReceivesHardBounceWebhook()
}
finally
{
- await CleanupWebhookAsync(webhookId, ct);
+ await WebhookPipelineHelper.CleanupWebhookAsync(_fixture.Client, webhookId, ct);
}
}
@@ -230,77 +211,29 @@ public async Task SendEmail_ToNonExistentDomain_ReceivesHardBounceWebhook()
#region Methods - Private
///
- /// Sets up the full webhook delivery pipeline: starts the local receiver, creates a
- /// Cloudflare Quick Tunnel, verifies POST reachability, and registers a webhook with SMTP2GO.
+ /// Sets up the full webhook delivery pipeline: establishes a reachable tunnel-backed receiver
+ /// URL (via ) and registers a webhook with SMTP2GO for the
+ /// specified events.
///
- ///
- ///
- /// This method consolidates the common setup sequence shared by all webhook delivery tests:
- ///
- /// - Start the local webhook receiver on a random port
- /// - Create a Cloudflare Quick Tunnel to the receiver
- /// - Wait for DNS propagation so the tunnel is reachable
- /// - Verify the tunnel accepts POST requests (self-test through the tunnel)
- /// - Clear self-test payloads to prevent interference with WaitForPayloadAsync
- /// - Build the webhook URL with Basic Auth credentials embedded (RFC 3986 userinfo)
- /// - Register the webhook with SMTP2GO for the specified events
- ///
- ///
- ///
/// The webhook receiver fixture (must be freshly created, not yet started).
/// The tunnel manager (must be freshly created, not yet started).
/// The subscription-level events to register the webhook for.
/// Cancellation token.
- /// The SMTP2GO webhook ID for cleanup via .
+ /// The SMTP2GO webhook ID for cleanup via .
private async Task SetupWebhookPipelineAsync(
WebhookReceiverFixture receiver,
CloudflareTunnelManager tunnel,
WebhookCreateEvent[] events,
CancellationToken ct)
{
- // Step 1: Start the local webhook receiver.
- await receiver.StartAsync(WebhookUsername, WebhookPassword);
-
- // Step 2: Create a Cloudflare Quick Tunnel to the receiver.
- var publicUrl = await tunnel.StartTunnelAsync(receiver.Port);
-
- // Step 2b: Wait for the tunnel to become reachable via DNS propagation.
- // Quick Tunnels need time for DNS records to propagate globally.
- var healthUrl = $"{publicUrl}{WebhookReceiverFixture.HealthPath}";
- var isReachable = await tunnel.WaitForTunnelReachableAsync(healthUrl);
-
- if (!isReachable)
- Assert.Fail($"Cloudflare tunnel {publicUrl} did not become reachable within 60 seconds (DNS propagation timeout).");
-
- // Step 2c: Verify the tunnel accepts POST requests by sending a self-test POST
- // through the tunnel. This confirms the full chain works for POST (not just GET).
- // Cloudflare Quick Tunnels may have WAF/Bot protection that blocks POSTs from
- // external services, so this step isolates tunnel-vs-SMTP2GO issues.
- var webhookPathUrl = $"{publicUrl}{WebhookReceiverFixture.WebhookPath}";
- await VerifyTunnelAcceptsPostAsync(webhookPathUrl);
-
- // Clear the self-test payload so it doesn't interfere with WaitForPayloadAsync.
- receiver.ClearReceivedPayloads();
-
- // Build the webhook URL with Basic Auth credentials embedded in the URI.
- // SMTP2GO requires credentials in the URL itself (RFC 3986 userinfo component),
- // NOT as separate API fields. The webhook_username/webhook_password API fields
- // are silently ignored — SMTP2GO extracts credentials from the URL and sends them
- // as an Authorization: Basic header when delivering webhook callbacks.
- var tunnelUri = new Uri(publicUrl);
- var webhookUri = new UriBuilder(tunnelUri)
- {
- UserName = Uri.EscapeDataString(WebhookUsername),
- Password = Uri.EscapeDataString(WebhookPassword),
- Path = WebhookReceiverFixture.WebhookPath
- };
- var webhookUrl = webhookUri.Uri.AbsoluteUri;
+ // Stand up the local receiver behind a Cloudflare Quick Tunnel and get the reachable URL.
+ var webhookUrl = await WebhookPipelineHelper.EstablishReachableWebhookUrlAsync(receiver, tunnel);
- // Step 3: Delete any stale webhooks from previous runs.
+ // Delete any stale webhooks from previous runs.
// SMTP2GO free tier allows only 1 webhook — a stale webhook from a failed run blocks creation.
- await DeleteAllExistingWebhooksAsync(ct);
+ await WebhookPipelineHelper.DeleteAllExistingWebhooksAsync(_fixture.Client, ct);
- // Step 4: Register the webhook with SMTP2GO.
+ // Register the webhook with SMTP2GO for the requested events.
var createRequest = new WebhookCreateRequest
{
WebhookUrl = webhookUrl,
@@ -318,56 +251,6 @@ private async Task SetupWebhookPipelineAsync(
}
- ///
- /// Deletes all existing webhooks on the SMTP2GO account.
- /// SMTP2GO free tier limits accounts to 1 webhook — stale webhooks from
- /// previous failed runs block creation of new ones.
- ///
- private async Task DeleteAllExistingWebhooksAsync(CancellationToken ct)
- {
- var listResponse = await _fixture.Client.Webhooks.ListAsync(ct);
-
- if (listResponse.Data is not { Length: > 0 })
- return;
-
- foreach (var webhook in listResponse.Data)
- {
- if (webhook.WebhookId is { } id)
- {
- try
- {
- await _fixture.Client.Webhooks.DeleteAsync(id, ct);
- }
- catch
- {
- // Best-effort cleanup — continue with remaining webhooks.
- }
- }
- }
- }
-
-
- ///
- /// Best-effort webhook cleanup. Silently ignores errors to prevent masking test failures.
- ///
- /// The webhook ID to delete, or null if no webhook was created.
- /// Cancellation token.
- private async Task CleanupWebhookAsync(int? webhookId, CancellationToken ct)
- {
- if (webhookId == null)
- return;
-
- try
- {
- await _fixture.Client.Webhooks.DeleteAsync(webhookId.Value, ct);
- }
- catch
- {
- // Best-effort cleanup.
- }
- }
-
-
///
/// Logs all received payloads and raw bodies for debugging failed webhook delivery tests.
///
@@ -381,42 +264,5 @@ private static void LogReceivedPayloads(string testName, WebhookReceiverFixture
Console.Error.WriteLine($"[{testName}] Raw body: {raw}");
}
-
- ///
- /// Sends a test POST through the Cloudflare tunnel to verify that POST requests
- /// are proxied correctly. Uses the DoH-bypassing HTTP client to avoid DNS cache issues.
- ///
- ///
- /// This self-test isolates tunnel configuration issues from SMTP2GO delivery issues.
- /// If this step fails, the tunnel does not support POSTs (e.g., Cloudflare WAF blocking).
- /// If this step succeeds but SMTP2GO never calls back, the issue is on SMTP2GO's side.
- ///
- private static async Task VerifyTunnelAcceptsPostAsync(string webhookUrl)
- {
- using var client = CloudflareTunnelManager.CreateDnsBypassingHttpClient();
-
- // Build a Basic Auth header matching the test credentials.
- var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{WebhookUsername}:{WebhookPassword}"));
- client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authValue);
-
- // Send a minimal JSON POST body — the receiver will attempt to deserialize it.
- var content = new StringContent(
- """{"event": "test", "hostname": "self-test"}""",
- Encoding.UTF8,
- "application/json");
-
- var response = await client.PostAsync(webhookUrl, content);
-
- Console.Error.WriteLine($"[WebhookDeliveryTest] Self-POST verification: HTTP {(int)response.StatusCode}");
-
- if (!response.IsSuccessStatusCode)
- {
- Assert.Fail(
- $"Cloudflare tunnel does not accept POST requests. " +
- $"Self-POST to {webhookUrl} returned HTTP {(int)response.StatusCode}. " +
- $"This may indicate Cloudflare WAF/Bot protection is blocking POSTs.");
- }
- }
-
#endregion
}
diff --git a/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookManagementIntegrationTests.cs b/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookManagementIntegrationTests.cs
index a459a52..9c94d9d 100644
--- a/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookManagementIntegrationTests.cs
+++ b/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookManagementIntegrationTests.cs
@@ -4,6 +4,13 @@ namespace Smtp2Go.NET.IntegrationTests.Webhooks;
using Helpers;
using Smtp2Go.NET.Models.Webhooks;
+///
+/// Collection definition for webhook tests — ensures they run sequentially
+/// because SMTP2GO free tier limits the account to 1 webhook at a time.
+///
+[CollectionDefinition("Webhook")]
+public class WebhookTestCollection;
+
///
/// Integration tests for webhook CRUD lifecycle operations using the live API key.
///
@@ -12,16 +19,18 @@ namespace Smtp2Go.NET.IntegrationTests.Webhooks;
/// These tests create, list, and delete real webhooks on the SMTP2GO account.
/// Each test cleans up after itself by deleting any webhooks it creates.
///
+///
+/// Reachable URL required. SMTP2GO validates that a webhook URL points to a
+/// reachable destination at webhook/add time (it rejects a fabricated URL with HTTP 400
+/// "The passed URL must point to a valid destination"), so these tests stand up a local receiver
+/// behind a Cloudflare Quick Tunnel (via ) to obtain a real
+/// URL. That makes them depend on cloudflared, so they carry the
+/// Integration.Webhook trait and are excluded from CI alongside the delivery tests
+/// (CI has no tunnel infrastructure); they run locally.
+///
///
-///
-/// Collection definition for webhook tests — ensures they run sequentially
-/// because SMTP2GO free tier limits the account to 1 webhook at a time.
-///
-[CollectionDefinition("Webhook")]
-public class WebhookTestCollection;
-
[Collection("Webhook")]
-[Trait("Category", "Integration.Live")]
+[Trait("Category", "Integration.Webhook")]
public sealed class WebhookManagementIntegrationTests : IClassFixture
{
#region Properties & Fields - Non-Public
@@ -45,59 +54,33 @@ public WebhookManagementIntegrationTests(Smtp2GoLiveFixture fixture)
#endregion
- #region Methods - Helpers
-
- ///
- /// Deletes all existing webhooks on the SMTP2GO account.
- /// SMTP2GO free tier limits accounts to 1 webhook — stale webhooks from
- /// previous failed runs or E2E tests block creation of new ones.
- ///
- private async Task DeleteAllExistingWebhooksAsync(CancellationToken ct)
- {
- var listResponse = await _fixture.Client.Webhooks.ListAsync(ct);
-
- if (listResponse.Data is not { Length: > 0 })
- return;
-
- foreach (var webhook in listResponse.Data)
- {
- if (webhook.WebhookId is { } id)
- {
- try
- {
- await _fixture.Client.Webhooks.DeleteAsync(id, ct);
- }
- catch
- {
- // Best-effort cleanup — continue with remaining webhooks.
- }
- }
- }
- }
-
- #endregion
-
-
#region Webhook Lifecycle
[Fact]
public async Task WebhookLifecycle_CreateListDelete_Succeeds()
{
- // Fail if live secrets are not configured.
+ // Fail if live secrets are not configured, or if cloudflared is unavailable for the tunnel.
TestSecretValidator.AssertLiveSecretsPresent();
+ TestSecretValidator.AssertCloudflaredInstalled();
var ct = TestContext.Current.CancellationToken;
int? webhookId = null;
+ await using var receiver = new WebhookReceiverFixture();
+ await using var tunnel = new CloudflareTunnelManager();
+
+ // SMTP2GO requires a reachable destination, so create a real tunnel-backed receiver URL.
+ var webhookUrl = await WebhookPipelineHelper.EstablishReachableWebhookUrlAsync(receiver, tunnel);
+
// SMTP2GO free tier allows only 1 webhook — clear stale webhooks from previous runs.
- await DeleteAllExistingWebhooksAsync(ct);
+ await WebhookPipelineHelper.DeleteAllExistingWebhooksAsync(_fixture.Client, ct);
try
{
// Step 1: Create a webhook.
var createRequest = new WebhookCreateRequest
{
- WebhookUrl = $"https://webhook-test.example.com/smtp2go/{Guid.NewGuid():N}",
+ WebhookUrl = webhookUrl,
Events = [WebhookCreateEvent.Delivered, WebhookCreateEvent.Bounce]
};
@@ -145,17 +128,7 @@ public async Task WebhookLifecycle_CreateListDelete_Succeeds()
finally
{
// Cleanup: Delete the webhook if the test failed midway.
- if (webhookId != null)
- {
- try
- {
- await _fixture.Client.Webhooks.DeleteAsync(webhookId.Value, ct);
- }
- catch
- {
- // Best-effort cleanup.
- }
- }
+ await WebhookPipelineHelper.CleanupWebhookAsync(_fixture.Client, webhookId, ct);
}
}
@@ -163,14 +136,21 @@ public async Task WebhookLifecycle_CreateListDelete_Succeeds()
[Fact]
public async Task WebhookCreate_WithSpecificEvents_ConfiguresCorrectly()
{
- // Fail if live secrets are not configured.
+ // Fail if live secrets are not configured, or if cloudflared is unavailable for the tunnel.
TestSecretValidator.AssertLiveSecretsPresent();
+ TestSecretValidator.AssertCloudflaredInstalled();
var ct = TestContext.Current.CancellationToken;
int? webhookId = null;
+ await using var receiver = new WebhookReceiverFixture();
+ await using var tunnel = new CloudflareTunnelManager();
+
+ // SMTP2GO requires a reachable destination, so create a real tunnel-backed receiver URL.
+ var webhookUrl = await WebhookPipelineHelper.EstablishReachableWebhookUrlAsync(receiver, tunnel);
+
// SMTP2GO free tier allows only 1 webhook — clear stale webhooks from previous runs.
- await DeleteAllExistingWebhooksAsync(ct);
+ await WebhookPipelineHelper.DeleteAllExistingWebhooksAsync(_fixture.Client, ct);
try
{
@@ -180,7 +160,7 @@ public async Task WebhookCreate_WithSpecificEvents_ConfiguresCorrectly()
// NOT callback payload events (e.g., "hard_bounced", "spam_complaint").
var createRequest = new WebhookCreateRequest
{
- WebhookUrl = $"https://webhook-test.example.com/smtp2go/{Guid.NewGuid():N}",
+ WebhookUrl = webhookUrl,
Events =
[
WebhookCreateEvent.Processed,
@@ -206,17 +186,7 @@ public async Task WebhookCreate_WithSpecificEvents_ConfiguresCorrectly()
finally
{
// Cleanup.
- if (webhookId != null)
- {
- try
- {
- await _fixture.Client.Webhooks.DeleteAsync(webhookId.Value, ct);
- }
- catch
- {
- // Best-effort cleanup.
- }
- }
+ await WebhookPipelineHelper.CleanupWebhookAsync(_fixture.Client, webhookId, ct);
}
}
diff --git a/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookPipelineHelper.cs b/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookPipelineHelper.cs
new file mode 100644
index 0000000..f471826
--- /dev/null
+++ b/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookPipelineHelper.cs
@@ -0,0 +1,205 @@
+namespace Smtp2Go.NET.IntegrationTests.Webhooks;
+
+using System.Net.Http.Headers;
+using System.Text;
+using Fixtures;
+using Smtp2Go.NET.Models.Webhooks;
+
+///
+/// Shared setup for live webhook integration tests: brings up a local receiver behind a
+/// Cloudflare Quick Tunnel and yields a publicly reachable webhook URL, plus account-level
+/// webhook cleanup helpers.
+///
+///
+///
+/// SMTP2GO validates that a webhook URL points to a reachable destination when the webhook is
+/// created (webhook/add returns HTTP 400 "The passed URL must point to a valid
+/// destination" otherwise), so both the delivery tests and the management/CRUD tests need a
+/// real, reachable endpoint — a fabricated URL is rejected. This helper is the single source of
+/// truth for standing that endpoint up, so the two webhook test classes do not duplicate the
+/// receiver/tunnel pipeline.
+///
+///
+/// Prerequisites: cloudflared must be installed and the live API key
+/// configured. The webhook Basic Auth credentials below are arbitrary test constants — we define
+/// them when creating the webhook, so they are NOT external secrets.
+///
+///
+internal static class WebhookPipelineHelper
+{
+ #region Constants & Statics
+
+ ///
+ /// Arbitrary Basic Auth username for the webhook receiver.
+ /// We define this when creating the webhook — it is NOT an external secret.
+ ///
+ public const string WebhookUsername = "test-webhook-user";
+
+ ///
+ /// Arbitrary Basic Auth password for the webhook receiver.
+ /// We define this when creating the webhook — it is NOT an external secret.
+ ///
+ public const string WebhookPassword = "test-webhook-pass";
+
+ #endregion
+
+
+ #region Methods
+
+ ///
+ /// Starts the local receiver, opens a Cloudflare Quick Tunnel to it, waits for the tunnel to
+ /// become reachable, verifies it accepts POSTs, and returns the reachable webhook URL with the
+ /// Basic Auth credentials embedded (RFC 3986 userinfo) — the form SMTP2GO accepts at
+ /// webhook/add.
+ ///
+ ///
+ ///
+ /// This consolidates the common setup sequence:
+ ///
+ /// - Start the local webhook receiver on a random port
+ /// - Create a Cloudflare Quick Tunnel to the receiver
+ /// - Wait for DNS propagation so the tunnel is reachable
+ /// - Verify the tunnel accepts POST requests (self-test through the tunnel)
+ /// - Clear self-test payloads so they don't interfere with WaitForPayloadAsync
+ /// - Build the webhook URL with Basic Auth credentials embedded (RFC 3986 userinfo)
+ ///
+ ///
+ ///
+ /// The webhook receiver fixture (must be freshly created, not yet started).
+ /// The tunnel manager (must be freshly created, not yet started).
+ /// The reachable webhook URL with Basic Auth credentials embedded.
+ public static async Task EstablishReachableWebhookUrlAsync(
+ WebhookReceiverFixture receiver,
+ CloudflareTunnelManager tunnel)
+ {
+ // Step 1: Start the local webhook receiver.
+ await receiver.StartAsync(WebhookUsername, WebhookPassword);
+
+ // Step 2: Create a Cloudflare Quick Tunnel to the receiver.
+ var publicUrl = await tunnel.StartTunnelAsync(receiver.Port);
+
+ // Step 2b: Wait for the tunnel to become reachable via DNS propagation.
+ // Quick Tunnels need time for DNS records to propagate globally.
+ var healthUrl = $"{publicUrl}{WebhookReceiverFixture.HealthPath}";
+ var isReachable = await tunnel.WaitForTunnelReachableAsync(healthUrl);
+
+ if (!isReachable)
+ Assert.Fail($"Cloudflare tunnel {publicUrl} did not become reachable within 60 seconds (DNS propagation timeout).");
+
+ // Step 2c: Verify the tunnel accepts POST requests by sending a self-test POST
+ // through the tunnel. This confirms the full chain works for POST (not just GET).
+ // Cloudflare Quick Tunnels may have WAF/Bot protection that blocks POSTs from
+ // external services, so this step isolates tunnel-vs-SMTP2GO issues.
+ var webhookPathUrl = $"{publicUrl}{WebhookReceiverFixture.WebhookPath}";
+ await VerifyTunnelAcceptsPostAsync(webhookPathUrl);
+
+ // Clear the self-test payload so it doesn't interfere with WaitForPayloadAsync.
+ receiver.ClearReceivedPayloads();
+
+ // Build the webhook URL with Basic Auth credentials embedded in the URI.
+ // SMTP2GO requires credentials in the URL itself (RFC 3986 userinfo component),
+ // NOT as separate API fields. The webhook_username/webhook_password API fields
+ // are silently ignored — SMTP2GO extracts credentials from the URL and sends them
+ // as an Authorization: Basic header when delivering webhook callbacks.
+ var webhookUri = new UriBuilder(new Uri(publicUrl))
+ {
+ UserName = Uri.EscapeDataString(WebhookUsername),
+ Password = Uri.EscapeDataString(WebhookPassword),
+ Path = WebhookReceiverFixture.WebhookPath
+ };
+
+ return webhookUri.Uri.AbsoluteUri;
+ }
+
+
+ ///
+ /// Deletes all existing webhooks on the SMTP2GO account.
+ /// SMTP2GO free tier limits accounts to 1 webhook — stale webhooks from
+ /// previous failed runs block creation of new ones.
+ ///
+ /// The live-configured SMTP2GO client.
+ /// Cancellation token.
+ public static async Task DeleteAllExistingWebhooksAsync(ISmtp2GoClient client, CancellationToken ct)
+ {
+ var listResponse = await client.Webhooks.ListAsync(ct);
+
+ if (listResponse.Data is not { Length: > 0 })
+ return;
+
+ foreach (var webhook in listResponse.Data)
+ {
+ if (webhook.WebhookId is { } id)
+ {
+ try
+ {
+ await client.Webhooks.DeleteAsync(id, ct);
+ }
+ catch
+ {
+ // Best-effort cleanup — continue with remaining webhooks.
+ }
+ }
+ }
+ }
+
+
+ ///
+ /// Best-effort webhook cleanup. Silently ignores errors to prevent masking test failures.
+ ///
+ /// The live-configured SMTP2GO client.
+ /// The webhook ID to delete, or null if no webhook was created.
+ /// Cancellation token.
+ public static async Task CleanupWebhookAsync(ISmtp2GoClient client, int? webhookId, CancellationToken ct)
+ {
+ if (webhookId == null)
+ return;
+
+ try
+ {
+ await client.Webhooks.DeleteAsync(webhookId.Value, ct);
+ }
+ catch
+ {
+ // Best-effort cleanup.
+ }
+ }
+
+
+ ///
+ /// Sends a test POST through the Cloudflare tunnel to verify that POST requests
+ /// are proxied correctly. Uses the DoH-bypassing HTTP client to avoid DNS cache issues.
+ ///
+ ///
+ /// This self-test isolates tunnel configuration issues from SMTP2GO delivery issues.
+ /// If this step fails, the tunnel does not support POSTs (e.g., Cloudflare WAF blocking).
+ /// If this step succeeds but SMTP2GO never calls back, the issue is on SMTP2GO's side.
+ ///
+ private static async Task VerifyTunnelAcceptsPostAsync(string webhookUrl)
+ {
+ using var client = CloudflareTunnelManager.CreateDnsBypassingHttpClient();
+
+ // Build a Basic Auth header matching the test credentials.
+ var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{WebhookUsername}:{WebhookPassword}"));
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authValue);
+
+ // Send a minimal JSON POST body — the receiver will attempt to deserialize it.
+ var content = new StringContent(
+ """{"event": "test", "hostname": "self-test"}""",
+ Encoding.UTF8,
+ "application/json");
+
+ var response = await client.PostAsync(webhookUrl, content);
+
+ Console.Error.WriteLine($"[WebhookPipeline] Self-POST verification: HTTP {(int)response.StatusCode}");
+
+ if (!response.IsSuccessStatusCode)
+ {
+ Assert.Fail(
+ $"Cloudflare tunnel does not accept POST requests. " +
+ $"Self-POST to {webhookUrl} returned HTTP {(int)response.StatusCode}. " +
+ $"This may indicate Cloudflare WAF/Bot protection is blocking POSTs.");
+ }
+ }
+
+ #endregion
+}
diff --git a/tests/Smtp2Go.NET.UnitTests/Models/EmailMimeRequestSerializationTests.cs b/tests/Smtp2Go.NET.UnitTests/Models/EmailMimeRequestSerializationTests.cs
new file mode 100644
index 0000000..96ca172
--- /dev/null
+++ b/tests/Smtp2Go.NET.UnitTests/Models/EmailMimeRequestSerializationTests.cs
@@ -0,0 +1,54 @@
+namespace Smtp2Go.NET.UnitTests.Models;
+
+using System.Text.Json;
+using Smtp2Go.NET.Internal;
+using Smtp2Go.NET.Models.Email;
+
+///
+/// Verifies that serializes to the SMTP2GO POST /email/mime body shape: a
+/// single snake_case mime_email field carrying the base64-encoded raw MIME message. The endpoint takes only
+/// this field (the API key travels in the X-Smtp2go-Api-Key header set by the client); all addressing and
+/// headers live inside the MIME itself.
+///
+[Trait("Category", "Unit")]
+public sealed class EmailMimeRequestSerializationTests
+{
+ #region Serialization
+
+ [Fact]
+ public void Serialize_ProducesSnakeCaseMimeEmailField()
+ {
+ // Arrange — the payload is an opaque base64 string as far as the wire contract is concerned.
+ var request = new EmailMimeRequest { MimeEmail = "TUlNRS1ib2R5" };
+
+ // Act
+ var json = JsonSerializer.Serialize(request, Smtp2GoJsonDefaults.Options);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ // Assert — the single field is emitted as snake_case mime_email carrying the payload.
+ root.GetProperty("mime_email").GetString().Should().Be("TUlNRS1ib2R5");
+ }
+
+
+ [Fact]
+ public void Serialize_DoesNotEmitSendEnvelopeFields()
+ {
+ // Arrange — email/mime carries no envelope; sender/to/subject/headers all live inside the MIME blob.
+ var request = new EmailMimeRequest { MimeEmail = "TUlNRS1ib2R5" };
+
+ // Act
+ var json = JsonSerializer.Serialize(request, Smtp2GoJsonDefaults.Options);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ // Assert — none of the email/send envelope fields leak into the mime request.
+ root.TryGetProperty("sender", out _).Should().BeFalse();
+ root.TryGetProperty("to", out _).Should().BeFalse();
+ root.TryGetProperty("subject", out _).Should().BeFalse();
+ root.TryGetProperty("html_body", out _).Should().BeFalse();
+ root.TryGetProperty("custom_headers", out _).Should().BeFalse();
+ }
+
+ #endregion
+}
diff --git a/tests/Smtp2Go.NET.UnitTests/Smtp2GoClientTests.cs b/tests/Smtp2Go.NET.UnitTests/Smtp2GoClientTests.cs
index a79a525..8b7362e 100644
--- a/tests/Smtp2Go.NET.UnitTests/Smtp2GoClientTests.cs
+++ b/tests/Smtp2Go.NET.UnitTests/Smtp2GoClientTests.cs
@@ -217,6 +217,106 @@ public async Task SendEmailAsync_WithServerError_ThrowsSmtp2GoApiException()
#endregion
+ #region SendMimeAsync
+
+ [Fact]
+ public async Task SendMimeAsync_WithValidRequest_ReturnsResponse()
+ {
+ // Arrange — email/mime returns the same envelope as email/send.
+ var responseJson = JsonSerializer.Serialize(new
+ {
+ request_id = "req-mime-123",
+ data = new
+ {
+ succeeded = 1,
+ failed = 0,
+ email_id = "mime-email-abc"
+ }
+ });
+
+ var (client, _, _) = CreateClient(
+ new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(responseJson, System.Text.Encoding.UTF8, "application/json")
+ });
+
+ var request = new EmailMimeRequest { MimeEmail = "TUlNRS1ib2R5" };
+
+ // Act
+ var response = await client.SendMimeAsync(request, TestContext.Current.CancellationToken);
+
+ // Assert
+ response.Should().NotBeNull();
+ response.RequestId.Should().Be("req-mime-123");
+ response.Data.Should().NotBeNull();
+ response.Data!.Succeeded.Should().Be(1);
+ response.Data.EmailId.Should().Be("mime-email-abc");
+ }
+
+
+ [Fact]
+ public async Task SendMimeAsync_PostsToEmailMimeEndpoint()
+ {
+ // Arrange
+ var (client, _, handler) = CreateClient();
+
+ // Act
+ await client.SendMimeAsync(
+ new EmailMimeRequest { MimeEmail = "TUlNRS1ib2R5" },
+ TestContext.Current.CancellationToken);
+
+ // Assert — the raw-MIME send must target email/mime, not email/send.
+ handler.LastRequest.Should().NotBeNull();
+ handler.LastRequest!.RequestUri!.AbsolutePath.Should().EndWith("/email/mime");
+ }
+
+
+ [Fact]
+ public async Task SendMimeAsync_WithNullRequest_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var (client, _, _) = CreateClient();
+
+ // Act
+ var act = async () => await client.SendMimeAsync(null!);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+
+ [Fact]
+ public async Task SendMimeAsync_WithApiError_ThrowsSmtp2GoApiException()
+ {
+ // Arrange
+ var errorJson = JsonSerializer.Serialize(new
+ {
+ request_id = "req-mime-err",
+ data = new
+ {
+ error = "Endpoint permission denied",
+ error_code = "E_ApiResponseCodes.ENDPOINT_PERMISSION_DENIED"
+ }
+ });
+
+ var (client, _, _) = CreateClient(
+ new HttpResponseMessage(HttpStatusCode.BadRequest)
+ {
+ Content = new StringContent(errorJson, System.Text.Encoding.UTF8, "application/json")
+ });
+
+ // Act
+ var act = async () => await client.SendMimeAsync(new EmailMimeRequest { MimeEmail = "TUlNRQ==" });
+
+ // Assert
+ var ex = (await act.Should().ThrowAsync()).Which;
+ ex.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ ex.ErrorMessage.Should().Be("Endpoint permission denied");
+ }
+
+ #endregion
+
+
#region Statistics.GetEmailSummaryAsync
[Fact]