diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 82e6e6e..39e78b6 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -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 @@ -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 @@ -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. diff --git a/src/Smtp2Go.NET/ISmtp2GoClient.cs b/src/Smtp2Go.NET/ISmtp2GoClient.cs index 6837163..05b841d 100644 --- a/src/Smtp2Go.NET/ISmtp2GoClient.cs +++ b/src/Smtp2Go.NET/ISmtp2GoClient.cs @@ -71,4 +71,21 @@ public interface ISmtp2GoClient /// Thrown when the SMTP2GO API returns an error. /// Thrown when the HTTP request fails. Task SendEmailAsync(EmailSendRequest request, CancellationToken ct = default); + + /// + /// Sends a pre-built MIME message via the SMTP2GO email/mime endpoint. + /// + /// The request carrying the Base64-encoded MIME message. + /// The cancellation token. + /// The send response containing success/failure counts and the assigned email id. + /// + /// + /// Unlike , SMTP2GO sends the supplied MIME verbatim instead of reconstructing it + /// from structured fields — preserving the caller's Content-Type (e.g. multipart/alternative) and + /// Message-ID. The response envelope is identical to email/send. + /// + /// + /// Thrown when the SMTP2GO API returns an error. + /// Thrown when the HTTP request fails. + Task SendMimeAsync(EmailMimeRequest request, CancellationToken ct = default); } diff --git a/src/Smtp2Go.NET/Internal/LoggingConstants.cs b/src/Smtp2Go.NET/Internal/LoggingConstants.cs index 2a3ca66..809e406 100644 --- a/src/Smtp2Go.NET/Internal/LoggingConstants.cs +++ b/src/Smtp2Go.NET/Internal/LoggingConstants.cs @@ -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) diff --git a/src/Smtp2Go.NET/Models/Email/EmailMimeRequest.cs b/src/Smtp2Go.NET/Models/Email/EmailMimeRequest.cs new file mode 100644 index 0000000..f619e42 --- /dev/null +++ b/src/Smtp2Go.NET/Models/Email/EmailMimeRequest.cs @@ -0,0 +1,36 @@ +namespace Smtp2Go.NET.Models.Email; + +using System.Text.Json.Serialization; + +/// +/// Request model for the SMTP2GO POST /email/mime endpoint. +/// +/// +/// +/// Unlike — which sends structured fields (sender, to, +/// subject, html_body, …) 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 multipart/alternative body, and a caller-supplied Message-ID +/// that SMTP2GO preserves rather than overwriting. +/// +/// +/// The API key travels in the X-Smtp2go-Api-Key request header (set by the client), so the only body field +/// is . The sender, recipients, subject, and every header are taken from inside the MIME +/// message itself. +/// +/// +public class EmailMimeRequest +{ + /// + /// Gets or sets the complete MIME message to send, Base64-encoded. + /// + /// + /// + /// The value must be a valid RFC 5322 / MIME message (headers + body) that has then been Base64-encoded. + /// SMTP2GO sends this exact message; the From, To, Subject, and all headers are read from + /// within it. + /// + /// + [JsonPropertyName("mime_email")] + public required string MimeEmail { get; set; } +} diff --git a/src/Smtp2Go.NET/Smtp2Go.NET.csproj b/src/Smtp2Go.NET/Smtp2Go.NET.csproj index 9d7f187..7456388 100644 --- a/src/Smtp2Go.NET/Smtp2Go.NET.csproj +++ b/src/Smtp2Go.NET/Smtp2Go.NET.csproj @@ -5,7 +5,7 @@ Smtp2Go.NET - 1.2.0 + 1.3.0 A .NET client library for the SMTP2GO email delivery API. Supports sending emails, webhook management, and email statistics with built-in resilience. smtp2go;email;smtp;api;webhook;dotnet diff --git a/src/Smtp2Go.NET/Smtp2GoClient.cs b/src/Smtp2Go.NET/Smtp2GoClient.cs index c41c75f..5946180 100644 --- a/src/Smtp2Go.NET/Smtp2GoClient.cs +++ b/src/Smtp2Go.NET/Smtp2GoClient.cs @@ -33,6 +33,9 @@ internal sealed partial class Smtp2GoClient : Smtp2GoResource, ISmtp2GoClient /// API endpoint for sending emails. private const string EmailSendEndpoint = "email/send"; + /// API endpoint for sending a pre-built (raw) MIME message. + private const string EmailMimeEndpoint = "email/mime"; + #endregion @@ -121,6 +124,22 @@ public async Task SendEmailAsync(EmailSendRequest request, Ca return response; } + + /// + public async Task SendMimeAsync(EmailMimeRequest request, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + + LogMimeSendStarted(request.MimeEmail?.Length ?? 0); + + var response = await PostAsync( + EmailMimeEndpoint, request, ct).ConfigureAwait(false); + + LogEmailSendCompleted(response.Data?.Succeeded ?? 0, response.Data?.Failed ?? 0); + + return response; + } + #endregion @@ -134,5 +153,9 @@ public async Task 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 } diff --git a/tests/Smtp2Go.NET.IntegrationTests/Email/EmailMimeLiveIntegrationTests.cs b/tests/Smtp2Go.NET.IntegrationTests/Email/EmailMimeLiveIntegrationTests.cs new file mode 100644 index 0000000..33efc4f --- /dev/null +++ b/tests/Smtp2Go.NET.IntegrationTests/Email/EmailMimeLiveIntegrationTests.cs @@ -0,0 +1,79 @@ +namespace Smtp2Go.NET.IntegrationTests.Email; + +using Fixtures; +using Helpers; +using Smtp2Go.NET.Models.Email; + +/// +/// Live integration test for the endpoint (email/mime) using the +/// live API key (the message is actually delivered). +/// +/// +/// +/// 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 DR01/DR02). Use with caution: the recipient must be a controlled mailbox. +/// +/// +[Trait("Category", "Integration.Live")] +public sealed class EmailMimeLiveIntegrationTests : IClassFixture +{ + #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]